SIPDAM/samooflutter/lib/tugas/Penugasan.dart

953 lines
46 KiB
Dart

import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:intl/intl.dart';
import 'package:image_picker/image_picker.dart';
import '../models/Penugasan_model.dart';
import '../api/PenugasanApi.dart';
const _bg = Color(0xFFF9FAFB);
const _bg1 = Color(0xFFFFFFFF);
const _bg2 = Color(0xFFF3F4F6);
const _green = Color(0xFF10B981);
const _greenDim = Color(0x1A10B981);
const _cyan = Color(0xFF06B6D4);
const _cyanDim = Color(0x1A06B6D4);
const _amber = Color(0xFFF59E0B);
const _amberDim = Color(0x1AF59E0B);
const _rose = Color(0xFFEF4444);
const _roseDim = Color(0x1AEF4444);
const _t1 = Color(0xFF111827);
const _t2 = Color(0xFF6B7280);
const _t3 = Color(0xFF9CA3AF);
const _line2 = Color(0xFFE5E7EB);
class PenugasanScreen extends StatefulWidget {
@override
_PenugasanScreenState createState() => _PenugasanScreenState();
}
class _PenugasanScreenState extends State<PenugasanScreen> {
final _api = PenugasanApi();
List<PenugasanModel> filteredList = [];
StatistikModel? statistik;
bool isLoading = false;
String? errorMessage;
@override
void initState() {
super.initState();
_loadData();
}
Future<void> _loadData() async {
setState(() { isLoading = true; errorMessage = null; });
try {
final results = await Future.wait([_api.getStatistik(), _api.getPenugasanList()]);
final stat = StatistikModel.fromJson(results[0]['data']);
final items = results[1]['data']['data'] as List? ?? [];
final list = items.map((e) => PenugasanModel.fromJson(e)).toList();
// HANYA tampilkan tugas belum mulai
final filtered = list.where((p) => p.statusPekerjaan == 'belum_mulai').toList();
if (mounted) setState(() { statistik = stat; filteredList = filtered; isLoading = false; });
} on PenugasanApiException catch (e) {
if (mounted) setState(() { errorMessage = e.message; isLoading = false; });
} catch (_) {
if (mounted) setState(() { errorMessage = 'Gagal terhubung ke server.'; isLoading = false; });
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: _bg,
appBar: AppBar(
title: Text('Penugasan Saya', style: TextStyle(fontWeight: FontWeight.w800, fontSize: 16)),
backgroundColor: _bg1,
foregroundColor: _t1,
elevation: 0,
surfaceTintColor: Colors.transparent,
systemOverlayStyle: SystemUiOverlayStyle(statusBarColor: Colors.transparent, statusBarIconBrightness: Brightness.light),
bottom: PreferredSize(
preferredSize: Size.fromHeight(60),
child: Container(
padding: EdgeInsets.fromLTRB(16, 0, 16, 16),
alignment: Alignment.centerLeft,
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text('DAFTAR TUGAS BARU', style: TextStyle(color: _cyan, fontSize: 11, fontWeight: FontWeight.w900, letterSpacing: 1.5)),
SizedBox(height: 4),
Text('Terima tugas dan mulai bekerja', style: TextStyle(color: _t2, fontSize: 12)),
]),
),
),
),
body: _buildBody(),
);
}
Widget _buildStatistikCard() {
if (statistik == null) return SizedBox.shrink();
return Container(
padding: EdgeInsets.all(16),
color: _bg,
child: Row(children: [
Expanded(child: _buildStatItem('Menunggu Detail', statistik!.menungguDetail.toString(), _amber, _amberDim, Icons.pending_actions)),
SizedBox(width: 12),
Expanded(child: _buildStatItem('Detail Lengkap', statistik!.detailLengkap.toString(), _green, _greenDim, Icons.check_circle_outline)),
]),
);
}
Widget _buildStatItem(String label, String value, Color color, Color dimColor, IconData icon) {
return Container(
padding: EdgeInsets.symmetric(horizontal: 14, vertical: 12),
decoration: BoxDecoration(color: _bg1, borderRadius: BorderRadius.circular(13), border: Border.all(color: _line2)),
child: Row(children: [
Container(
width: 38, height: 38,
decoration: BoxDecoration(color: dimColor, borderRadius: BorderRadius.circular(10), border: Border.all(color: color.withOpacity(0.3))),
child: Icon(icon, color: color, size: 20),
),
SizedBox(width: 10),
Expanded(
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(value, style: TextStyle(fontSize: 18, fontWeight: FontWeight.w800, color: color)),
SizedBox(height: 2),
Text(label, style: TextStyle(fontSize: 11, color: _t2), maxLines: 1, overflow: TextOverflow.ellipsis),
]),
),
]),
);
}
Widget _buildBody() {
if (isLoading) return Center(child: CircularProgressIndicator(color: _green));
if (errorMessage != null) return Center(child: Padding(
padding: EdgeInsets.all(24),
child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [
Icon(Icons.wifi_off, size: 60, color: _rose),
SizedBox(height: 16),
Text(errorMessage!, textAlign: TextAlign.center, style: TextStyle(color: _t1)),
SizedBox(height: 20),
ElevatedButton.icon(onPressed: _loadData, icon: Icon(Icons.refresh, size: 18), label: Text('Coba Lagi'),
style: ElevatedButton.styleFrom(backgroundColor: _greenDim, foregroundColor: _green, side: BorderSide(color: _green.withOpacity(0.4)))),
]),
));
if (filteredList.isEmpty) return Center(child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [
Icon(Icons.assignment_outlined, size: 60, color: _t3),
SizedBox(height: 12),
Text('Belum ada penugasan', style: TextStyle(fontSize: 15, color: _t2, fontWeight: FontWeight.w500)),
]));
return RefreshIndicator(
onRefresh: _loadData,
color: _green,
backgroundColor: _bg1,
child: ListView.builder(
padding: EdgeInsets.symmetric(horizontal: 16, vertical: 8),
itemCount: filteredList.length,
itemBuilder: (context, index) => _buildCard(filteredList[index]),
),
);
}
Widget _buildCard(PenugasanModel item) {
return Container(
margin: EdgeInsets.only(bottom: 16),
decoration: BoxDecoration(
color: _bg1,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: _line2),
),
child: ClipRRect(
borderRadius: BorderRadius.circular(16),
child: Material(
color: Colors.transparent,
child: InkWell(
onTap: () => _showDetail(item),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
// Foto & Instruksi Utama
if (item.fotoSuratUrl != null)
Image.network(item.fotoSuratUrl!, height: 160, width: double.infinity, fit: BoxFit.cover,
loadingBuilder: (c, child, p) => p == null ? child : Container(height: 160, color: _bg2, child: Center(child: CircularProgressIndicator(color: _green, strokeWidth: 2))),
errorBuilder: (_, __, ___) => Container(height: 100, color: _bg2, child: Center(child: Icon(Icons.image_not_supported_outlined, color: _t3))))
else
Container(
width: double.infinity,
padding: EdgeInsets.fromLTRB(16, 20, 16, 12),
decoration: BoxDecoration(
gradient: LinearGradient(begin: Alignment.topLeft, end: Alignment.bottomRight, colors: [_bg2, _bg1]),
border: Border(bottom: BorderSide(color: _line2))
),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text('INSTRUKSI LOKASI & TUGAS', style: TextStyle(color: _cyan, fontSize: 10, fontWeight: FontWeight.w900, letterSpacing: 1.5)),
SizedBox(height: 10),
Text(item.catatanAdmin ?? 'Tidak ada catatan lokasi spesifik',
style: TextStyle(color: _t1, fontSize: 14, fontWeight: FontWeight.w600, height: 1.4),
maxLines: 3, overflow: TextOverflow.ellipsis),
]),
),
Padding(
padding: EdgeInsets.all(16),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Row(mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [
Row(children: [
Icon(Icons.calendar_today_outlined, size: 14, color: _t3),
SizedBox(width: 6),
Text(DateFormat('dd MMM yyyy').format(DateTime.parse(item.tanggalDiberikan).toLocal()), style: TextStyle(fontSize: 13, color: _t2)),
]),
_buildStatusBadge(item.statusPekerjaan),
]),
SizedBox(height: 12),
if (item.teknisi != null) Row(children: [
Icon(Icons.person, size: 16, color: _cyan),
SizedBox(width: 8),
Expanded(child: Text(item.namaTim, style: TextStyle(fontSize: 15, fontWeight: FontWeight.w700, color: _t1))),
]),
SizedBox(height: 12),
if (item.isDetailLengkap) ...[
Container(
padding: EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(color: _cyanDim, borderRadius: BorderRadius.circular(8), border: Border.all(color: _cyan.withOpacity(0.3))),
child: Row(mainAxisSize: MainAxisSize.min, children: [
Icon(Icons.build_circle_outlined, size: 14, color: _cyan),
SizedBox(width: 6),
Text(item.labelJenisPekerjaanFallback, style: TextStyle(fontSize: 12, color: _cyan, fontWeight: FontWeight.w600)),
]),
),
],
if (item.alamatLokasi != null) ...[
SizedBox(height: 10),
Container(
padding: EdgeInsets.all(12),
decoration: BoxDecoration(color: _bg2, borderRadius: BorderRadius.circular(10), border: Border.all(color: _line2)),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Row(children: [
Icon(Icons.location_on, size: 14, color: _green),
SizedBox(width: 8),
Expanded(child: Text(item.alamatLokasi!, style: TextStyle(fontSize: 13, color: _t1, fontWeight: FontWeight.w600))),
]),
if (item.namaPelanggan != null || item.noSambungan != null) ...[
SizedBox(height: 8),
Row(children: [
Icon(Icons.person_outline, size: 14, color: _t2),
SizedBox(width: 8),
Text(item.namaPelanggan ?? '-', style: TextStyle(fontSize: 12, color: _t2)),
SizedBox(width: 12),
Icon(Icons.tag, size: 14, color: _t2),
SizedBox(width: 4),
Text(item.noSambungan ?? '-', style: TextStyle(fontSize: 12, color: _t2)),
]),
],
]),
),
],
if (item.catatanAdmin != null) ...[
SizedBox(height: 10),
Container(
padding: EdgeInsets.all(12),
decoration: BoxDecoration(color: _amberDim, borderRadius: BorderRadius.circular(10), border: Border.all(color: _amber.withOpacity(0.3))),
child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [
Icon(Icons.info_outline, size: 16, color: _amber),
SizedBox(width: 8),
Expanded(child: Text(item.catatanAdmin!, style: TextStyle(fontSize: 12, color: _amber, height: 1.4), maxLines: 2, overflow: TextOverflow.ellipsis)),
]),
),
],
SizedBox(height: 14),
Divider(height: 1, color: _line2),
SizedBox(height: 14),
SizedBox(
width: double.infinity,
child: ElevatedButton.icon(
onPressed: () => _terimaTugas(item),
icon: Icon(Icons.check_rounded, size: 18),
label: Text('TERIMA TUGAS', style: TextStyle(fontSize: 13, fontWeight: FontWeight.w800, letterSpacing: 0.8)),
style: ElevatedButton.styleFrom(
backgroundColor: _green,
foregroundColor: Colors.black,
elevation: 0,
padding: EdgeInsets.symmetric(vertical: 14),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
),
),
),
]),
),
]),
),
),
),
);
}
Widget _labelChip(String label, Color color) {
return Container(
padding: EdgeInsets.symmetric(horizontal: 10, vertical: 5),
decoration: BoxDecoration(color: color.withOpacity(0.08), borderRadius: BorderRadius.circular(12), border: Border.all(color: color.withOpacity(0.3))),
child: Text(label, style: TextStyle(fontSize: 10, color: color, fontWeight: FontWeight.w700, letterSpacing: 0.5)),
);
}
Widget _actionButton(String label, IconData icon, Color color, VoidCallback onTap) {
return ElevatedButton.icon(
onPressed: onTap,
icon: Icon(icon, size: 16),
label: Text(label, style: TextStyle(fontWeight: FontWeight.w700, fontSize: 13)),
style: ElevatedButton.styleFrom(
backgroundColor: color.withOpacity(0.15),
foregroundColor: color,
shadowColor: Colors.transparent,
side: BorderSide(color: color.withOpacity(0.5)),
padding: EdgeInsets.symmetric(horizontal: 14, vertical: 8),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10))),
);
}
Widget _buildStatusBadge(String status) {
Color bg, text; String label; IconData icon;
switch (status) {
case 'belum_mulai': bg = _bg2; text = _t2; label = 'Belum Mulai'; icon = Icons.schedule; break;
case 'dalam_proses': bg = _amberDim; text = _amber; label = 'Dalam Proses'; icon = Icons.pending_actions; break;
case 'selesai': bg = _greenDim; text = _green; label = 'Selesai'; icon = Icons.check_circle_outline; break;
default: bg = _bg2; text = _t3; label = status; icon = Icons.help_outline;
}
return Container(
padding: EdgeInsets.symmetric(horizontal: 10, vertical: 5),
decoration: BoxDecoration(color: bg, borderRadius: BorderRadius.circular(12), border: Border.all(color: text.withOpacity(0.3))),
child: Row(mainAxisSize: MainAxisSize.min, children: [Icon(icon, size: 12, color: text), SizedBox(width: 5), Text(label, style: TextStyle(fontSize: 10, fontWeight: FontWeight.w700, color: text, letterSpacing: 0.5))]),
);
}
void _toast(String msg, {bool isError = false}) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text(msg, style: TextStyle(color: _t1)),
backgroundColor: _bg1,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10), side: BorderSide(color: (isError ? _rose : _green).withOpacity(0.5))),
behavior: SnackBarBehavior.floating,
));
}
Future<void> _terimaTugas(PenugasanModel item) async {
setState(() => isLoading = true);
try {
await _api.updateStatus(
idPenugasan: item.idPenugasan,
statusPekerjaan: 'dalam_proses',
tanggalDiselesaikan: null,
);
_loadData();
_toast('Tugas berhasil diterima! Cek menu Progress.');
} catch (e) {
setState(() => isLoading = false);
_toast('Gagal: $e', isError: true);
}
}
void _showFormIsiDetail(PenugasanModel item) {
showModalBottomSheet(
context: context, isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (_) => _FormIsiDetail(item: item, api: _api, onSuccess: () {
Navigator.pop(context);
_loadData();
_toast('Detail berhasil diisi!');
}, onFail: (err) => _toast(err, isError: true)),
);
}
void _showFormUpdateProgres(PenugasanModel item) {
showModalBottomSheet(
context: context, isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (_) => _FormUpdateProgres(item: item, api: _api, onSuccess: () {
Navigator.pop(context);
_loadData();
_toast('Progres berhasil diupdate!');
}, onFail: (err) => _toast(err, isError: true)),
);
}
void _showDetail(PenugasanModel item) {
showModalBottomSheet(
context: context, isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (_) => DraggableScrollableSheet(
expand: false, initialChildSize: 0.75, maxChildSize: 0.95,
builder: (_, controller) => Container(
decoration: BoxDecoration(
color: _bg1,
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
border: Border(top: BorderSide(color: _line2))
),
child: SingleChildScrollView(
controller: controller, padding: EdgeInsets.all(24),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Center(child: Container(width: 48, height: 5, decoration: BoxDecoration(color: _bg2, borderRadius: BorderRadius.circular(3)))),
SizedBox(height: 24),
Row(mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [
Text('Detail Tugas #${item.idPenugasan}', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w800, color: _t1)),
_buildStatusBadge(item.statusPekerjaan),
]),
SizedBox(height: 24),
if (item.fotoSuratUrl != null) ...[
Text('FOTO SURAT TUGAS', style: TextStyle(fontSize: 10, fontWeight: FontWeight.w800, color: _t3, letterSpacing: 1.2)),
SizedBox(height: 10),
ClipRRect(borderRadius: BorderRadius.circular(12),
child: Image.network(item.fotoSuratUrl!, width: double.infinity, fit: BoxFit.cover,
loadingBuilder: (c, child, p) => p == null ? child : Container(height: 180, color: _bg2, child: Center(child: CircularProgressIndicator(color: _green))),
errorBuilder: (_, __, ___) => Container(height: 100, color: _bg2, child: Center(child: Icon(Icons.image_not_supported_outlined, color: _t3))))),
SizedBox(height: 24),
],
_detailRow('Teknisi', item.namaTim),
_detailRow('Tanggal Tugas', DateFormat('dd MMMM yyyy').format(DateTime.parse(item.tanggalDiberikan).toLocal())),
if (item.alamatLokasi != null) _detailRow('Alamat Lokasi', item.alamatLokasi!, isHighlight: true, valColor: _green),
if (item.namaPelanggan != null) _detailRow('Nama Pelanggan', item.namaPelanggan!),
if (item.noSambungan != null) _detailRow('No. Sambungan', item.noSambungan!),
if (item.catatanAdmin != null) _detailRow('Instruksi Tambahan', item.catatanAdmin!),
if (item.isDetailLengkap) ...[
SizedBox(height: 10), Divider(height: 1, color: _line2), SizedBox(height: 16),
Text('DETAIL PEKERJAAN', style: TextStyle(fontSize: 10, fontWeight: FontWeight.w800, color: _cyan, letterSpacing: 1.2)),
SizedBox(height: 12),
_detailRow('Jenis Pekerjaan', item.labelJenisPekerjaanFallback),
if (item.dimensiPipa != null) _detailRow('Dimensi Pipa', 'Dim ${item.dimensiPipa}'),
if (item.jarakMeter != null) _detailRow('Jarak', '${item.jarakMeter} meter'),
if (item.jumlahUnit != null) _detailRow('Jumlah Unit', '${item.jumlahUnit} unit'),
if (item.jumlahTitik != null) _detailRow('Jumlah Titik', '${item.jumlahTitik} titik'),
if (item.pakaiPipaBesi != null) _detailRow('Pakai Pipa Besi', item.pakaiPipaBesi! ? 'Ya' : 'Tidak'),
if (item.jenisPengangkatan != null) _detailRow('Jenis Pengangkatan', item.jenisPengangkatan == 'gate_valve' ? 'Gate Valve' : 'Meteran Air'),
if (item.detailPekerjaan != null) _detailRow('Catatan Teknisi', item.detailPekerjaan!),
if (item.tanggalMulai != null) _detailRow('Tanggal Mulai', item.tanggalMulai!),
if (item.tanggalDiselesaikan != null) _detailRow('Waktu Selesai', item.tanggalDiselesaikan!),
],
if (item.fotoSebelumUrl != null || item.fotoSesudahUrl != null) ...[
SizedBox(height: 10), Divider(height: 1, color: _line2), SizedBox(height: 16),
Text('FOTO BUKTI', style: TextStyle(fontSize: 10, fontWeight: FontWeight.w800, color: _t3, letterSpacing: 1.2)),
SizedBox(height: 12),
Row(children: [
if (item.fotoSebelumUrl != null) Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text('Sebelum', style: TextStyle(fontSize: 12, color: _t2)),
SizedBox(height: 8),
ClipRRect(borderRadius: BorderRadius.circular(10), child: Image.network(item.fotoSebelumUrl!, height: 100, fit: BoxFit.cover, width: double.infinity,
errorBuilder: (_, __, ___) => Container(height: 100, color: _bg2, child: Center(child: Icon(Icons.image_not_supported_outlined, color: _t3))))),
])),
if (item.fotoSebelumUrl != null && item.fotoSesudahUrl != null) SizedBox(width: 16),
if (item.fotoSesudahUrl != null) Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text('Sesudah', style: TextStyle(fontSize: 12, color: _t2)),
SizedBox(height: 8),
ClipRRect(borderRadius: BorderRadius.circular(10), child: Image.network(item.fotoSesudahUrl!, height: 100, fit: BoxFit.cover, width: double.infinity,
errorBuilder: (_, __, ___) => Container(height: 100, color: _bg2, child: Center(child: Icon(Icons.image_not_supported_outlined, color: _t3))))),
])),
]),
],
SizedBox(height: 32),
if (item.statusPekerjaan == 'belum_mulai')
SizedBox(
width: double.infinity,
child: ElevatedButton.icon(
onPressed: () {
Navigator.pop(context);
_terimaTugas(item);
},
icon: Icon(Icons.check_rounded, size: 18),
label: Text('TERIMA TUGAS', style: TextStyle(fontSize: 14, fontWeight: FontWeight.w800, letterSpacing: 0.8)),
style: ElevatedButton.styleFrom(
backgroundColor: _green,
foregroundColor: Colors.black,
elevation: 0,
padding: EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
),
),
),
SizedBox(height: 20),
]),
),
),
),
);
}
Widget _detailRow(String label, String value, {bool isHighlight = false, Color valColor = _t1}) {
return Padding(
padding: EdgeInsets.only(bottom: 12),
child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [
SizedBox(width: 140, child: Text(label, style: TextStyle(fontSize: 13, color: _t2))),
Expanded(child: Container(
padding: isHighlight ? EdgeInsets.all(10) : null,
decoration: isHighlight ? BoxDecoration(color: _bg2, borderRadius: BorderRadius.circular(8), border: Border.all(color: _line2)) : null,
child: Text(value, style: TextStyle(fontSize: isHighlight ? 13 : 13.5, fontWeight: isHighlight ? FontWeight.w500 : FontWeight.w600, color: valColor, height: 1.4))
)),
]),
);
}
ButtonStyle _btnStyle(Color c) => ElevatedButton.styleFrom(
backgroundColor: c, foregroundColor: Colors.black, elevation: 0,
padding: EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
);
}
// ===================================
// FORM ISI DETAIL
// ===================================
class _FormIsiDetail extends StatefulWidget {
final PenugasanModel item;
final PenugasanApi api;
final VoidCallback onSuccess;
final Function(String) onFail;
const _FormIsiDetail({required this.item, required this.api, required this.onSuccess, required this.onFail});
@override
__FormIsiDetailState createState() => __FormIsiDetailState();
}
class __FormIsiDetailState extends State<_FormIsiDetail> {
final _formKey = GlobalKey<FormState>();
bool _isLoading = false;
String? _tanggalMulai;
final _detailController = TextEditingController();
XFile? _fotoSebelum;
final _picker = ImagePicker();
// List item pekerjaan
List<Map<String, dynamic>> _items = [
{
'jenis_pekerjaan': null,
'dimensi_pipa': null,
'jarak_meter': TextEditingController(),
'jumlah_unit': TextEditingController(),
'jumlah_titik': TextEditingController(),
'pakai_pipa_besi': false,
'jenis_pengangkatan': null,
}
];
final List<Map<String, String>> _jenisList = [
{'value': 'sr', 'label': 'SR (Sambungan Rumah)'},
{'value': 'pengembangan_jaringan_pipa', 'label': 'Pengembangan Jaringan Pipa'},
{'value': 'pengangkatan', 'label': 'Pengangkatan'},
{'value': 'pemasangan_gate_valve', 'label': 'Pemasangan Gate Valve'},
{'value': 'gali_urug', 'label': 'Gali Urug'},
{'value': 'perbaikan_jaringan_pipa', 'label': 'Perbaikan Jaringan Pipa'},
{'value': 'pengecatan_pipa_besi', 'label': 'Pengecatan Pipa Besi'},
{'value': 'penyempurnaan_jaringan_pipa', 'label': 'Penyempurnaan Jaringan Pipa'},
];
@override
void dispose() {
_detailController.dispose();
for (var it in _items) {
(it['jarak_meter'] as TextEditingController).dispose();
(it['jumlah_unit'] as TextEditingController).dispose();
(it['jumlah_titik'] as TextEditingController).dispose();
}
super.dispose();
}
void _addItem() {
setState(() {
_items.add({
'jenis_pekerjaan': null,
'dimensi_pipa': null,
'jarak_meter': TextEditingController(),
'jumlah_unit': TextEditingController(),
'jumlah_titik': TextEditingController(),
'pakai_pipa_besi': false,
'jenis_pengangkatan': null,
});
});
}
void _removeItem(int index) {
if (_items.length > 1) {
setState(() {
( _items[index]['jarak_meter'] as TextEditingController).dispose();
( _items[index]['jumlah_unit'] as TextEditingController).dispose();
( _items[index]['jumlah_titik'] as TextEditingController).dispose();
_items.removeAt(index);
});
}
}
Future<void> _pickFoto() async {
final picked = await _picker.pickImage(source: ImageSource.camera, imageQuality: 70);
if (picked != null) setState(() => _fotoSebelum = picked);
}
Future<void> _pickTanggal() async {
final now = DateTime.now();
final picked = await showDatePicker(context: context, initialDate: now, firstDate: DateTime(now.year - 1), lastDate: now,
builder: (ctx, child) => Theme(data: ThemeData.light().copyWith(
colorScheme: ColorScheme.light(primary: _green, onPrimary: Colors.black, surface: _bg1, onSurface: _t1),
dialogBackgroundColor: _bg1,
), child: child!));
if (picked != null) setState(() => _tanggalMulai = DateFormat('yyyy-MM-dd').format(picked));
}
Future<void> _submit() async {
if (!_formKey.currentState!.validate()) return;
if (_tanggalMulai == null) { widget.onFail('Pilih tanggal mulai'); return; }
if (_fotoSebelum == null && !kIsWeb) { widget.onFail('Foto sebelum pekerjaan wajib diisi'); return; }
setState(() => _isLoading = true);
try {
// Persiapkan list items untuk dikirim
final List<Map<String, dynamic>> itemsToSubmit = _items.map((it) {
return {
'jenis_pekerjaan': it['jenis_pekerjaan'],
'dimensi_pipa': it['dimensi_pipa'],
'jarak_meter': (it['jarak_meter'] as TextEditingController).text.isNotEmpty ? double.tryParse((it['jarak_meter'] as TextEditingController).text) : null,
'jumlah_unit': (it['jumlah_unit'] as TextEditingController).text.isNotEmpty ? int.tryParse((it['jumlah_unit'] as TextEditingController).text) : null,
'jumlah_titik': (it['jumlah_titik'] as TextEditingController).text.isNotEmpty ? int.tryParse((it['jumlah_titik'] as TextEditingController).text) : null,
'pakai_pipa_besi': it['pakai_pipa_besi'],
'jenis_pengangkatan': it['jenis_pengangkatan'],
};
}).toList();
await widget.api.lengkapiDetail(
idPenugasan: widget.item.idPenugasan,
items: itemsToSubmit,
tanggalMulai: _tanggalMulai!,
detailPekerjaan: _detailController.text.isNotEmpty ? _detailController.text : null,
fotoSebelum: _fotoSebelum,
);
widget.onSuccess();
} on PenugasanApiException catch (e) {
setState(() => _isLoading = false); widget.onFail(e.getFirstError() ?? e.message);
} catch (e) {
setState(() => _isLoading = false); widget.onFail('Gagal: $e');
}
}
@override
Widget build(BuildContext context) {
return Padding(
padding: EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom),
child: DraggableScrollableSheet(
expand: false, initialChildSize: 0.85, maxChildSize: 0.95,
builder: (_, controller) => Container(
decoration: BoxDecoration(color: _bg1, borderRadius: BorderRadius.vertical(top: Radius.circular(24)), border: Border(top: BorderSide(color: _line2))),
child: SingleChildScrollView(
controller: controller, padding: EdgeInsets.all(24),
child: Form(key: _formKey, child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Center(child: Container(width: 48, height: 5, decoration: BoxDecoration(color: _bg2, borderRadius: BorderRadius.circular(3)))),
SizedBox(height: 24),
Text('Isi Detail Pekerjaan', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w800, color: _t1)),
Text('Penugasan #${widget.item.idPenugasan}', style: TextStyle(fontSize: 13, color: _t3)),
SizedBox(height: 24),
_label('Tanggal Mulai *'),
GestureDetector(
onTap: _pickTanggal,
child: Container(
padding: EdgeInsets.symmetric(horizontal: 14, vertical: 14),
decoration: BoxDecoration(color: _bg2, border: Border.all(color: _line2), borderRadius: BorderRadius.circular(10)),
child: Row(children: [
Icon(Icons.calendar_today_rounded, size: 18, color: _t2),
SizedBox(width: 10),
Text(_tanggalMulai ?? 'Pilih tanggal mulai', style: TextStyle(fontSize: 14, color: _tanggalMulai != null ? _t1 : _t3)),
]),
),
),
SizedBox(height: 24),
// LOOP ITEMS
ListView.builder(
shrinkWrap: true,
physics: NeverScrollableScrollPhysics(),
itemCount: _items.length,
itemBuilder: (ctx, index) => _buildItemForm(index),
),
Center(
child: TextButton.icon(
onPressed: _addItem,
icon: Icon(Icons.add_circle_outline, color: _cyan),
label: Text('Tambah Jenis Pekerjaan Lain', style: TextStyle(color: _cyan, fontWeight: FontWeight.w700)),
),
),
SizedBox(height: 24),
_label('Catatan Teknisi (Opsional)'),
TextFormField(controller: _detailController, maxLines: 2, style: TextStyle(color: _t1, fontSize: 14), decoration: _inputDecor(hint: 'Catatan tambahan terkait lapangan...')),
SizedBox(height: 16),
_label('Foto Sebelum Pekerjaan *'),
GestureDetector(
onTap: _pickFoto,
child: Container(
height: 120,
decoration: BoxDecoration(color: _bg2, borderRadius: BorderRadius.circular(10), border: Border.all(color: _line2)),
child: _fotoSebelum != null
? kIsWeb
? Center(child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [Icon(Icons.check_circle, color: _green, size: 36), SizedBox(height: 8), Text('Foto dipilih ✓', style: TextStyle(color: _green, fontWeight: FontWeight.w600))]))
: ClipRRect(borderRadius: BorderRadius.circular(10), child: Image.file(File(_fotoSebelum!.path), fit: BoxFit.cover, width: double.infinity))
: Center(child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [
Container(padding: EdgeInsets.all(10), decoration: BoxDecoration(color: _cyanDim, shape: BoxShape.circle), child: Icon(Icons.camera_alt_outlined, color: _cyan, size: 24)),
SizedBox(height: 10),
Text('Ambil Foto Sebelum', style: TextStyle(color: _t2, fontSize: 13, fontWeight: FontWeight.w500))
])),
),
),
SizedBox(height: 32),
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: _isLoading ? null : _submit,
style: ElevatedButton.styleFrom(backgroundColor: _green, foregroundColor: Colors.black, padding: EdgeInsets.symmetric(vertical: 16), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12))),
child: _isLoading ? SizedBox(height: 20, width: 20, child: CircularProgressIndicator(color: Colors.black, strokeWidth: 2)) : Text('KIRIM & SIMPAN', style: TextStyle(fontSize: 14, fontWeight: FontWeight.w800, letterSpacing: 1.2)),
),
),
SizedBox(height: 20),
])),
),
),
),
);
}
Widget _buildItemForm(int index) {
final item = _items[index];
final jenisPek = item['jenis_pekerjaan'];
return Container(
margin: EdgeInsets.only(bottom: 24),
padding: EdgeInsets.all(16),
decoration: BoxDecoration(
color: _bg,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: _line2),
),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Row(mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [
Text('PEKERJAAN #${index + 1}', style: TextStyle(fontSize: 11, fontWeight: FontWeight.w800, color: _cyan, letterSpacing: 1)),
if (_items.length > 1)
IconButton(
onPressed: () => _removeItem(index),
icon: Icon(Icons.delete_outline, color: _rose, size: 20),
visualDensity: VisualDensity.compact,
),
]),
SizedBox(height: 12),
_label('Jenis Pekerjaan *'),
DropdownButtonFormField<String>(
value: jenisPek, hint: Text('Pilih jenis pekerjaan', style: TextStyle(color: _t3)),
dropdownColor: _bg2, style: TextStyle(color: _t1, fontSize: 13),
decoration: _inputDecor(), iconEnabledColor: _t2,
items: _jenisList.map((j) => DropdownMenuItem(value: j['value'], child: Text(j['label']!))).toList(),
onChanged: (val) { setState(() { _items[index]['jenis_pekerjaan'] = val; }); },
validator: (v) => v == null ? 'Wajib' : null,
),
SizedBox(height: 16),
if (jenisPek != null) ...[
if (['sr','pengembangan_jaringan_pipa','pemasangan_gate_valve','perbaikan_jaringan_pipa','pengecatan_pipa_besi','penyempurnaan_jaringan_pipa'].contains(jenisPek)) ...[
_label('Dimensi Pipa'),
DropdownButtonFormField<String>(
value: item['dimensi_pipa'], hint: Text('Pilih dimensi', style: TextStyle(color: _t3)), dropdownColor: _bg2, style: TextStyle(color: _t1, fontSize: 13), decoration: _inputDecor(), iconEnabledColor: _t2,
items: ['1-2','3','4','6','8','10','12'].map((d) => DropdownMenuItem(value: d, child: Text('Dim $d inch'))).toList(),
onChanged: (val) => setState(() => _items[index]['dimensi_pipa'] = val),
),
SizedBox(height: 16),
],
if (['pengembangan_jaringan_pipa','penyempurnaan_jaringan_pipa'].contains(jenisPek)) ...[
_label('Jarak (meter)'),
TextFormField(controller: item['jarak_meter'], keyboardType: TextInputType.numberWithOptions(decimal: true), style: TextStyle(color: _t1, fontSize: 13), decoration: _inputDecor(hint: 'Contoh: 50.5', suffix: 'm')),
SizedBox(height: 16),
],
if (['sr','pengangkatan'].contains(jenisPek)) ...[
_label('Jumlah Unit'),
TextFormField(controller: item['jumlah_unit'], keyboardType: TextInputType.number, style: TextStyle(color: _t1, fontSize: 13), decoration: _inputDecor(hint: 'Contoh: 2', suffix: 'unit')),
SizedBox(height: 16),
],
if (jenisPek == 'gali_urug') ...[
_label('Jumlah Titik'),
TextFormField(controller: item['jumlah_titik'], keyboardType: TextInputType.number, style: TextStyle(color: _t1, fontSize: 13), decoration: _inputDecor(hint: 'Contoh: 3', suffix: 'titik')),
SizedBox(height: 16),
],
if (jenisPek == 'pemasangan_gate_valve') ...[
Theme(
data: ThemeData(unselectedWidgetColor: _t3),
child: CheckboxListTile(
value: item['pakai_pipa_besi'], onChanged: (v) => setState(() => _items[index]['pakai_pipa_besi'] = v ?? false),
title: Text('Pakai Pipa Besi (+Rp 200rb)', style: TextStyle(fontSize: 12, color: _t1)),
activeColor: _green, checkColor: Colors.black, contentPadding: EdgeInsets.zero,
),
),
],
if (jenisPek == 'pengangkatan') ...[
_label('Jenis Pengangkatan'),
DropdownButtonFormField<String>(
value: item['jenis_pengangkatan'], hint: Text('Pilih jenis', style: TextStyle(color: _t3)), dropdownColor: _bg2, style: TextStyle(color: _t1, fontSize: 13), decoration: _inputDecor(), iconEnabledColor: _t2,
items: [DropdownMenuItem(value: 'meteran', child: Text('Meteran Air')), DropdownMenuItem(value: 'gate_valve', child: Text('Gate Valve'))],
onChanged: (val) => setState(() => _items[index]['jenis_pengangkatan'] = val),
),
SizedBox(height: 16),
],
],
]),
);
}
Widget _label(String text) => Padding(padding: EdgeInsets.only(bottom: 8), child: Text(text, style: TextStyle(fontSize: 11, fontWeight: FontWeight.w800, color: _t2, letterSpacing: 0.8)));
InputDecoration _inputDecor({String? hint, String? suffix}) => InputDecoration(
hintText: hint, hintStyle: TextStyle(color: _t3), suffixText: suffix, suffixStyle: TextStyle(color: _t2),
filled: true, fillColor: _bg2,
border: OutlineInputBorder(borderRadius: BorderRadius.circular(10), borderSide: BorderSide.none),
enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(10), borderSide: BorderSide(color: _line2)),
focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(10), borderSide: BorderSide(color: _green, width: 1.5)),
contentPadding: EdgeInsets.symmetric(horizontal: 14, vertical: 14),
);
}
// ===================================
// FORM UPDATE PROGRES
// ===================================
class _FormUpdateProgres extends StatefulWidget {
final PenugasanModel item;
final PenugasanApi api;
final VoidCallback onSuccess;
final Function(String) onFail;
const _FormUpdateProgres({required this.item, required this.api, required this.onSuccess, required this.onFail});
@override
__FormUpdateProgresState createState() => __FormUpdateProgresState();
}
class __FormUpdateProgresState extends State<_FormUpdateProgres> {
bool _isLoading = false;
bool _tandaiSelesai = false;
XFile? _fotoSesudah;
final _picker = ImagePicker();
Future<void> _pickFoto() async {
final picked = await _picker.pickImage(source: ImageSource.camera, imageQuality: 70);
if (picked != null) setState(() => _fotoSesudah = picked);
}
Future<void> _submit() async {
setState(() => _isLoading = true);
try {
if (_fotoSesudah != null) {
await widget.api.uploadFoto(idPenugasan: widget.item.idPenugasan, tipeFoto: 'sesudah', foto: _fotoSesudah!);
} else if (_tandaiSelesai) {
// Jika mau selesai, harus ada foto
throw PenugasanApiException(message: "Foto sesudah pekerjaan wajib diisi sebelum selesai.");
}
if (_tandaiSelesai) {
await widget.api.updateStatus(
idPenugasan: widget.item.idPenugasan,
statusPekerjaan: 'selesai',
tanggalDiselesaikan: DateFormat('yyyy-MM-dd HH:mm:ss').format(DateTime.now()),
);
} else if (_fotoSesudah == null) {
throw PenugasanApiException(message: "Tidak ada aksi yang dilakukan.");
}
widget.onSuccess();
} on PenugasanApiException catch (e) {
setState(() => _isLoading = false); widget.onFail(e.message);
} catch (e) {
setState(() => _isLoading = false); widget.onFail('Gagal: $e');
}
}
@override
Widget build(BuildContext context) {
return Padding(
padding: EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom),
child: DraggableScrollableSheet(
expand: false, initialChildSize: 0.65, maxChildSize: 0.9,
builder: (_, controller) => Container(
decoration: BoxDecoration(color: _bg1, borderRadius: BorderRadius.vertical(top: Radius.circular(24)), border: Border(top: BorderSide(color: _line2))),
child: SingleChildScrollView(
controller: controller, padding: EdgeInsets.all(24),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Center(child: Container(width: 48, height: 5, decoration: BoxDecoration(color: _bg2, borderRadius: BorderRadius.circular(3)))),
SizedBox(height: 24),
Text('Update Progres Pekerjaan', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w800, color: _t1)),
Text('${widget.item.labelJenisPekerjaanFallback}${widget.item.totalNilaiFormatted}', style: TextStyle(fontSize: 13, color: _t3)),
SizedBox(height: 24),
Text('FOTO HASIL PEKERJAAN', style: TextStyle(fontSize: 11, fontWeight: FontWeight.w800, color: _t2, letterSpacing: 0.8)),
SizedBox(height: 10),
GestureDetector(
onTap: _pickFoto,
child: Container(
height: 140,
decoration: BoxDecoration(color: _bg2, borderRadius: BorderRadius.circular(10), border: Border.all(color: _line2)),
child: _fotoSesudah != null
? kIsWeb
? Center(child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [Icon(Icons.check_circle, color: _cyan, size: 36), SizedBox(height: 8), Text('Foto dipilih ✓', style: TextStyle(color: _cyan, fontWeight: FontWeight.w600))]))
: ClipRRect(borderRadius: BorderRadius.circular(10), child: Image.file(File(_fotoSesudah!.path), fit: BoxFit.cover, width: double.infinity))
: Center(child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [
Container(padding: EdgeInsets.all(10), decoration: BoxDecoration(color: _greenDim, shape: BoxShape.circle), child: Icon(Icons.camera_alt_outlined, color: _green, size: 28)),
SizedBox(height: 10),
Text('Ambil Foto Hasil', style: TextStyle(color: _t2, fontSize: 13, fontWeight: FontWeight.w500))
])),
),
),
SizedBox(height: 24),
AnimatedContainer(
duration: Duration(milliseconds: 200),
decoration: BoxDecoration(
color: _tandaiSelesai ? _greenDim : _bg2,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: _tandaiSelesai ? _green.withOpacity(0.4) : _line2),
),
child: Theme(
data: ThemeData(unselectedWidgetColor: _t3),
child: CheckboxListTile(
value: _tandaiSelesai,
onChanged: (v) => setState(() => _tandaiSelesai = v ?? false),
title: Text('Tandai Pekerjaan Selesai', style: TextStyle(fontWeight: FontWeight.w700, fontSize: 14, color: _tandaiSelesai ? _green : _t1)),
subtitle: Text('Centang jika pekerjaan sudah selesai', style: TextStyle(fontSize: 12, color: _tandaiSelesai ? _green.withOpacity(0.8) : _t2)),
activeColor: _green, checkColor: Colors.black,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
),
),
),
SizedBox(height: 32),
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: _isLoading ? null : _submit,
style: ElevatedButton.styleFrom(
backgroundColor: _tandaiSelesai ? _green : _cyan,
foregroundColor: Colors.black,
padding: EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
),
child: _isLoading
? SizedBox(height: 20, width: 20, child: CircularProgressIndicator(color: Colors.black, strokeWidth: 2))
: Text(_tandaiSelesai ? 'SIMPAN & TANDAI SELESAI' : 'SIMPAN FOTO', style: TextStyle(fontSize: 14, fontWeight: FontWeight.w800, letterSpacing: 1.2)),
),
),
SizedBox(height: 20),
]),
),
),
),
);
}
}