import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:image_picker/image_picker.dart'; import '../api/AbsensiApi.dart'; import 'package:geolocator/geolocator.dart'; import '../api/LoginApi.dart'; import 'package:intl/intl.dart'; import 'package:flutter/foundation.dart' show kIsWeb; import 'package:timezone/timezone.dart' as tz; import 'package:timezone/data/latest.dart' as tz; // ── Palette ─────────────────────────────────────────────────────────────────── const _bg = Color(0xFFF9FAFB); const _bg1 = Color(0xFFFFFFFF); const _bg2 = Color(0xFFF3F4F6); const _green = Color(0xFF10B981); const _greenDim = Color(0x1A10B981); const _greenGlow = Color(0x4D10B981); 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 AbsensiScreen extends StatefulWidget { const AbsensiScreen({Key? key}) : super(key: key); @override State createState() => _AbsensiScreenState(); } class _AbsensiScreenState extends State with TickerProviderStateMixin { final AbsensiApi _absensiApi = AbsensiApi(); final ApiService _apiService = ApiService(); final ImagePicker _picker = ImagePicker(); bool _isLoading = true; bool _isLoadingRiwayat = false; bool _isLoadingRekap = false; bool _isProcessing = false; Map? _userData; Map? _statusAbsensi; int? _idTeknisi; List> _riwayat = []; Map _rekap = {}; Map _kalenderData = {}; String _filterStatus = 'semua'; DateTime _bulanRiwayat = DateTime(DateTime.now().year, DateTime.now().month); DateTime _bulanRekap = DateTime(DateTime.now().year, DateTime.now().month); DateTime _bulanKalender = DateTime(DateTime.now().year, DateTime.now().month); late TabController _tabCtrl; late AnimationController _pulseCtrl; late Animation _pulseAnim; final List> _statusOptions = [ {'label': 'Hadir', 'value': 'hadir', 'icon': Icons.check_circle_rounded, 'color': _green}, {'label': 'Izin', 'value': 'izin', 'icon': Icons.event_busy_rounded, 'color': _amber}, {'label': 'Sakit', 'value': 'sakit', 'icon': Icons.local_hospital_rounded, 'color': _rose}, ]; @override void initState() { super.initState(); tz.initializeTimeZones(); _tabCtrl = TabController(length: 2, vsync: this); _pulseCtrl = AnimationController( vsync: this, duration: const Duration(seconds: 2)) ..repeat(reverse: true); _pulseAnim = Tween(begin: 0.4, end: 1.0).animate( CurvedAnimation(parent: _pulseCtrl, curve: Curves.easeInOut)); _tabCtrl.addListener(() { if (!_tabCtrl.indexIsChanging) { if (_tabCtrl.index == 1) { if (_rekap.isEmpty) _fetchRekap(); if (_riwayat.isEmpty) _fetchRiwayat(); } } }); _loadData(); } @override void dispose() { _tabCtrl.dispose(); _pulseCtrl.dispose(); super.dispose(); } // ── API calls ───────────────────────────────────────────────────────────── Future _loadData() async { setState(() => _isLoading = true); final res = await _apiService.getProfile(); if (res['success'] == true && res['data'] != null) { setState(() { _userData = res['data']; _idTeknisi = res['data']['teknisi']?['id_teknisi']; }); if (_idTeknisi != null) await _checkStatusAbsensi(); } setState(() => _isLoading = false); } Future _checkStatusAbsensi() async { if (_idTeknisi == null) return; final r = await _absensiApi.checkStatus(_idTeknisi!); if (r['success'] == true) setState(() => _statusAbsensi = r['data']); } /// Ambil riwayat absensi dari API berdasarkan bulan yang dipilih Future _fetchRiwayat() async { if (_idTeknisi == null) return; setState(() => _isLoadingRiwayat = true); try { final r = await _absensiApi.getRiwayat( idTeknisi: _idTeknisi!, bulan: _bulanRekap.month, // Menggunakan bulan dari rekap agar sinkron tahun: _bulanRekap.year, ); if (r['success'] == true) { setState(() { _riwayat = List>.from(r['data'] ?? []); }); } } catch (_) { } finally { setState(() { _isLoadingRiwayat = false; // Update kalender data dari riwayat _kalenderData.clear(); for (var item in _riwayat) { final tgl = item['tanggal'] as String?; if (tgl != null) { final date = DateTime.parse(tgl); _kalenderData[date.day] = item['status'] as String; } } }); } } /// Ambil rekap bulanan dari API Future _fetchRekap() async { if (_idTeknisi == null) return; setState(() => _isLoadingRekap = true); try { final r = await _absensiApi.getRekap( idTeknisi: _idTeknisi!, bulan: _bulanRekap.month, tahun: _bulanRekap.year, ); if (r['success'] == true) { setState(() => _rekap = Map.from(r['data'] ?? {})); } } catch (_) { } finally { setState(() => _isLoadingRekap = false); } } // ── Absen handlers ──────────────────────────────────────────────────────── Future _getCurrentPosition() async { bool serviceEnabled; LocationPermission permission; serviceEnabled = await Geolocator.isLocationServiceEnabled(); if (!serviceEnabled) { _toast('Layanan lokasi dinonaktifkan.', ok: false); return null; } permission = await Geolocator.checkPermission(); if (permission == LocationPermission.denied) { permission = await Geolocator.requestPermission(); if (permission == LocationPermission.denied) { _toast('Izin lokasi ditolak', ok: false); return null; } } if (permission == LocationPermission.deniedForever) { _toast('Izin lokasi ditolak secara permanen.', ok: false); return null; } _toast('Mencari lokasi GPS...', ok: true); return await Geolocator.getCurrentPosition( desiredAccuracy: LocationAccuracy.high); } Future _handleAbsenMasuk() async { if (_idTeknisi == null) { _toast('ID Teknisi tidak ditemukan', ok: false); return; } final result = await _showStatusDialog('Absen Masuk'); if (result == null) return; final status = result['status'] as String; final ket = result['keterangan'] as String?; XFile? image; if (status == 'hadir') { image = await _pickImage(); if (image == null) { _toast('Foto diperlukan untuk status Hadir', ok: false); return; } } Position? position = await _getCurrentPosition(); if (position == null) return; setState(() => _isProcessing = true); final r = await _absensiApi.absenMasuk( idTeknisi: _idTeknisi!, fotoAbsenMasuk: image, status: status, keterangan: ket, latitude: position.latitude, longitude: position.longitude); setState(() => _isProcessing = false); if (r['success'] == true) { _toast('Absen masuk berhasil!', ok: true); await _checkStatusAbsensi(); _fetchRekap(); // Refresh rekap _fetchRiwayat(); // Refresh riwayat } else { _toast(r['message'] ?? 'Gagal absen masuk', ok: false); } } Future _handleAbsenKeluar() async { if (_idTeknisi == null) { _toast('ID Teknisi tidak ditemukan', ok: false); return; } // Langsung minta foto tanpa dialog status untuk absen keluar final image = await _pickImage(); if (image == null) { _toast('Foto diperlukan untuk Absen Keluar', ok: false); return; } Position? position = await _getCurrentPosition(); if (position == null) return; setState(() => _isProcessing = true); final r = await _absensiApi.absenKeluar( idTeknisi: _idTeknisi!, fotoAbsenKeluar: image, status: 'hadir', // Otomatis hadir jika absen keluar keterangan: null, latitude: position.latitude, longitude: position.longitude); setState(() => _isProcessing = false); if (r['success'] == true) { _toast('Absen keluar berhasil!', ok: true); await _checkStatusAbsensi(); _fetchRekap(); // Refresh rekap _fetchRiwayat(); // Refresh riwayat } else { _toast(r['message'] ?? 'Gagal absen keluar', ok: false); } } Future _pickImage() async { if (kIsWeb) return _picker.pickImage(source: ImageSource.gallery, imageQuality: 70); final src = await _showImageSourceDialog(); if (src == null) return null; return _picker.pickImage(source: src, imageQuality: 70); } // ── Dialogs ─────────────────────────────────────────────────────────────── Future _showImageSourceDialog() { return showDialog( context: context, builder: (ctx) => Dialog( backgroundColor: _bg1, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(20), side: const BorderSide(color: _line2)), child: Padding( padding: const EdgeInsets.all(24), child: Column(mainAxisSize: MainAxisSize.min, children: [ Container(width: 48, height: 48, decoration: BoxDecoration(color: _cyanDim, borderRadius: BorderRadius.circular(13), border: Border.all(color: _cyan.withOpacity(0.3))), child: const Icon(Icons.camera_alt_rounded, color: _cyan, size: 22)), const SizedBox(height: 12), const Text('Pilih Sumber Foto', style: TextStyle( fontSize: 16, fontWeight: FontWeight.w700, color: _t1)), const SizedBox(height: 18), _srcBtn(Icons.camera_alt_rounded, 'Kamera', _cyan, _cyanDim, () => Navigator.pop(ctx, ImageSource.camera)), const SizedBox(height: 8), _srcBtn(Icons.photo_library_rounded, 'Galeri', _green, _greenDim, () => Navigator.pop(ctx, ImageSource.gallery)), const SizedBox(height: 4), TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('Batal', style: TextStyle(color: _t2))), ]), ), ), ); } Widget _srcBtn(IconData icon, String label, Color c, Color dim, VoidCallback fn) { return Material(color: dim, borderRadius: BorderRadius.circular(11), child: InkWell(onTap: fn, borderRadius: BorderRadius.circular(11), child: Container(width: double.infinity, padding: const EdgeInsets.symmetric(vertical: 13, horizontal: 16), decoration: BoxDecoration(borderRadius: BorderRadius.circular(11), border: Border.all(color: c.withOpacity(0.25))), child: Row(children: [ Icon(icon, color: c, size: 18), const SizedBox(width: 10), Text(label, style: TextStyle(color: c, fontWeight: FontWeight.w600, fontSize: 14)), ]), ), ), ); } Future?> _showStatusDialog(String title) { String? selected; final ketCtrl = TextEditingController(); final isIn = title.contains('Masuk'); return showDialog>( context: context, builder: (ctx) => StatefulBuilder(builder: (ctx, setSt) { return Dialog( backgroundColor: _bg1, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(20), side: const BorderSide(color: _line2)), child: SingleChildScrollView( padding: const EdgeInsets.all(22), child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Row(children: [ Container(width: 42, height: 42, decoration: BoxDecoration( color: isIn ? _greenDim : _roseDim, borderRadius: BorderRadius.circular(11), border: Border.all( color: (isIn ? _green : _rose).withOpacity(0.3))), child: Icon( isIn ? Icons.login_rounded : Icons.logout_rounded, color: isIn ? _green : _rose, size: 20)), const SizedBox(width: 12), Text(title, style: const TextStyle( fontSize: 16, fontWeight: FontWeight.w700, color: _t1)), const Spacer(), GestureDetector(onTap: () => Navigator.pop(ctx), child: const Icon(Icons.close_rounded, color: _t3, size: 20)), ]), const SizedBox(height: 18), const Text('PILIH STATUS', style: TextStyle( fontSize: 10, fontWeight: FontWeight.w700, color: _t3, letterSpacing: 1.3)), const SizedBox(height: 10), ..._statusOptions.map((opt) { final isSel = selected == opt['value']; final Color c = opt['color'] as Color; return GestureDetector( onTap: () => setSt(() { selected = opt['value'] as String; if (selected != 'izin') ketCtrl.clear(); }), child: AnimatedContainer( duration: const Duration(milliseconds: 150), margin: const EdgeInsets.only(bottom: 8), padding: const EdgeInsets.symmetric( horizontal: 14, vertical: 11), decoration: BoxDecoration( color: isSel ? c.withOpacity(0.08) : _bg2, borderRadius: BorderRadius.circular(11), border: Border.all( color: isSel ? c.withOpacity(0.45) : _line2, width: isSel ? 1.5 : 1)), child: Row(children: [ Icon(opt['icon'] as IconData, color: isSel ? c : _t3, size: 19), const SizedBox(width: 11), Text(opt['label'] as String, style: TextStyle( color: isSel ? c : _t2, fontSize: 14, fontWeight: isSel ? FontWeight.w700 : FontWeight.w400)), const Spacer(), if (isSel) Container(width: 17, height: 17, decoration: BoxDecoration( color: c, shape: BoxShape.circle), child: const Icon(Icons.check, color: Colors.black, size: 11)), ]), ), ); }).toList(), if (selected == 'izin') ...[ const SizedBox(height: 12), const Text('KETERANGAN IZIN', style: TextStyle( fontSize: 10, fontWeight: FontWeight.w700, color: _t3, letterSpacing: 1.3)), const SizedBox(height: 8), TextField( controller: ketCtrl, maxLines: 3, style: const TextStyle(color: _t1, fontSize: 13), cursorColor: _amber, decoration: InputDecoration( hintText: 'Tulis alasan izin...', hintStyle: const TextStyle(color: _t3, fontSize: 13), filled: true, fillColor: _bg2, contentPadding: const EdgeInsets.all(12), border: OutlineInputBorder( borderRadius: BorderRadius.circular(11), borderSide: BorderSide.none), enabledBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(11), borderSide: const BorderSide(color: _line2)), focusedBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(11), borderSide: const BorderSide(color: _amber, width: 1.5)), ), ), ], const SizedBox(height: 12), Container( padding: const EdgeInsets.symmetric( horizontal: 11, vertical: 9), decoration: BoxDecoration( color: _bg2, borderRadius: BorderRadius.circular(9), border: Border.all(color: _line2)), child: Row(children: [ Icon(Icons.info_outline_rounded, size: 14, color: selected == 'hadir' ? _green : _t3), const SizedBox(width: 7), Expanded(child: Text( selected == 'hadir' ? 'Foto akan diminta setelah ini' : 'Foto tidak diperlukan untuk status ini', style: TextStyle(fontSize: 12, color: selected == 'hadir' ? _green : _t3), )), ]), ), const SizedBox(height: 18), Row(children: [ Expanded(child: OutlinedButton( onPressed: () => Navigator.pop(ctx), style: OutlinedButton.styleFrom( foregroundColor: _t2, side: const BorderSide(color: _line2), padding: const EdgeInsets.symmetric(vertical: 13), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(11))), child: const Text('Batal'), )), const SizedBox(width: 10), Expanded(flex: 2, child: AnimatedContainer( duration: const Duration(milliseconds: 150), decoration: BoxDecoration( color: selected != null ? (isIn ? _green : _rose) : _bg2, borderRadius: BorderRadius.circular(11), boxShadow: selected != null ? [BoxShadow( color: (isIn ? _green : _rose).withOpacity(0.28), blurRadius: 12, offset: const Offset(0, 4))] : []), child: Material(color: Colors.transparent, child: InkWell( borderRadius: BorderRadius.circular(11), onTap: selected == null ? null : () { if (selected == 'izin' && ketCtrl.text.trim().isEmpty) { _toast('Keterangan izin harus diisi', ok: false); return; } Navigator.pop(ctx, { 'status': selected, 'keterangan': selected == 'izin' ? ketCtrl.text.trim() : null, }); }, child: Padding( padding: const EdgeInsets.symmetric(vertical: 13), child: Text('Lanjutkan', textAlign: TextAlign.center, style: TextStyle( fontWeight: FontWeight.w700, fontSize: 14, color: selected != null ? Colors.black : _t3)), ), )), )), ]), ]), ), ); }), ); } // ── Helpers ─────────────────────────────────────────────────────────────── void _toast(String msg, {required bool ok}) { if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar(SnackBar( content: Row(children: [ Icon(ok ? Icons.check_circle_outline : Icons.error_outline, color: ok ? _green : _rose, size: 16), const SizedBox(width: 8), Expanded(child: Text(msg, style: const TextStyle(color: _t1, fontSize: 13))), ]), backgroundColor: _bg1, behavior: SnackBarBehavior.floating, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(10), side: BorderSide( color: ok ? _green.withOpacity(0.4) : _rose.withOpacity(0.4))), duration: const Duration(seconds: 3), )); } String _formatTime(String? t) { if (t == null) return '--:--'; try { final jakarta = tz.getLocation('Asia/Jakarta'); return DateFormat('HH:mm') .format(tz.TZDateTime.from(DateTime.parse(t), jakarta)); } catch (_) { return t; } } Color _statusColor(String s) { switch (s) { case 'hadir': return _green; case 'izin': return _amber; case 'sakit': return _rose; default: return _t3; } } Color _statusBg(String s) { switch (s) { case 'hadir': return _greenDim; case 'izin': return _amberDim; case 'sakit': return _roseDim; default: return const Color(0x08ffffff); } } Widget _pill(String text, Color bg, Color fg) => Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), decoration: BoxDecoration(color: bg, borderRadius: BorderRadius.circular(9), border: Border.all(color: fg.withOpacity(0.25))), child: Text(text, style: TextStyle( fontSize: 10, fontWeight: FontWeight.w600, color: fg)), ); Widget _navBtn(IconData icon, VoidCallback onTap) => GestureDetector( onTap: onTap, child: Container( width: 34, height: 34, decoration: BoxDecoration(color: _bg2, borderRadius: BorderRadius.circular(10), border: Border.all(color: _line2)), child: Icon(icon, color: _t2, size: 18), ), ); String _fmtBulan(DateTime d) => DateFormat('MMMM yyyy', 'id_ID').format(d); // ═══════════════════════════════════════════════════════════════════════════ // BUILD // ═══════════════════════════════════════════════════════════════════════════ @override Widget build(BuildContext context) { final nama = (_userData?['teknisi']?['nama_teknisi'] ?? 'Teknisi') as String; final id = _userData?['teknisi']?['id_teknisi'] ?? '-'; final sudahIn = _statusAbsensi?['sudah_absen_masuk'] ?? false; final sudahOut = _statusAbsensi?['sudah_absen_keluar'] ?? false; final data = _statusAbsensi?['data_absensi']; final initial = nama.isNotEmpty ? nama[0].toUpperCase() : 'T'; return Scaffold( backgroundColor: _bg, appBar: _buildAppBar(), body: _isLoading ? const Center(child: Column(mainAxisSize: MainAxisSize.min, children: [ SizedBox(width: 36, height: 36, child: CircularProgressIndicator( color: _green, strokeWidth: 2.5)), SizedBox(height: 14), Text('Memuat data…', style: TextStyle(color: _t2, fontSize: 13)), ])) : Column(children: [ // Tab bar Container( color: _bg1, child: TabBar( controller: _tabCtrl, indicatorColor: _green, indicatorWeight: 3, labelColor: _green, unselectedLabelColor: _t3, labelStyle: const TextStyle( fontSize: 13, fontWeight: FontWeight.w800, letterSpacing: 0.5), unselectedLabelStyle: const TextStyle( fontSize: 13, fontWeight: FontWeight.w600), tabs: const [ Tab(text: 'ABSEN'), Tab(text: 'REKAP'), ], ), ), const Divider(height: 1, color: _line2), Expanded( child: TabBarView( controller: _tabCtrl, children: [ _buildTabAbsen( initial, nama, id, sudahIn, sudahOut, data), _buildTabRekap(), ], ), ), ]), ); } PreferredSizeWidget _buildAppBar() { return AppBar( elevation: 0, backgroundColor: _bg1, surfaceTintColor: Colors.transparent, systemOverlayStyle: const SystemUiOverlayStyle( statusBarColor: Colors.transparent, statusBarIconBrightness: Brightness.light), title: const Text('Absensi Teknisi', style: TextStyle( fontSize: 16, fontWeight: FontWeight.w800, color: _t1, letterSpacing: 0.2)), actions: [ IconButton( icon: const Icon(Icons.refresh_rounded, color: _t2, size: 22), onPressed: _loadData, ), const SizedBox(width: 8), ], ); } // ═══════════════════════════════════════════════════════════════════════════ // TAB 1 — ABSEN // ═══════════════════════════════════════════════════════════════════════════ Widget _buildTabAbsen(String initial, String nama, dynamic id, bool sudahIn, bool sudahOut, Map? data) { final now = DateTime.now(); final timeStr = DateFormat('HH:mm').format(now); final dateStr = DateFormat('EEEE, d MMMM yyyy', 'id_ID').format(now); final jadwal = _userData?['teknisi']?['jadwal_masuk'] ?? '07:30'; final jamMasuk = data != null ? _formatTime(data['jam_masuk']) : '--:--'; final jamKeluar = data != null ? _formatTime(data['jam_keluar']) : '--:--'; final durasi = data?['durasi_kerja_formatted'] ?? '--'; return RefreshIndicator( onRefresh: _loadData, color: _green, backgroundColor: _bg1, child: SingleChildScrollView( physics: const AlwaysScrollableScrollPhysics(), padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 24), child: Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [ // Greeting & Profile Row(children: [ Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ Text('Selamat ${now.hour < 11 ? 'Pagi' : now.hour < 15 ? 'Siang' : now.hour < 18 ? 'Sore' : 'Malam'},', style: const TextStyle(color: _t2, fontSize: 13, fontWeight: FontWeight.w500)), const SizedBox(height: 2), Text(nama, style: const TextStyle(color: _t1, fontSize: 18, fontWeight: FontWeight.w800)), ]), const Spacer(), _pill('ID: $id', _greenDim, _green), ]), const SizedBox(height: 30), // Central Clock Display Center( child: Column(children: [ Text(timeStr, style: const TextStyle(fontSize: 64, fontWeight: FontWeight.w900, color: _t1, letterSpacing: -2, height: 1)), Text(dateStr, style: const TextStyle(color: _t2, fontSize: 14, fontWeight: FontWeight.w500)), const SizedBox(height: 12), ]), ), const SizedBox(height: 40), // Log Aktivitas Card Container( padding: const EdgeInsets.all(20), decoration: BoxDecoration( color: _bg1, borderRadius: BorderRadius.circular(24), border: Border.all(color: _line2), boxShadow: [BoxShadow(color: Colors.black.withOpacity(0.1), blurRadius: 20, offset: const Offset(0, 10))], ), child: Column(children: [ Row(mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ const Text('LOG AKTIVITAS', style: TextStyle(color: _t3, fontSize: 11, fontWeight: FontWeight.w800, letterSpacing: 1.5)), if (data != null) _pill( sudahOut ? 'Selesai' : (data['status'] == 'hadir' ? 'Sedang Bekerja' : data['status'].toUpperCase()), sudahOut ? _greenDim : (data['status'] == 'hadir' ? _cyanDim : _roseDim), sudahOut ? _green : (data['status'] == 'hadir' ? _cyan : _rose) ), ]), const SizedBox(height: 20), Row(children: [ _logItem('JADWAL', jadwal, Icons.alarm_rounded, _cyan), _vLine(), _logItem('MASUK', jamMasuk, Icons.login_rounded, sudahIn ? _green : _t3), ]), const Padding(padding: EdgeInsets.symmetric(vertical: 15), child: Divider(color: _line2, height: 1)), Row(children: [ _logItem('KELUAR', jamKeluar, Icons.logout_rounded, sudahOut ? _rose : _t3), _vLine(), _logItem('DURASI', durasi, Icons.timer_outlined, _amber), ]), ]), ), const SizedBox(height: 32), // Floating Action Area _buildActionArea(sudahIn, sudahOut, data), ]), ), ); } Widget _logItem(String label, String value, IconData icon, Color color) { return Expanded( child: Row(children: [ Container( padding: const EdgeInsets.all(8), decoration: BoxDecoration(color: color.withOpacity(0.1), borderRadius: BorderRadius.circular(10)), child: Icon(icon, color: color, size: 18), ), const SizedBox(width: 12), Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(label, style: const TextStyle(color: _t3, fontSize: 9, fontWeight: FontWeight.w700)), const SizedBox(height: 2), Text(value, style: TextStyle(color: color == _t3 ? _t3 : _t1, fontSize: 16, fontWeight: FontWeight.w800)), ]), ]), ); } Widget _vLine() => Container(width: 1, height: 30, color: _line2, margin: const EdgeInsets.symmetric(horizontal: 10)); Widget _buildActionArea(bool sudahIn, bool sudahOut, Map? data) { if (sudahIn && sudahOut) { return Container( padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 20), decoration: BoxDecoration(color: _greenDim, borderRadius: BorderRadius.circular(16), border: Border.all(color: _green.withOpacity(0.2))), child: const Row(mainAxisAlignment: MainAxisAlignment.center, children: [ Icon(Icons.check_circle_rounded, color: _green, size: 20), const SizedBox(width: 10), Text('Kerja hari ini selesai', style: TextStyle(color: _green, fontWeight: FontWeight.w700, fontSize: 14)), ]), ); } if (data != null && (data['status'] == 'izin' || data['status'] == 'sakit')) { final Color c = data['status'] == 'sakit' ? _rose : _amber; return Container( padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 20), decoration: BoxDecoration(color: c.withOpacity(0.1), borderRadius: BorderRadius.circular(16), border: Border.all(color: c.withOpacity(0.2))), child: Row(mainAxisAlignment: MainAxisAlignment.center, children: [ Icon(data['status'] == 'sakit' ? Icons.local_hospital_rounded : Icons.event_busy_rounded, color: c, size: 20), const SizedBox(width: 10), Text('Status: ${data['status'].toUpperCase()}', style: TextStyle(color: c, fontWeight: FontWeight.w700, fontSize: 14)), ]), ); } final String label = !sudahIn ? 'Absen Masuk' : 'Absen Keluar'; final Color color = !sudahIn ? _green : _rose; final IconData ico = !sudahIn ? Icons.fingerprint_rounded : Icons.logout_rounded; final VoidCallback fn = !sudahIn ? _handleAbsenMasuk : _handleAbsenKeluar; return Container( height: 64, decoration: BoxDecoration( borderRadius: BorderRadius.circular(20), gradient: LinearGradient(colors: [color, color.withBlue(150)], begin: Alignment.topLeft, end: Alignment.bottomRight), boxShadow: [BoxShadow(color: color.withOpacity(0.3), blurRadius: 20, offset: const Offset(0, 8))], ), child: Material( color: Colors.transparent, child: InkWell( borderRadius: BorderRadius.circular(20), onTap: _isProcessing ? null : fn, child: Center( child: _isProcessing ? const SizedBox(width: 24, height: 24, child: CircularProgressIndicator(color: Colors.black, strokeWidth: 3)) : Row(mainAxisAlignment: MainAxisAlignment.center, children: [ Icon(ico, color: Colors.black, size: 24), const SizedBox(width: 12), Text(label.toUpperCase(), style: const TextStyle(color: Colors.black, fontSize: 16, fontWeight: FontWeight.w900, letterSpacing: 1)), ]), ), ), ), ); } // ═══════════════════════════════════════════════════════════════════════════ // TAB 2 — REKAP & RIWAYAT // ═══════════════════════════════════════════════════════════════════════════ Widget _buildTabRekap() { return _isLoadingRekap ? const Center(child: CircularProgressIndicator(color: _green, strokeWidth: 2.5)) : _rekap.isEmpty ? Center(child: Column(mainAxisSize: MainAxisSize.min, children: [ Container(width: 56, height: 56, decoration: BoxDecoration(color: _bg2, borderRadius: BorderRadius.circular(14), border: Border.all(color: _line2)), child: const Icon(Icons.bar_chart_rounded, color: _t3, size: 26)), const SizedBox(height: 14), const Text('Tidak ada data rekap', style: TextStyle(color: _t2, fontSize: 14)), const SizedBox(height: 8), GestureDetector( onTap: () { _fetchRekap(); _fetchRiwayat(); }, child: Container( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), decoration: BoxDecoration(color: _greenDim, borderRadius: BorderRadius.circular(8), border: Border.all(color: _green.withOpacity(0.3))), child: const Text('Muat Rekap', style: TextStyle(color: _green, fontSize: 12, fontWeight: FontWeight.w600)), ), ), ])) : _buildRekapContent(); } Widget _buildRekapContent() { final pct = (_rekap['persentase'] as num?)?.toDouble() ?? 0.0; final hadir = (_rekap['hadir'] as num?)?.toInt() ?? 0; final izin = (_rekap['izin'] as num?)?.toInt() ?? 0; final sakit = (_rekap['sakit'] as num?)?.toInt() ?? 0; final alpha = (_rekap['alpha'] as num?)?.toInt() ?? 0; final total = (_rekap['total_hari_kerja'] as num?)?.toInt() ?? 1; return CustomScrollView( slivers: [ SliverToBoxAdapter( child: Padding( padding: const EdgeInsets.fromLTRB(20, 24, 20, 16), child: Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [ // Bulan nav Row(children: [ Text(_rekap['bulan']?.toString() ?? _fmtBulan(_bulanRekap), style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w800, color: _t1)), const Spacer(), _navBtn(Icons.chevron_left_rounded, () { setState(() { _bulanRekap = DateTime(_bulanRekap.year, _bulanRekap.month - 1); _rekap = {}; _riwayat = []; }); _fetchRekap(); _fetchRiwayat(); }), const SizedBox(width: 8), _navBtn(Icons.chevron_right_rounded, () { setState(() { _bulanRekap = DateTime(_bulanRekap.year, _bulanRekap.month + 1); _rekap = {}; _riwayat = []; }); _fetchRekap(); _fetchRiwayat(); }), ]), const SizedBox(height: 20), // Calendar Grid _buildCalendarGrid(), const SizedBox(height: 24), const SizedBox(height: 16), // 3 Stats Grid Row(children: [ Expanded(child: _rekapStatCard('$hadir', 'Hadir', _green, hadir/total)), const SizedBox(width: 10), Expanded(child: _rekapStatCard('$izin', 'Izin', _amber, izin/total)), const SizedBox(width: 10), Expanded(child: _rekapStatCard('$sakit', 'Sakit', _rose, sakit/total)), ]), const SizedBox(height: 32), const Text('RIWAYAT HARIAN', style: TextStyle(fontSize: 11, color: _t3, fontWeight: FontWeight.w800, letterSpacing: 1.5)), const SizedBox(height: 16), ]), ), ), // Riwayat List if (_isLoadingRiwayat) const SliverFillRemaining(child: Center(child: CircularProgressIndicator(color: _green))) else if (_riwayat.isEmpty) const SliverToBoxAdapter( child: Center( child: Padding( padding: EdgeInsets.symmetric(vertical: 40), child: Text('Tidak ada riwayat bulan ini', style: TextStyle(color: _t3)), ), ), ) else SliverPadding( padding: const EdgeInsets.fromLTRB(20, 0, 20, 40), sliver: SliverList( delegate: SliverChildBuilderDelegate( (context, i) => _buildHistoryItem(_riwayat[i]), childCount: _riwayat.length, ), ), ), ], ); } Widget _rekapStatCard(String val, String label, Color color, double frac) { return Container( padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 4), decoration: BoxDecoration(color: _bg1, borderRadius: BorderRadius.circular(16), border: Border.all(color: color.withOpacity(0.15))), child: Column(children: [ Text(val, style: TextStyle(fontSize: 22, fontWeight: FontWeight.w900, color: color)), const SizedBox(height: 2), Text(label, style: const TextStyle(fontSize: 9, color: _t2, fontWeight: FontWeight.w700)), const SizedBox(height: 10), Padding( padding: const EdgeInsets.symmetric(horizontal: 12), child: ClipRRect( borderRadius: BorderRadius.circular(100), child: LinearProgressIndicator(value: frac.clamp(0.0, 1.0), minHeight: 2, backgroundColor: color.withOpacity(0.1), valueColor: AlwaysStoppedAnimation(color)), ), ), ]), ); } Widget _buildHistoryItem(Map item) { final status = (item['status'] ?? 'alpha') as String; final color = _statusColor(status); final bg = _statusBg(status); final masuk = item['jam_masuk_formatted'] as String?; final keluar = item['jam_keluar_formatted'] as String?; final tanggal = item['tanggal'] as String? ?? ''; final dayNum = tanggal.length >= 10 ? tanggal.substring(8, 10) : '--'; final dayName = tanggal.isNotEmpty ? DateFormat('EEEE', 'id_ID').format(DateTime.parse(tanggal)) : '-'; return Container( margin: const EdgeInsets.only(bottom: 12), padding: const EdgeInsets.all(16), decoration: BoxDecoration(color: _bg1, borderRadius: BorderRadius.circular(16), border: Border.all(color: _line2)), child: Row(children: [ Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(dayNum, style: const TextStyle(fontSize: 20, fontWeight: FontWeight.w900, color: _t1)), Text(dayName.substring(0, 3).toUpperCase(), style: const TextStyle(fontSize: 10, color: _t3, fontWeight: FontWeight.w800)), ]), const SizedBox(width: 20), Container(width: 1, height: 30, color: _line2), const SizedBox(width: 20), Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ if (status == 'libur') const Text('Hari Libur', style: TextStyle(fontSize: 14, color: _t3, fontStyle: FontStyle.italic)) else Row(children: [ Text(masuk ?? '--:--', style: TextStyle(fontSize: 15, fontWeight: FontWeight.w800, color: masuk != null ? _t1 : _t3)), const Padding(padding: EdgeInsets.symmetric(horizontal: 8), child: Text('→', style: TextStyle(color: _t3))), Text(keluar ?? '--:--', style: TextStyle(fontSize: 15, fontWeight: FontWeight.w800, color: keluar != null ? _t1 : _t3)), ]), const SizedBox(height: 2), Text(status == 'hadir' ? (keluar != null ? 'Selesai' : 'Sedang Bekerja') : status.toUpperCase(), style: TextStyle(fontSize: 10, color: color, fontWeight: FontWeight.w700)), ])), Container( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), decoration: BoxDecoration(color: bg, borderRadius: BorderRadius.circular(100), border: Border.all(color: color.withOpacity(0.2))), child: Text(status.toUpperCase(), style: TextStyle(fontSize: 9, fontWeight: FontWeight.w900, color: color)), ), ]), ); } Widget _buildCalendarGrid() { final daysInMonth = DateTime(_bulanRekap.year, _bulanRekap.month + 1, 0).day; final firstDay = DateTime(_bulanRekap.year, _bulanRekap.month, 1).weekday; // Day labels const weekDays = ['Sn', 'Sl', 'Rb', 'Km', 'Jm', 'Sb', 'Mg']; return Container( padding: const EdgeInsets.all(16), decoration: BoxDecoration( color: _bg1, borderRadius: BorderRadius.circular(20), border: Border.all(color: _line2), ), child: Column( children: [ Row( mainAxisAlignment: MainAxisAlignment.spaceAround, children: weekDays.map((d) => Text(d, style: const TextStyle(color: _t3, fontSize: 11, fontWeight: FontWeight.w800))).toList(), ), const SizedBox(height: 12), GridView.builder( shrinkWrap: true, physics: const NeverScrollableScrollPhysics(), gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( crossAxisCount: 7, mainAxisSpacing: 8, crossAxisSpacing: 8, ), itemCount: daysInMonth + (firstDay - 1), itemBuilder: (context, index) { if (index < firstDay - 1) return const SizedBox(); final day = index - (firstDay - 2); final status = _kalenderData[day]; Color dotColor = Colors.transparent; Color textColor = _t2; BoxDecoration? deco; if (status != null) { dotColor = _statusColor(status); textColor = _t1; deco = BoxDecoration( color: _statusBg(status), shape: BoxShape.circle, border: Border.all(color: dotColor.withOpacity(0.3)), ); } return Center( child: Container( width: 32, height: 32, decoration: deco, child: Center( child: Text('$day', style: TextStyle( color: textColor, fontSize: 12, fontWeight: status != null ? FontWeight.w900 : FontWeight.w400 ) ), ), ), ); }, ), const SizedBox(height: 16), // Legend Row( mainAxisAlignment: MainAxisAlignment.center, children: [ _legendItem('Masuk', _green), const SizedBox(width: 12), _legendItem('Izin', _amber), const SizedBox(width: 12), _legendItem('Sakit', _rose), const SizedBox(width: 12), _legendItem('Alfa', _t3), ], ) ], ), ); } Widget _legendItem(String label, Color color) { return Row( children: [ Container(width: 6, height: 6, decoration: BoxDecoration(color: color, shape: BoxShape.circle)), const SizedBox(width: 4), Text(label, style: const TextStyle(color: _t3, fontSize: 9, fontWeight: FontWeight.w700)), ], ); } }