1100 lines
46 KiB
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)),
|
|
],
|
|
);
|
|
}
|
|
} |