SIPDAM/samooflutter/lib/absensi/absensi.dart

1100 lines
46 KiB
Dart

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<AbsensiScreen> createState() => _AbsensiScreenState();
}
class _AbsensiScreenState extends State<AbsensiScreen>
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<String, dynamic>? _userData;
Map<String, dynamic>? _statusAbsensi;
int? _idTeknisi;
List<Map<String, dynamic>> _riwayat = [];
Map<String, dynamic> _rekap = {};
Map<int, String> _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<double> _pulseAnim;
final List<Map<String, dynamic>> _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<double>(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<void> _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<void> _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<void> _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<Map<String, dynamic>>.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<void> _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<String, dynamic>.from(r['data'] ?? {}));
}
} catch (_) {
} finally {
setState(() => _isLoadingRekap = false);
}
}
// ── Absen handlers ────────────────────────────────────────────────────────
Future<Position?> _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<void> _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<void> _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<XFile?> _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<ImageSource?> _showImageSourceDialog() {
return showDialog<ImageSource>(
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<Map<String, dynamic>?> _showStatusDialog(String title) {
String? selected;
final ketCtrl = TextEditingController();
final isIn = title.contains('Masuk');
return showDialog<Map<String, dynamic>>(
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<String, dynamic>? 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<String, dynamic>? 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<String, dynamic> 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)),
],
);
}
}