import 'dart:io'; import 'package:cloud_firestore/cloud_firestore.dart'; import 'package:excel/excel.dart'; import 'package:firebase_database/firebase_database.dart'; import 'package:flutter/material.dart'; import 'package:path_provider/path_provider.dart'; class LaporanPage extends StatefulWidget { const LaporanPage({super.key}); @override State createState() => _LaporanPageState(); } class _LaporanPageState extends State { final refAbsen = FirebaseDatabase.instance.ref("absensi"); List siswaList = []; String selectedFilter = "harian"; DateTime selectedDate = DateTime.now(); DateTime? selectedMonth; DateTime? selectedYear; @override void initState() { super.initState(); loadSiswa(); } // ================= LOAD DATA SISWA ================= Future loadSiswa() async { final snap = await FirebaseFirestore.instance.collection("siswa").get(); setState(() { siswaList = snap.docs; }); } // ================= WARNA STATUS SESUAI TEMA ================= Color getStatusTextColor(String status) { switch (status) { case "tepat_waktu": return const Color(0xff16A34A); case "telat": return const Color(0xffEA580C); case "izin": return const Color(0xff7C3AED); case "sakit": return const Color(0xff9333EA); case "pulang": return const Color(0xff0284C7); default: return const Color(0xffDC2626); } } Color getStatusBgColor(String status) { switch (status) { case "tepat_waktu": return const Color(0xffF0FDF4); case "telat": return const Color(0xffFFF7ED); case "izin": return const Color(0xffFAF5FF); case "sakit": return const Color(0xffF3E8FF); case "pulang": return const Color(0xffF0F9FF); default: return const Color(0xffFEF2F2); } } // ================= ICON STATUS ================= IconData getStatusIcon(String status) { switch (status) { case "tepat_waktu": return Icons.check_circle_rounded; case "telat": return Icons.warning_rounded; case "izin": return Icons.description_rounded; case "sakit": return Icons.local_hospital_rounded; case "pulang": return Icons.exit_to_app_rounded; default: return Icons.cancel_rounded; } } // ================= UBAH NAMA STATUS ================= String getStatusLabel(String status) { switch (status) { case "tepat_waktu": return "Tepat Waktu"; case "telat": return "Telat"; case "izin": return "Izin"; case "sakit": return "Sakit"; case "pulang": return "Pulang"; default: return "Tidak Hadir"; } } // ================= HAPUS RIWAYAT ================= Future hapusRiwayat() async { try { await refAbsen.remove(); if (mounted) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text("Semua riwayat absensi berhasil dihapus"), behavior: SnackBarBehavior.floating, shape: RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(10))), ), ); } } catch (e) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text("Gagal menghapus riwayat: $e"), backgroundColor: const Color(0xffDC2626), behavior: SnackBarBehavior.floating, shape: RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(10))), ), ); } } } // ================= KONFIRMASI HAPUS ================= void showHapusDialog() { showDialog( context: context, builder: (_) => AlertDialog( shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)), title: const Text( "Hapus Riwayat Absensi", style: TextStyle( fontWeight: FontWeight.bold, color: Color(0xff12175E), ), ), content: const Text( "Apakah Anda yakin ingin menghapus SEMUA riwayat absensi? Tindakan ini tidak dapat dibatalkan.", style: TextStyle(color: Color(0xff12175E), height: 1.4), ), actions: [ TextButton( onPressed: () => Navigator.pop(context), child: const Text( "Batal", style: TextStyle(color: Colors.grey, fontWeight: FontWeight.w600), ), ), ElevatedButton( style: ElevatedButton.styleFrom( backgroundColor: const Color(0xffDC2626), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), ), onPressed: () { Navigator.pop(context); hapusRiwayat(); }, child: const Row( mainAxisSize: MainAxisSize.min, children: [ Icon(Icons.delete_rounded, size: 18, color: Colors.white), SizedBox(width: 6), Text("Hapus", style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold)), ], ), ), ], ), ); } DateTime? parseDateKey(String value) { final normalized = value.replaceAll('/', '-'); return DateTime.tryParse(normalized); } String formatDateKey(DateTime value) { return "${value.year.toString().padLeft(4, '0')}-${value.month.toString().padLeft(2, '0')}-${value.day.toString().padLeft(2, '0')}"; } bool isDateInSelectedFilter(String tanggal) { final parsed = parseDateKey(tanggal); if (parsed == null) { return false; } switch (selectedFilter) { case "harian": return parsed.year == selectedDate.year && parsed.month == selectedDate.month && parsed.day == selectedDate.day; case "mingguan": { final startOfWeek = selectedDate.subtract(Duration(days: selectedDate.weekday - 1)); final endOfWeek = startOfWeek.add(const Duration(days: 6)); return !parsed.isBefore(startOfWeek) && !parsed.isAfter(endOfWeek); } case "bulanan": return parsed.year == selectedDate.year && parsed.month == selectedDate.month; case "tahunan": return parsed.year == selectedDate.year; default: return true; } } List getFilteredDates(Map absen) { final dates = {}; absen.forEach((uid, value) { if (value is Map) { value.forEach((tanggal, detail) { if (tanggal is String && isDateInSelectedFilter(tanggal)) { dates.add(tanggal); } }); } }); final sorted = dates.toList()..sort((a, b) => b.compareTo(a)); return sorted; } void moveFilter(int step) { setState(() { switch (selectedFilter) { case 'harian': selectedDate = selectedDate.add(Duration(days: step)); break; case 'mingguan': selectedDate = selectedDate.add(Duration(days: step * 7)); break; case 'bulanan': selectedDate = DateTime(selectedDate.year, selectedDate.month + step, 1); break; case 'tahunan': selectedDate = DateTime(selectedDate.year + step, 1, 1); break; } }); } String getFilterLabel() { switch (selectedFilter) { case 'harian': return 'Tanggal ${formatDateKey(selectedDate)}'; case 'mingguan': final startOfWeek = selectedDate.subtract(Duration(days: selectedDate.weekday - 1)); final endOfWeek = startOfWeek.add(const Duration(days: 6)); return '${formatDateKey(startOfWeek)} - ${formatDateKey(endOfWeek)}'; case 'bulanan': return '${_monthName(selectedDate.month)} ${selectedDate.year}'; case 'tahunan': return '${selectedDate.year}'; default: return 'Filter'; } } String _monthName(int month) { const months = [ 'Januari', 'Februari', 'Maret', 'April', 'Mei', 'Juni', 'Juli', 'Agustus', 'September', 'Oktober', 'November', 'Desember', ]; return months[month - 1]; } Future exportToExcel(Map absen) async { try { final excel = Excel.createExcel(); final sheet = excel['Laporan Absensi']; final filteredDates = getFilteredDates(absen); sheet.appendRow([ TextCellValue('Nama'), TextCellValue('Kelas'), TextCellValue('Tanggal'), TextCellValue('Status Masuk'), TextCellValue('Jam Masuk'), TextCellValue('Status Pulang'), TextCellValue('Jam Pulang'), ]); for (final tanggal in filteredDates) { for (final siswa in siswaList) { final uid = siswa['rfid']; final nama = siswa['nama'] ?? '-'; final kelas = siswa['kelas'] ?? '-'; String statusMasuk = 'tidak_hadir'; String jamMasuk = '-'; String statusPulang = 'tidak_hadir'; String jamPulang = '-'; if (absen[uid] != null && absen[uid][tanggal] != null) { final dataHariIni = Map.from(absen[uid][tanggal]); if (dataHariIni['masuk'] != null) { final masuk = Map.from(dataHariIni['masuk']); statusMasuk = masuk['status'] ?? 'tidak_hadir'; jamMasuk = masuk['jam'] ?? '-'; } if (dataHariIni['pulang'] != null) { final pulang = Map.from(dataHariIni['pulang']); statusPulang = pulang['status'] ?? 'tidak_hadir'; jamPulang = pulang['jam'] ?? '-'; } } sheet.appendRow([ TextCellValue(nama.toString()), TextCellValue(kelas.toString()), TextCellValue(tanggal.toString()), TextCellValue(getStatusLabel(statusMasuk)), TextCellValue(jamMasuk), TextCellValue(getStatusLabel(statusPulang)), TextCellValue(jamPulang), ]); } } final bytes = excel.encode(); if (bytes == null) { throw Exception('Gagal membuat file Excel'); } final dir = await getDownloadsDirectory(); if (dir == null) { throw Exception('Folder Downloads tidak tersedia'); } final fileName = 'laporan_absensi_${DateTime.now().millisecondsSinceEpoch}.xlsx'; final file = File('${dir.path}/$fileName'); await file.writeAsBytes(bytes); if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text('File Excel berhasil disimpan di ${file.path}'), behavior: SnackBarBehavior.floating, shape: const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(10))), ), ); } } catch (e) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text('Gagal mengekspor Excel: $e'), backgroundColor: const Color(0xffDC2626), behavior: SnackBarBehavior.floating, shape: const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(10))), ), ); } } } // ================= HEADER DESAIN SAMA ================= Widget header(BuildContext context) { final screenHeight = MediaQuery.of(context).size.height; return Container( width: double.infinity, height: screenHeight * 0.24, decoration: const BoxDecoration( gradient: LinearGradient( colors: [ Color(0xffE6DFFF), Color(0xffCCBFFF), ], begin: Alignment.topCenter, end: Alignment.bottomCenter, ), borderRadius: BorderRadius.only( bottomLeft: Radius.circular(40), bottomRight: Radius.circular(40), ), boxShadow: [ BoxShadow( color: Color(0xff7F56D9), blurRadius: 24, offset: Offset(0, 8), ), ], ), child: ClipRRect( borderRadius: const BorderRadius.only( bottomLeft: Radius.circular(40), bottomRight: Radius.circular(40), ), child: Stack( children: [ // Ornamen lingkaran Positioned( top: -30, left: -20, child: Container( width: 150, height: 150, decoration: BoxDecoration( shape: BoxShape.circle, color: Colors.white.withOpacity(0.15), ), ), ), // Garis gelombang Positioned.fill( child: CustomPaint(painter: HeaderWavePainter()), ), // Pola titik Positioned(top: 40, left: 24, child: _buildGridDots()), Positioned(top: 60, right: 60, child: _buildGridDots()), // Konten header Padding( padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 20), child: Row( children: [ // Tombol kembali Container( height: 44, width: 44, decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(14), boxShadow: [ BoxShadow( color: Colors.black.withOpacity(0.06), blurRadius: 12, offset: const Offset(0, 4), ) ], ), child: Material( color: Colors.transparent, child: InkWell( borderRadius: BorderRadius.circular(14), onTap: () => Navigator.pop(context), child: const Icon(Icons.arrow_back_ios_new, color: Color(0xff7F56D9), size: 18), ), ), ), const SizedBox(width: 16), Image.asset( 'lib/assets/sekolah.png', width: 50, height: 50, fit: BoxFit.contain, errorBuilder: (_, __, ___) => const Icon(Icons.school, size: 40, color: Color(0xff7F56D9)), ), const SizedBox(width: 12), const Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisAlignment: MainAxisAlignment.center, children: [ Text( "Laporan Absensi", style: TextStyle( color: Color(0xff12175E), fontSize: 22, fontWeight: FontWeight.bold, letterSpacing: -0.5, ), ), SizedBox(height: 4), Text( "Riwayat kehadiran siswa", style: TextStyle( color: Color(0xff12175E), fontSize: 14, fontWeight: FontWeight.w500, height: 0.7, ), ), ], ), ), ], ), ), ], ), ), ); } Widget _buildGridDots() { return Opacity( opacity: 0.25, child: Column( children: List.generate(4, (_) => Row( children: List.generate(4, (_) => Container( width: 2.5, height: 2.5, margin: const EdgeInsets.all(2.5), decoration: const BoxDecoration(color: Color(0xff12175E), shape: BoxShape.circle), )), )), ), ); } Widget sectionTitle(String title) { return Padding( padding: const EdgeInsets.only(left: 24, bottom: 12, top: 16), child: Row( children: [ Container(width: 24, height: 3, decoration: BoxDecoration(color: const Color(0xff7F56D9), borderRadius: BorderRadius.circular(1.5))), const SizedBox(width: 3), Container(width: 3, height: 3, decoration: const BoxDecoration(color: Color(0xff7F56D9), shape: BoxShape.circle)), const SizedBox(width: 8), Text( title.toUpperCase(), style: const TextStyle( fontSize: 14, fontWeight: FontWeight.bold, color: Color(0xff12175E), letterSpacing: 0.5, ), ), ], ), ); } @override Widget build(BuildContext context) { return Scaffold( backgroundColor: const Color(0xffF6F5FB), body: SafeArea( child: Column( children: [ header(context), Expanded( child: StreamBuilder( stream: refAbsen.onValue, builder: (context, snapshot) { if (!snapshot.hasData) { return const Center(child: CircularProgressIndicator(color: Color(0xff7F56D9))); } final absen = snapshot.data!.snapshot.value as Map? ?? {}; if (siswaList.isEmpty) { return const Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Icon(Icons.person_off_rounded, size: 60, color: Color(0xff12175E)), SizedBox(height: 12), Text("Data siswa belum dimuat", style: TextStyle(color: Color(0xff12175E))), ], ), ); } final tanggalList = getFilteredDates(absen); return Column( children: [ Container( margin: const EdgeInsets.fromLTRB(24, 16, 24, 8), padding: const EdgeInsets.all(16), decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(20), boxShadow: [ BoxShadow( color: const Color(0xff7F56D9).withOpacity(0.08), blurRadius: 16, offset: const Offset(0, 6), ), ], ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ const Expanded( child: Text( 'Filter laporan', style: TextStyle( fontWeight: FontWeight.bold, fontSize: 15, color: Color(0xff12175E), ), ), ), IconButton( onPressed: () => exportToExcel(absen), icon: const Icon(Icons.download_rounded, color: Color(0xff7F56D9)), tooltip: 'Download file Excel', ), ], ), const SizedBox(height: 8), Wrap( spacing: 8, runSpacing: 8, children: [ ChoiceChip( label: const Text('Harian'), selected: selectedFilter == 'harian', onSelected: (_) => setState(() => selectedFilter = 'harian'), selectedColor: const Color(0xff7F56D9), labelStyle: TextStyle(color: selectedFilter == 'harian' ? Colors.white : const Color(0xff12175E)), ), ChoiceChip( label: const Text('Mingguan'), selected: selectedFilter == 'mingguan', onSelected: (_) => setState(() => selectedFilter = 'mingguan'), selectedColor: const Color(0xff7F56D9), labelStyle: TextStyle(color: selectedFilter == 'mingguan' ? Colors.white : const Color(0xff12175E)), ), ChoiceChip( label: const Text('Bulanan'), selected: selectedFilter == 'bulanan', onSelected: (_) => setState(() => selectedFilter = 'bulanan'), selectedColor: const Color(0xff7F56D9), labelStyle: TextStyle(color: selectedFilter == 'bulanan' ? Colors.white : const Color(0xff12175E)), ), ChoiceChip( label: const Text('Tahunan'), selected: selectedFilter == 'tahunan', onSelected: (_) => setState(() => selectedFilter = 'tahunan'), selectedColor: const Color(0xff7F56D9), labelStyle: TextStyle(color: selectedFilter == 'tahunan' ? Colors.white : const Color(0xff12175E)), ), ], ), const SizedBox(height: 10), Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), decoration: BoxDecoration( color: const Color(0xffF6F5FB), borderRadius: BorderRadius.circular(14), ), child: Row( children: [ IconButton( onPressed: () => moveFilter(-1), icon: const Icon(Icons.chevron_left_rounded, color: Color(0xff7F56D9)), ), Expanded( child: Text( getFilterLabel(), textAlign: TextAlign.center, style: const TextStyle( fontWeight: FontWeight.bold, color: Color(0xff12175E), ), ), ), IconButton( onPressed: () => moveFilter(1), icon: const Icon(Icons.chevron_right_rounded, color: Color(0xff7F56D9)), ), ], ), ), ], ), ), Expanded( child: tanggalList.isEmpty ? const Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Icon(Icons.history_rounded, size: 60, color: Color(0xff12175E)), SizedBox(height: 12), Text( "Belum ada riwayat absensi pada periode ini", style: TextStyle(color: Color(0xff12175E)), ), ], ), ) : ListView.builder( physics: const BouncingScrollPhysics(), padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 8), itemCount: tanggalList.length, itemBuilder: (context, index) { final tanggal = tanggalList[index]; return Container( margin: const EdgeInsets.only(bottom: 20), decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(24), boxShadow: [ BoxShadow( color: const Color(0xff7F56D9).withOpacity(0.08), blurRadius: 20, spreadRadius: 0, offset: const Offset(0, 8), ), ], ), child: Theme( data: Theme.of(context).copyWith(dividerColor: Colors.transparent), child: ExpansionTile( iconColor: const Color(0xff7F56D9), collapsedIconColor: const Color(0xff7F56D9), tilePadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), leading: Container( width: 48, height: 48, decoration: BoxDecoration( color: const Color(0xff7F56D9).withOpacity(0.12), borderRadius: BorderRadius.circular(14), ), child: const Icon( Icons.calendar_month_rounded, color: Color(0xff7F56D9), size: 24, ), ), title: Text( tanggal, style: const TextStyle( fontWeight: FontWeight.bold, fontSize: 17, color: Color(0xff12175E), ), ), children: siswaList.map((doc) { final uid = doc['rfid']; final nama = doc['nama']; final kelas = doc['kelas'] ?? "-"; String statusMasuk = "tidak_hadir"; String jamMasuk = "-"; String statusPulang = "tidak_hadir"; String jamPulang = "-"; if (absen[uid] != null && absen[uid][tanggal] != null) { final dataHariIni = Map.from(absen[uid][tanggal]); if (dataHariIni['masuk'] != null) { final masuk = Map.from(dataHariIni['masuk']); statusMasuk = masuk['status'] ?? "tidak_hadir"; jamMasuk = masuk['jam'] ?? "-"; } if (dataHariIni['pulang'] != null) { final pulang = Map.from(dataHariIni['pulang']); statusPulang = pulang['status'] ?? "tidak_hadir"; jamPulang = pulang['jam'] ?? "-"; } } return Container( margin: const EdgeInsets.symmetric(horizontal: 20, vertical: 8), padding: const EdgeInsets.all(16), decoration: BoxDecoration( color: const Color(0xffF6F5FB), borderRadius: BorderRadius.circular(16), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Container( width: 40, height: 40, decoration: BoxDecoration( color: getStatusBgColor(statusMasuk), shape: BoxShape.circle, ), child: Icon( getStatusIcon(statusMasuk), color: getStatusTextColor(statusMasuk), size: 20, ), ), const SizedBox(width: 12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( nama, style: const TextStyle( fontWeight: FontWeight.bold, fontSize: 15, color: Color(0xff12175E), ), ), const SizedBox(height: 2), Text( "Kelas: $kelas", style: TextStyle( fontSize: 13, color: const Color(0xff12175E).withOpacity(0.6), ), ), ], ), ), ], ), const SizedBox(height: 14), Row( children: [ Expanded( child: Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), decoration: BoxDecoration( color: getStatusBgColor(statusMasuk), borderRadius: BorderRadius.circular(12), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( "Masuk", style: TextStyle( color: getStatusTextColor(statusMasuk).withOpacity(0.8), fontSize: 12, fontWeight: FontWeight.w600, ), ), const SizedBox(height: 4), Text( getStatusLabel(statusMasuk), style: TextStyle( color: getStatusTextColor(statusMasuk), fontWeight: FontWeight.bold, fontSize: 13, ), ), const SizedBox(height: 2), Text( jamMasuk, style: TextStyle( fontSize: 12, color: const Color(0xff12175E).withOpacity(0.6), ), ), ], ), ), ), const SizedBox(width: 10), Expanded( child: Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), decoration: BoxDecoration( color: getStatusBgColor(statusPulang), borderRadius: BorderRadius.circular(12), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( "Pulang", style: TextStyle( color: getStatusTextColor(statusPulang).withOpacity(0.8), fontSize: 12, fontWeight: FontWeight.w600, ), ), const SizedBox(height: 4), Text( getStatusLabel(statusPulang), style: TextStyle( color: getStatusTextColor(statusPulang), fontWeight: FontWeight.bold, fontSize: 13, ), ), const SizedBox(height: 2), Text( jamPulang, style: TextStyle( fontSize: 12, color: const Color(0xff12175E).withOpacity(0.6), ), ), ], ), ), ), ], ), ], ), ); }).toList(), ), ), ); }, ), ), // ✅ TOMBOL HAPUS DIPINDAH KE BAWAH Padding( padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 20), child: SizedBox( width: double.infinity, height: 52, child: ElevatedButton.icon( onPressed: showHapusDialog, style: ElevatedButton.styleFrom( backgroundColor: const Color(0xffDC2626), foregroundColor: Colors.white, elevation: 2, shadowColor: const Color(0xffDC2626).withOpacity(0.3), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(14), ), ), icon: const Icon(Icons.delete_rounded, size: 20), label: const Text( "Hapus Semua Riwayat", style: TextStyle( fontSize: 16, fontWeight: FontWeight.w600, ), ), ), ), ), ], ); }, ), ), ], ), ), ); } } // Widget gelombang yang sama class HeaderWavePainter extends CustomPainter { @override void paint(Canvas canvas, Size size) { final paint = Paint() ..color = Colors.white.withOpacity(0.3) ..style = PaintingStyle.stroke ..strokeWidth = 1.8; final path = Path(); path.moveTo(0, size.height * 0.4); path.quadraticBezierTo(size.width * 0.5, size.height * 0.1, size.width, size.height * 0.3); canvas.drawPath(path, paint); final path2 = Path(); path2.moveTo(0, size.height * 0.6); path2.quadraticBezierTo(size.width * 0.6, size.height * 0.2, size.width, size.height * 0.5); canvas.drawPath(path2, paint); } @override bool shouldRepaint(covariant CustomPainter oldDelegate) => false; }