TKK_E32230254/lib/absensi_page.dart

873 lines
30 KiB
Dart

import 'dart:convert';
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:firebase_database/firebase_database.dart';
import 'package:cloud_firestore/cloud_firestore.dart';
class AbsensiPage extends StatefulWidget {
const AbsensiPage({super.key});
@override
State<AbsensiPage> createState() => _AbsensiPageState();
}
class _AbsensiPageState extends State<AbsensiPage> {
final refAbsen = FirebaseDatabase.instance.ref("absensi");
final refJadwal = FirebaseDatabase.instance.ref("jadwal/absen");
final refIzin = FirebaseDatabase.instance.ref("izin");
final jamMasuk = TextEditingController();
final jamPulang = TextEditingController();
String today = DateTime.now().toString().substring(0, 10);
List siswaList = [];
@override
void initState() {
super.initState();
loadJadwal();
loadSiswa();
}
@override
void dispose() {
jamMasuk.dispose();
jamPulang.dispose();
super.dispose();
}
// ================= LOAD DATA BACKEND =================
Future<void> loadSiswa() async {
final snap = await FirebaseFirestore.instance.collection('siswa').get();
setState(() {
siswaList = snap.docs;
});
}
Future<void> loadJadwal() async {
final snap = await refJadwal.get();
if (snap.exists) {
final data = snap.value as Map;
jamMasuk.text = data['jam_masuk'] ?? '';
jamPulang.text = data['jam_pulang'] ?? '';
}
}
Future<void> pilihJam(TextEditingController controller) async {
final picked = await showTimePicker(
context: context,
initialTime: TimeOfDay.now(),
builder: (context, child) {
return Theme(
data: Theme.of(context).copyWith(
colorScheme: const ColorScheme.light(primary: Color(0xff16A34A)),
),
child: child!,
);
},
);
if (picked != null) {
controller.text =
"${picked.hour.toString().padLeft(2, '0')}:${picked.minute.toString().padLeft(2, '0')}";
setState(() {});
}
}
Future<void> simpanJadwal() async {
await refJadwal.set({
"jam_masuk": jamMasuk.text,
"jam_pulang": jamPulang.text,
});
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text("Jadwal berhasil disimpan"),
behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(10))),
),
);
}
}
// ================= DEKORASI WARNA STATUS =================
Color getStatusTextColor(String status) {
switch (status) {
case "tepat_waktu":
return const Color(0xff16A34A);
case "telat":
return const Color(0xffEA580C);
case "pulang":
return const Color(0xff0284C7);
case "izin":
return const Color(0xff7C3AED);
case "sakit":
return const Color(0xff9333EA);
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 "pulang":
return const Color(0xffF0F9FF);
case "izin":
return const Color(0xffFAF5FF);
case "sakit":
return const Color(0xffF3E8FF);
default:
return const Color(0xffFEF2F2);
}
}
// ================= ACC / TOLAK LOGIC =================
Future<void> acc(String key, Map data) async {
if (data['rfid'] == null) return;
final uid = data['rfid'];
final now = DateTime.now();
final jam = "${now.hour.toString().padLeft(2, '0')}:${now.minute.toString().padLeft(2, '0')}";
await refAbsen.child("$uid/$today/masuk").set({
"jam": jam,
"status": data['jenis'],
});
await refIzin.child(key).remove();
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text("${data['nama']} telah disetujui"),
behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(10))),
),
);
}
}
Future<void> tolak(String key) async {
await refIzin.child(key).remove();
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text("Permintaan izin ditolak"),
behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(10))),
),
);
}
}
void bukaFoto(String? base64String) {
showDialog(
context: context,
builder: (_) => Dialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
decoration: const BoxDecoration(
gradient: LinearGradient(
colors: [Color(0xffE6DFFF), Color(0xffCCBFFF)],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.only(
topLeft: Radius.circular(24),
topRight: Radius.circular(24),
),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text(
"Surat Izin",
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: Color(0xff12175E),
),
),
InkWell(
onTap: () => Navigator.pop(context),
child: const Icon(Icons.close, color: Color(0xff12175E)),
),
],
),
),
Padding(
padding: const EdgeInsets.all(20),
child: (base64String == null || base64String.isEmpty)
? const Text(
"Tidak ada gambar surat izin",
style: TextStyle(color: Color(0xff12175E), fontSize: 15),
)
: Builder(
builder: (_) {
try {
Uint8List bytes = base64Decode(base64String);
return ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Image.memory(bytes, fit: BoxFit.contain),
);
} catch (e) {
return const Text(
"Gagal memuat gambar",
style: TextStyle(color: Color(0xffDC2626), fontSize: 15),
);
}
},
),
),
],
),
),
);
}
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: [
Positioned(
top: -30,
left: -20,
child: Container(
width: 150,
height: 150,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: Colors.white.withOpacity(0.15),
),
),
),
Positioned.fill(
child: CustomPaint(painter: HeaderWavePainter()),
),
Positioned(top: 40, left: 24, child: _buildGridDots()),
Positioned(top: 60, right: 60, child: _buildGridDots()),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 20),
child: Row(
children: [
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(
"Absensi Harian",
style: TextStyle(
color: Color(0xff12175E),
fontSize: 22,
fontWeight: FontWeight.bold,
letterSpacing: -0.5,
),
),
SizedBox(height: 4),
Text(
"Kelola kehadiran siswa hari ini",
style: TextStyle(
color: Color(0xff12175E),
fontSize: 14,
fontWeight: FontWeight.w500,
height: 0.7,
),
),
],
),
),
Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.4),
borderRadius: BorderRadius.circular(14),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.04),
blurRadius: 8,
offset: const Offset(0, 2),
)
],
),
child: const Icon(
Icons.assignment_turned_in_rounded,
color: Color(0xff16A34A),
size: 26,
),
),
],
),
),
],
),
),
);
}
Widget _buildGridDots() {
return Opacity(
opacity: 0.25,
child: Column(
children: List.generate(4, (index) => Row(
children: List.generate(4, (index) => 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(0xff16A34A), borderRadius: BorderRadius.circular(1.5))),
const SizedBox(width: 3),
Container(width: 3, height: 3, decoration: const BoxDecoration(color: Color(0xff16A34A), shape: BoxShape.circle)),
const SizedBox(width: 8),
Text(
title.toUpperCase(),
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
color: Color(0xff12175E),
letterSpacing: 0.5,
),
),
],
),
);
}
Widget jamInput({
required TextEditingController controller,
required String label,
required VoidCallback onTap,
}) {
return Container(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(20),
boxShadow: [
BoxShadow(
color: const Color(0xff16A34A).withOpacity(0.08),
blurRadius: 16,
spreadRadius: 0,
offset: const Offset(0, 6),
),
],
),
child: TextField(
controller: controller,
readOnly: true,
onTap: onTap,
textAlign: TextAlign.center,
style: const TextStyle(
fontSize: 28,
fontWeight: FontWeight.bold,
color: Color(0xff12175E),
),
decoration: InputDecoration(
border: InputBorder.none,
contentPadding: const EdgeInsets.symmetric(vertical: 20),
prefixIcon: Container(
margin: const EdgeInsets.all(8),
width: 40,
height: 40,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: const Color(0xff16A34A).withOpacity(0.12),
),
child: const Icon(Icons.access_time_rounded, color: Color(0xff16A34A)),
),
hintText: "--:--",
hintStyle: TextStyle(color: const Color(0xff12175E).withOpacity(0.3)),
),
),
);
}
Widget izinCard(Map<String, dynamic> data, String key) {
return Container(
width: 260,
margin: const EdgeInsets.all(8),
padding: const EdgeInsets.all(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: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
width: 40,
height: 40,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: const Color(0xff7C3AED).withOpacity(0.12),
),
child: const Icon(Icons.description_rounded, color: Color(0xff7C3AED), size: 20),
),
const SizedBox(width: 10),
Expanded(
child: Text(
data['nama'] ?? 'Tidak ada nama',
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.bold,
color: Color(0xff12175E),
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
),
const SizedBox(height: 12),
Text(
"Jenis: ${data['jenis'] ?? '-'}",
style: TextStyle(
fontSize: 13,
color: const Color(0xff12175E).withOpacity(0.6),
),
),
const Spacer(),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
TextButton(
onPressed: () => bukaFoto(data['foto']),
style: TextButton.styleFrom(
padding: EdgeInsets.zero,
minimumSize: const Size(60, 30),
),
child: const Text(
"Lihat Surat",
style: TextStyle(color: Color(0xff7F56D9), fontSize: 13, fontWeight: FontWeight.w600),
),
),
Row(
children: [
SizedBox(
height: 32,
child: ElevatedButton(
onPressed: () => acc(key, data),
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xff16A34A),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
padding: const EdgeInsets.symmetric(horizontal: 10),
),
child: const Text("ACC", style: TextStyle(fontSize: 12, fontWeight: FontWeight.bold)),
),
),
const SizedBox(width: 6),
SizedBox(
height: 32,
child: ElevatedButton(
onPressed: () => tolak(key),
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xffDC2626),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
padding: const EdgeInsets.symmetric(horizontal: 10),
),
child: const Text("Tolak", style: TextStyle(fontSize: 12, fontWeight: FontWeight.bold)),
),
),
],
)
],
)
],
),
);
}
Widget absensiCard({
required String nama,
required String jamMasuk,
required String jamPulang,
required String statusMasuk,
required String statusPulang,
}) {
return Container(
margin: const EdgeInsets.only(bottom: 16, left: 24, right: 24),
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(24),
boxShadow: [
BoxShadow(
color: const Color(0xff16A34A).withOpacity(0.08),
blurRadius: 20,
spreadRadius: 0,
offset: const Offset(0, 8),
),
],
),
child: Row(
children: [
Container(
width: 56,
height: 56,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: const Color(0xff16A34A).withOpacity(0.12),
boxShadow: [
BoxShadow(
color: const Color(0xff16A34A).withOpacity(0.15),
blurRadius: 12,
spreadRadius: 2,
),
],
),
child: const Icon(Icons.person_rounded, color: Color(0xff16A34A), size: 26),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
nama,
style: const TextStyle(
fontSize: 17,
fontWeight: FontWeight.bold,
color: Color(0xff12175E),
),
),
const SizedBox(height: 6),
Text(
"Masuk: $jamMasuk",
style: TextStyle(
fontSize: 14,
color: const Color(0xff12175E).withOpacity(0.6),
),
),
Text(
"Pulang: $jamPulang",
style: TextStyle(
fontSize: 14,
color: const Color(0xff12175E).withOpacity(0.6),
),
),
],
),
),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Container(
margin: const EdgeInsets.only(bottom: 6),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: getStatusBgColor(statusMasuk),
borderRadius: BorderRadius.circular(12),
),
child: Text(
statusMasuk == "tidak_hadir" ? "BELUM" : statusMasuk.toUpperCase(),
style: TextStyle(
color: getStatusTextColor(statusMasuk),
fontWeight: FontWeight.bold,
fontSize: 11,
),
),
),
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: getStatusBgColor(statusPulang),
borderRadius: BorderRadius.circular(12),
),
child: Text(
statusPulang == "tidak_hadir" ? "BELUM" : statusPulang.toUpperCase(),
style: TextStyle(
color: getStatusTextColor(statusPulang),
fontWeight: FontWeight.bold,
fontSize: 11,
),
),
),
],
)
],
),
);
}
Widget gradientButton({
required String title,
required VoidCallback onTap,
}) {
return SizedBox(
height: 52,
width: double.infinity,
child: DecoratedBox(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16),
gradient: const LinearGradient(
colors: [Color(0xff16A34A), Color(0xff22C55E)],
),
boxShadow: [
BoxShadow(
color: const Color(0xff16A34A).withOpacity(0.3),
blurRadius: 10,
offset: const Offset(0, 4),
),
],
),
child: ElevatedButton(
onPressed: onTap,
style: ElevatedButton.styleFrom(
elevation: 0,
backgroundColor: Colors.transparent,
shadowColor: Colors.transparent,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
),
child: Text(
title,
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: Colors.white),
),
),
),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: const Color(0xffF6F5FB),
body: SafeArea(
child: Column(
children: [
header(context),
Expanded(
child: SingleChildScrollView(
physics: const BouncingScrollPhysics(),
child: Column(
children: [
sectionTitle("Jadwal Kehadiran"),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Row(
children: [
Expanded(child: jamInput(controller: jamMasuk, label: "Jam Masuk", onTap: () => pilihJam(jamMasuk))),
const SizedBox(width: 16),
Expanded(child: jamInput(controller: jamPulang, label: "Jam Pulang", onTap: () => pilihJam(jamPulang))),
],
),
),
const SizedBox(height: 20),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 80),
child: gradientButton(title: "Simpan Jadwal", onTap: simpanJadwal),
),
sectionTitle("Permintaan Izin"),
StreamBuilder(
stream: refIzin.onValue,
builder: (context, snapshot) {
if (!snapshot.hasData) return const SizedBox(height: 150);
final data = snapshot.data!.snapshot.value as Map? ?? {};
if (data.isEmpty) {
return SizedBox(
height: 150,
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.check_circle_outline, size: 40, color: const Color(0xff12175E).withOpacity(0.3)),
const SizedBox(height: 8),
Text(
"Tidak ada permintaan izin",
style: TextStyle(color: const Color(0xff12175E).withOpacity(0.5)),
),
],
),
),
);
}
return SizedBox(
height: 170,
child: ListView(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 16),
children: data.entries.map((e) {
return izinCard(Map<String, dynamic>.from(e.value), e.key);
}).toList(),
),
);
},
),
sectionTitle("Daftar Kehadiran Hari Ini"),
StreamBuilder(
stream: refAbsen.onValue,
builder: (context, snapshot) {
if (!snapshot.hasData) {
return const Padding(
padding: EdgeInsets.all(30.0),
child: Center(child: CircularProgressIndicator(color: Color(0xff7F56D9))),
);
}
final absen = snapshot.data!.snapshot.value as Map? ?? {};
if (siswaList.isEmpty) {
return const Center(child: Padding(padding: EdgeInsets.all(20), child: Text("Memuat data siswa...")));
}
return ListView.builder(
physics: const NeverScrollableScrollPhysics(),
shrinkWrap: true,
itemCount: siswaList.length,
itemBuilder: (c, i) {
final doc = siswaList[i];
final uid = doc['rfid'];
final nama = doc['nama'];
String statusMasuk = "tidak_hadir";
String jamMasukSiswa = "-";
String statusPulang = "tidak_hadir";
String jamPulangSiswa = "-";
if (absen[uid] != null && absen[uid][today] != null) {
final dataHariIni = absen[uid][today] as Map;
if (dataHariIni['masuk'] != null) {
statusMasuk = dataHariIni['masuk']['status'] ?? "tidak_hadir";
jamMasukSiswa = dataHariIni['masuk']['jam'] ?? "-";
}
if (dataHariIni['pulang'] != null) {
statusPulang = dataHariIni['pulang']['status'] ?? "tidak_hadir";
jamPulangSiswa = dataHariIni['pulang']['jam'] ?? "-";
}
}
return absensiCard(
nama: nama,
jamMasuk: jamMasukSiswa,
jamPulang: jamPulangSiswa,
statusMasuk: statusMasuk,
statusPulang: statusPulang,
);
},
);
},
),
const SizedBox(height: 20),
],
),
),
),
],
),
),
);
}
}
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;
}