import 'dart:async'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_map/flutter_map.dart'; import 'package:geolocator/geolocator.dart'; import 'package:latlong2/latlong.dart'; import 'package:supabase_flutter/supabase_flutter.dart'; import '../../models/attendance_log.dart'; import '../../models/duty_schedule.dart'; import '../../models/leave_of_absence.dart'; import '../../models/pass_slip.dart'; import '../../models/profile.dart'; import '../../providers/attendance_provider.dart'; import '../../providers/debug_settings_provider.dart'; import '../../providers/leave_provider.dart'; import '../../screens/dashboard/dashboard_screen.dart'; import '../../providers/pass_slip_provider.dart'; import '../../providers/profile_provider.dart'; import '../../providers/reports_provider.dart'; import '../../providers/whereabouts_provider.dart'; import '../../providers/workforce_provider.dart'; import '../../providers/it_service_request_provider.dart'; import '../../models/it_service_request.dart'; import '../../theme/m3_motion.dart'; import '../../utils/app_time.dart'; import '../../utils/location_permission.dart'; import 'package:permission_handler/permission_handler.dart'; import '../../widgets/face_verification_overlay.dart'; import '../../utils/snackbar.dart'; import '../../widgets/gemini_animated_text_field.dart'; import '../../widgets/gemini_button.dart'; import '../../widgets/multi_select_picker.dart'; import '../../widgets/app_page_header.dart'; import '../../widgets/responsive_body.dart'; class AttendanceScreen extends ConsumerStatefulWidget { const AttendanceScreen({super.key}); @override ConsumerState createState() => _AttendanceScreenState(); } class _AttendanceScreenState extends ConsumerState with TickerProviderStateMixin { late TabController _tabController; bool _fabMenuOpen = false; // (moved into _CheckInTabState) local tracking state for optimistic UI updates // bool _trackingLocal = false; // bool _trackingSaving = false; @override void initState() { super.initState(); _tabController = TabController(length: 4, vsync: this); _tabController.addListener(_onTabChanged); } void _onTabChanged() { if (_fabMenuOpen) setState(() => _fabMenuOpen = false); setState(() {}); // rebuild for FAB visibility } @override void dispose() { _tabController.removeListener(_onTabChanged); _tabController.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final theme = Theme.of(context); final colors = theme.colorScheme; final profile = ref.watch(currentProfileProvider).valueOrNull; final showFab = _tabController.index >= 2; // Pass Slip or Leave tabs return ResponsiveBody( maxWidth: 1200, child: Scaffold( backgroundColor: Colors.transparent, floatingActionButton: showFab && profile != null ? _buildFabMenu(context, theme, colors, profile) : null, body: Column( children: [ const AppPageHeader( title: 'Attendance', subtitle: 'Check in, logbook, pass slip and leave', ), TabBar( controller: _tabController, isScrollable: true, tabAlignment: TabAlignment.start, tabs: const [ Tab(text: 'Check In'), Tab(text: 'Logbook'), Tab(text: 'Pass Slip'), Tab(text: 'Leave'), ], ), Expanded( child: TabBarView( controller: _tabController, children: const [ _CheckInTab(), _LogbookTab(), _PassSlipTab(), _LeaveTab(), ], ), ), ], ), ), ); } Widget _buildFabMenu( BuildContext context, ThemeData theme, ColorScheme colors, Profile profile, ) { final isAdmin = profile.role == 'admin' || profile.role == 'programmer'; final canFileLeave = profile.role == 'admin' || profile.role == 'programmer' || profile.role == 'dispatcher' || profile.role == 'it_staff'; if (!_fabMenuOpen) { return M3ExpandedFab( heroTag: 'attendance_fab', onPressed: () => setState(() => _fabMenuOpen = true), icon: const Icon(Icons.add), label: const Text('Actions'), ); } return Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.end, children: [ // Leave option if (canFileLeave) ...[ _FabMenuItem( heroTag: 'fab_leave', label: 'File Leave', icon: Icons.event_busy, color: colors.tertiaryContainer, onColor: colors.onTertiaryContainer, onTap: () { setState(() => _fabMenuOpen = false); _showLeaveDialog(context, isAdmin); }, ), const SizedBox(height: 12), ], // Pass Slip option _FabMenuItem( heroTag: 'fab_slip', label: 'Request Slip', icon: Icons.receipt_long, color: colors.secondaryContainer, onColor: colors.onSecondaryContainer, onTap: () { setState(() => _fabMenuOpen = false); _showPassSlipDialog(context, profile); }, ), const SizedBox(height: 12), // Close button FloatingActionButton( heroTag: 'fab_close', onPressed: () => setState(() => _fabMenuOpen = false), child: const Icon(Icons.close), ), ], ); } void _showLeaveDialog(BuildContext context, bool isAdmin) { m3ShowDialog( context: context, builder: (ctx) => _FileLeaveDialog( isAdmin: isAdmin, onSubmitted: () { if (mounted) { showSuccessSnackBar( context, isAdmin ? 'Leave filed and auto-approved.' : 'Leave application submitted for approval.', ); } }, ), ); } void _showPassSlipDialog(BuildContext context, Profile profile) { final isAdmin = profile.role == 'admin' || profile.role == 'programmer'; if (isAdmin) { showWarningSnackBar(context, 'Admins cannot file pass slips.'); return; } final activeSlip = ref.read(activePassSlipProvider); if (activeSlip != null) { showWarningSnackBar(context, 'You already have an active pass slip.'); return; } final now = AppTime.now(); final today = DateTime(now.year, now.month, now.day); final schedules = ref.read(dutySchedulesProvider).valueOrNull ?? []; final todaySchedule = schedules.where((s) { final sDay = DateTime( s.startTime.year, s.startTime.month, s.startTime.day, ); return s.userId == profile.id && sDay == today && s.shiftType != 'overtime'; }).toList(); if (todaySchedule.isEmpty) { showWarningSnackBar(context, 'No schedule found for today.'); return; } m3ShowDialog( context: context, builder: (ctx) => _PassSlipDialog( scheduleId: todaySchedule.first.id, onSubmitted: () { if (mounted) { showSuccessSnackBar(context, 'Pass slip requested.'); } }, ), ); } } // ──────────────────────────────────────────────── // Tab 1 – Check In / Check Out // ──────────────────────────────────────────────── class _CheckInTab extends ConsumerStatefulWidget { const _CheckInTab(); @override ConsumerState<_CheckInTab> createState() => _CheckInTabState(); } class _CheckInTabState extends ConsumerState<_CheckInTab> { bool _loading = false; // local tracking state for optimistic UI updates (optimistic toggle in UI) bool _trackingLocal = false; bool _trackingSaving = false; final _justCheckedIn = {}; final _checkInLogIds = {}; String? _overtimeLogId; final _justificationController = TextEditingController(); bool _isGeminiProcessing = false; // Animated clock Timer? _clockTimer; DateTime _currentTime = AppTime.now(); // Geofence state bool _insideGeofence = false; bool _checkingGeofence = true; @override void initState() { super.initState(); _clockTimer = Timer.periodic(const Duration(seconds: 1), (_) { if (mounted) setState(() => _currentTime = AppTime.now()); }); _checkGeofenceStatus(); } @override void dispose() { _clockTimer?.cancel(); _justificationController.dispose(); super.dispose(); } Future _checkGeofenceStatus() async { final debugBypass = kDebugMode && ref.read(debugSettingsProvider).bypassGeofence; if (debugBypass) { if (mounted) { setState(() { _insideGeofence = true; _checkingGeofence = false; }); } return; } try { final geoCfg = await ref.read(geofenceProvider.future); final position = await Geolocator.getCurrentPosition( locationSettings: const LocationSettings( accuracy: LocationAccuracy.high, ), ); bool inside = true; if (geoCfg != null) { if (geoCfg.hasPolygon) { inside = geoCfg.containsPolygon( position.latitude, position.longitude, ); } else if (geoCfg.hasCircle) { final dist = Geolocator.distanceBetween( position.latitude, position.longitude, geoCfg.lat!, geoCfg.lng!, ); inside = dist <= (geoCfg.radiusMeters ?? 0); } } if (mounted) { setState(() { _insideGeofence = inside; _checkingGeofence = false; }); } } catch (_) { if (mounted) { setState(() { _insideGeofence = false; _checkingGeofence = false; }); } } } @override Widget build(BuildContext context) { final theme = Theme.of(context); final colors = theme.colorScheme; final profile = ref.watch(currentProfileProvider).valueOrNull; final schedulesAsync = ref.watch(dutySchedulesProvider); final logsAsync = ref.watch(attendanceLogsProvider); final allowTracking = profile?.allowTracking ?? false; // local state for optimistic switch update. We only trust `_trackingLocal` // while a save is in flight – after that the server-side value (`allowTracking`) // is authoritative. This ensures the toggle correctly reflects the persisted // setting when the app restarts. final bool effectiveTracking = _trackingSaving ? _trackingLocal : allowTracking; if (profile == null) { return const Center(child: CircularProgressIndicator()); } final now = _currentTime; final todayStart = DateTime(now.year, now.month, now.day); final tomorrowStart = todayStart.add(const Duration(days: 1)); final noon = DateTime(now.year, now.month, now.day, 12, 0); final onePM = DateTime(now.year, now.month, now.day, 13, 0); // Find today's schedule for the current user. // Includes schedules where the user is a reliever (not only the primary user). // Exclude overtime schedules – they only belong in the Logbook. // We treat a schedule as "today" if it overlaps the local calendar day window. final schedules = schedulesAsync.valueOrNull ?? []; final todaySchedule = schedules.where((s) { final isAssigned = s.userId == profile.id || s.relieverIds.contains(profile.id); final overlapsToday = s.startTime.isBefore(tomorrowStart) && s.endTime.isAfter(todayStart); return isAssigned && overlapsToday; }).toList(); // Find active attendance log (checked in but not out) final logs = logsAsync.valueOrNull ?? []; final activeLog = logs .where((l) => l.userId == profile.id && !l.isCheckedOut) .toList(); final activeOvertimeLog = activeLog .where((l) => (l.justification ?? '').trim().isNotEmpty) .toList(); // IT Service Request override (allows check-in/out outside geofence) final itRequests = ref.watch(itServiceRequestsProvider).valueOrNull ?? []; final itAssignments = ref.watch(itServiceRequestAssignmentsProvider).valueOrNull ?? []; final assignedRequestIds = itAssignments .where((a) => a.userId == profile.id) .map((a) => a.requestId) .toSet(); final hasGeofenceOverride = itRequests.any((r) { return assignedRequestIds.contains(r.id) && r.outsidePremiseAllowed && (r.status == ItServiceRequestStatus.scheduled || r.status == ItServiceRequestStatus.inProgressDryRun || r.status == ItServiceRequestStatus.inProgress); }); final hasScheduleToday = todaySchedule.isNotEmpty; final latestScheduleEnd = hasScheduleToday ? todaySchedule .map((s) => s.endTime) .reduce((a, b) => a.isAfter(b) ? a : b) : null; final hasScheduleEnded = latestScheduleEnd != null && now.isAfter(latestScheduleEnd); // If the user has an approved IT Service Request override, treat it as a "schedule" for // purposes of showing the normal check-in UI (even if the duty schedule list is empty). // (Note: this override should not prevent the overtime check-in from being shown.) // Show overtime check-in when the user has no schedule today, or their last // scheduled shift has already ended. final showOvertimeCard = (activeOvertimeLog.isEmpty && _overtimeLogId == null) && activeLog.isEmpty && (!hasScheduleToday || hasScheduleEnded); if (kDebugMode && showOvertimeCard) { final assignedSchedules = schedules .where( (s) => s.userId == profile.id || s.relieverIds.contains(profile.id), ) .toList(); final assignedTodaySchedules = todaySchedule; debugPrint( 'Attendance: showOvertimeCard=true (profile=${profile.id}, hasScheduleToday=$hasScheduleToday, hasScheduleEnded=$hasScheduleEnded, schedules=${schedules.length}, assigned=${assignedSchedules.length}, assignedToday=${assignedTodaySchedules.length})', ); if (assignedSchedules.isNotEmpty) { for (final s in assignedSchedules.take(6)) { debugPrint( ' assigned: ${s.id} start=${s.startTime.toIso8601String()} end=${s.endTime.toIso8601String()} user=${s.userId} relievers=${s.relieverIds} shiftType=${s.shiftType}', ); } } if (assignedTodaySchedules.isNotEmpty) { for (final s in assignedTodaySchedules) { debugPrint( ' assignedToday: ${s.id} start=${s.startTime.toIso8601String()} end=${s.endTime.toIso8601String()} user=${s.userId} relievers=${s.relieverIds} shiftType=${s.shiftType}', ); } } } return SingleChildScrollView( padding: const EdgeInsets.all(16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ // Location tracking toggle Card( child: Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), child: Row( children: [ Icon( Icons.location_on, size: 20, color: allowTracking ? colors.primary : colors.outline, ), const SizedBox(width: 8), Expanded( child: Text( 'Location Tracking', style: theme.textTheme.bodyMedium, ), ), Switch( value: effectiveTracking, onChanged: (v) async { if (_trackingSaving) return; // ignore while pending if (v) { // first ensure foreground & background location perms final grantedFg = await ensureLocationPermission(); if (!grantedFg) { if (context.mounted) { showWarningSnackBar( context, 'Location permission is required.', ); } return; } final grantedBg = await ensureBackgroundLocationPermission(); if (!grantedBg) { // if permanently denied, open settings so the user can // manually grant the permission; otherwise just warn. if (await Permission .locationAlways .isPermanentlyDenied || await Permission.location.isPermanentlyDenied) { openAppSettings(); } if (context.mounted) { showWarningSnackBar( context, 'Background location permission is required.', ); } return; } } // optimistically flip setState(() { _trackingLocal = v; _trackingSaving = true; }); try { await ref .read(whereaboutsControllerProvider) .setTracking(v); } catch (e) { if (context.mounted) { showWarningSnackBar(context, e.toString()); } // revert to actual stored value setState(() { _trackingLocal = allowTracking; }); } finally { setState(() { _trackingSaving = false; }); } }, ), ], ), ), ), const SizedBox(height: 8), // Debug: Geofence bypass toggle (only in debug mode) if (kDebugMode) Card( color: colors.errorContainer, child: Padding( padding: const EdgeInsets.symmetric( horizontal: 16, vertical: 8, ), child: Row( children: [ Icon( Icons.bug_report, size: 20, color: colors.onErrorContainer, ), const SizedBox(width: 8), Expanded( child: Text( 'DEBUG: Bypass Geofence', style: theme.textTheme.bodyMedium?.copyWith( color: colors.onErrorContainer, fontWeight: FontWeight.w600, ), ), ), Switch( value: ref.watch(debugSettingsProvider).bypassGeofence, onChanged: (v) => ref .read(debugSettingsProvider.notifier) .setGeofenceBypass(v), ), ], ), ), ), const SizedBox(height: 16), // ── Animated Clock ── () { // Calculate lateness color from schedule Color timeColor = colors.onSurface; if (todaySchedule.isNotEmpty) { final scheduleStart = todaySchedule.first.startTime; final diff = scheduleStart.difference(now); if (diff.isNegative) { timeColor = colors.error; } else if (diff.inMinutes <= 5) { timeColor = colors.error; } else if (diff.inMinutes <= 15) { timeColor = Colors.orange; } else if (diff.inMinutes <= 30) { timeColor = colors.tertiary; } } return Center( child: Column( children: [ AnimatedDefaultTextStyle( duration: M3Motion.standard, curve: M3Motion.standard_, style: theme.textTheme.displayMedium!.copyWith( fontWeight: FontWeight.w300, color: timeColor, letterSpacing: 2, ), child: Text(AppTime.formatTime(now)), ), const SizedBox(height: 4), Text( AppTime.formatDate(now), style: theme.textTheme.bodyMedium?.copyWith( color: colors.onSurfaceVariant, ), ), const SizedBox(height: 8), AnimatedSwitcher( duration: M3Motion.short, child: _checkingGeofence ? Row( mainAxisSize: MainAxisSize.min, key: const ValueKey('checking'), children: [ SizedBox( width: 14, height: 14, child: CircularProgressIndicator( strokeWidth: 2, color: colors.outline, ), ), const SizedBox(width: 8), Text( 'Checking location...', style: theme.textTheme.bodySmall?.copyWith( color: colors.outline, ), ), ], ) : Row( mainAxisSize: MainAxisSize.min, key: ValueKey(_insideGeofence), children: [ Icon( _insideGeofence || hasGeofenceOverride ? Icons.check_circle : Icons.cancel, size: 16, color: _insideGeofence || hasGeofenceOverride ? Colors.green : colors.error, ), const SizedBox(width: 6), Text( _insideGeofence ? 'Within geofence' : hasGeofenceOverride ? 'Outside geofence (allowed by IT request)' : 'Outside geofence', style: theme.textTheme.bodySmall?.copyWith( color: _insideGeofence || hasGeofenceOverride ? Colors.green : colors.error, fontWeight: FontWeight.w500, ), ), if (!_insideGeofence && !hasGeofenceOverride) ...[ const SizedBox(width: 8), TextButton( onPressed: () { setState(() => _checkingGeofence = true); _checkGeofenceStatus(); }, child: const Text('Refresh'), ), ], ], ), ), ], ), ); }(), const SizedBox(height: 24), // Today's schedule Text( "Today's Schedule", style: theme.textTheme.titleMedium?.copyWith( fontWeight: FontWeight.w600, ), ), const SizedBox(height: 8), if (activeOvertimeLog.isNotEmpty || _overtimeLogId != null) _buildActiveOvertimeCard(context, theme, colors, activeOvertimeLog) else if (showOvertimeCard) _buildOvertimeCard( context, theme, colors, hasScheduleToday: hasScheduleToday, ) else ...todaySchedule.map((schedule) { // All logs for this schedule. final scheduleLogs = logs .where((l) => l.dutyScheduleId == schedule.id) .toList(); final realActiveLog = scheduleLogs .where((l) => !l.isCheckedOut) .toList(); final completedLogs = scheduleLogs .where((l) => l.isCheckedOut) .toList(); final hasActiveLog = realActiveLog.isNotEmpty; final isLocallyCheckedIn = _justCheckedIn.contains(schedule.id); final showCheckOut = hasActiveLog || isLocallyCheckedIn; final isShiftOver = !now.isBefore(schedule.endTime); final isFullDay = schedule.endTime.difference(schedule.startTime).inHours >= 6; final isNoonBreakWindow = isFullDay && !now.isBefore(noon) && now.isBefore(onePM); // Determine status label. String statusLabel; if (showCheckOut) { statusLabel = 'On Duty'; } else if (isShiftOver) { statusLabel = scheduleLogs.isEmpty ? 'Absent' : 'Completed'; } else if (completedLogs.isNotEmpty && isNoonBreakWindow) { statusLabel = 'Noon Break'; } else if (completedLogs.isNotEmpty) { statusLabel = 'Early Out'; } else { statusLabel = 'Scheduled'; } final canCheckIn = !showCheckOut && !isShiftOver && (_insideGeofence || hasGeofenceOverride) && !_checkingGeofence; return Card( child: Padding( padding: const EdgeInsets.all(16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Icon(Icons.schedule, size: 20, color: colors.primary), const SizedBox(width: 8), Expanded( child: Text( _shiftLabel(schedule.shiftType), style: theme.textTheme.titleSmall?.copyWith( fontWeight: FontWeight.w600, ), ), ), _statusChip(context, statusLabel), ], ), const SizedBox(height: 8), Text( '${AppTime.formatTime(schedule.startTime)} – ${AppTime.formatTime(schedule.endTime)}', style: theme.textTheme.bodyMedium, ), // Session history — show each completed check-in/out pair. if (completedLogs.isNotEmpty) ...[ const SizedBox(height: 12), Text( 'Sessions', style: theme.textTheme.labelMedium?.copyWith( color: colors.onSurfaceVariant, ), ), const SizedBox(height: 4), ...completedLogs.map((log) { final dur = log.checkOutAt!.difference(log.checkInAt); final hours = dur.inHours; final mins = dur.inMinutes.remainder(60); return Padding( padding: const EdgeInsets.symmetric(vertical: 2), child: Row( children: [ Icon( Icons.check_circle_outline, size: 14, color: colors.primary, ), const SizedBox(width: 6), Expanded( child: Text( '${AppTime.formatTime(log.checkInAt)} – ${AppTime.formatTime(log.checkOutAt!)} (${hours}h ${mins}m)', style: theme.textTheme.bodySmall?.copyWith( color: colors.onSurfaceVariant, ), ), ), ], ), ); }), ], const SizedBox(height: 20), // Action button — check-in or check-out (centered, enlarged). if (canCheckIn) Center( child: SizedBox( width: 220, height: 56, child: FilledButton.icon( onPressed: _loading ? null : () => _handleCheckIn(schedule), icon: _loading ? const SizedBox( width: 20, height: 20, child: CircularProgressIndicator( strokeWidth: 2, ), ) : const Icon(Icons.login, size: 24), label: Text( 'Check In', style: theme.textTheme.titleMedium?.copyWith( color: colors.onPrimary, fontWeight: FontWeight.w600, ), ), ), ), ) else if (!showCheckOut && !isShiftOver && !_insideGeofence && !hasGeofenceOverride && !_checkingGeofence) Center( child: SizedBox( width: 220, height: 56, child: FilledButton.icon( onPressed: null, icon: const Icon(Icons.location_off, size: 24), label: Text( 'Check In', style: theme.textTheme.titleMedium, ), ), ), ) else if (showCheckOut) Center( child: SizedBox( width: 220, height: 56, child: FilledButton.tonalIcon( onPressed: _loading ? null : () { if (realActiveLog.isNotEmpty) { _handleCheckOut( realActiveLog.first, scheduleId: schedule.id, ); } else { final logId = _checkInLogIds[schedule.id]; if (logId != null) { _handleCheckOutById( logId, scheduleId: schedule.id, ); } } }, icon: _loading ? const SizedBox( width: 20, height: 20, child: CircularProgressIndicator( strokeWidth: 2, ), ) : const Icon(Icons.logout, size: 24), label: Text( 'Check Out', style: theme.textTheme.titleMedium, ), ), ), ) else if (statusLabel == 'Absent') Row( children: [ Icon( Icons.cancel_outlined, size: 16, color: colors.error, ), const SizedBox(width: 6), Expanded( child: Text( 'No check-in recorded for this shift.', style: theme.textTheme.bodySmall?.copyWith( color: colors.error, ), ), ), ], ), ], ), ), ); }), ], ), ); } Future _handleCheckIn(DutySchedule schedule) async { setState(() => _loading = true); try { // Ensure location permission before check-in final locGranted = await ensureLocationPermission(); if (!locGranted) { if (mounted) { showWarningSnackBar( context, 'Location permission is required for check-in.', ); } return; } final geoCfg = await ref.read(geofenceProvider.future); final position = await Geolocator.getCurrentPosition( locationSettings: const LocationSettings( accuracy: LocationAccuracy.high, ), ); // Client-side geofence check (can be bypassed in debug mode or by an approved IT request) final debugBypass = kDebugMode && ref.read(debugSettingsProvider).bypassGeofence; // Allow outside check-in if the user has an approved IT Service Request // with outsidePremiseAllowed = true. final profile = ref.read(currentProfileProvider).valueOrNull; final hasItOverride = () { if (profile == null) return false; final itRequests = ref.watch(itServiceRequestsProvider).valueOrNull ?? []; final itAssignments = ref.watch(itServiceRequestAssignmentsProvider).valueOrNull ?? []; final assignedRequestIds = itAssignments .where((a) => a.userId == profile.id) .map((a) => a.requestId) .toSet(); return itRequests.any((r) { return assignedRequestIds.contains(r.id) && r.outsidePremiseAllowed && (r.status == ItServiceRequestStatus.scheduled || r.status == ItServiceRequestStatus.inProgressDryRun || r.status == ItServiceRequestStatus.inProgress); }); }(); if (geoCfg != null && !debugBypass && !hasItOverride) { bool inside = false; if (geoCfg.hasPolygon) { inside = geoCfg.containsPolygon( position.latitude, position.longitude, ); } else if (geoCfg.hasCircle) { final dist = Geolocator.distanceBetween( position.latitude, position.longitude, geoCfg.lat!, geoCfg.lng!, ); inside = dist <= (geoCfg.radiusMeters ?? 0); } if (!inside && mounted) { showWarningSnackBar(context, 'You are outside the geofence area.'); return; } } else if ((debugBypass || hasItOverride) && mounted) { showInfoSnackBar( context, hasItOverride ? 'Allowed by approved IT Service Request.' : '⚠️ DEBUG: Geofence check bypassed', ); } final logId = await ref .read(attendanceControllerProvider) .checkIn( dutyScheduleId: schedule.id, lat: position.latitude, lng: position.longitude, ); // automatically enable tracking when user checks in try { await ref.read(whereaboutsControllerProvider).setTracking(true); } catch (_) {} _trackingLocal = true; // reflect new state immediately // Update live position immediately on check-in ref.read(whereaboutsControllerProvider).updatePositionNow(); if (mounted) { setState(() { _justCheckedIn.add(schedule.id); if (logId != null) _checkInLogIds[schedule.id] = logId; }); showSuccessSnackBar(context, 'Checked in! Running verification...'); if (logId != null) _performFaceVerification(logId); } } catch (e) { if (mounted) { showErrorSnackBar(context, 'Check-in failed: $e'); } } finally { if (mounted) setState(() => _loading = false); } } Future _handleCheckOut(AttendanceLog log, {String? scheduleId}) async { setState(() => _loading = true); try { // Ensure location permission before check-out final locGranted = await ensureLocationPermission(); if (!locGranted) { if (mounted) { showWarningSnackBar( context, 'Location permission is required for check-out.', ); } return; } final position = await Geolocator.getCurrentPosition( locationSettings: const LocationSettings( accuracy: LocationAccuracy.high, ), ); // Check if outside geofence — require justification if so String? checkOutJustification; final geoCfg = await ref.read(geofenceProvider.future); final debugBypass = kDebugMode && ref.read(debugSettingsProvider).bypassGeofence; if (geoCfg != null && !debugBypass) { bool inside = false; if (geoCfg.hasPolygon) { inside = geoCfg.containsPolygon( position.latitude, position.longitude, ); } else if (geoCfg.hasCircle) { final dist = Geolocator.distanceBetween( position.latitude, position.longitude, geoCfg.lat!, geoCfg.lng!, ); inside = dist <= (geoCfg.radiusMeters ?? 0); } if (!inside && mounted) { checkOutJustification = await _showCheckOutJustificationDialog( context, ); if (checkOutJustification == null) { // User cancelled return; } } } await ref .read(attendanceControllerProvider) .checkOut( attendanceId: log.id, lat: position.latitude, lng: position.longitude, justification: checkOutJustification, ); // automatically disable tracking when user checks out try { await ref.read(whereaboutsControllerProvider).setTracking(false); } catch (_) {} _trackingLocal = false; // Update live position immediately on check-out ref.read(whereaboutsControllerProvider).updatePositionNow(); if (mounted) { setState(() { if (scheduleId != null) { _justCheckedIn.remove(scheduleId); _checkInLogIds.remove(scheduleId); } _overtimeLogId = null; }); showSuccessSnackBar(context, 'Checked out! Running verification...'); _performFaceVerification(log.id, isCheckOut: true); } } catch (e) { if (mounted) { showErrorSnackBar(context, 'Check-out failed: $e'); } } finally { if (mounted) setState(() => _loading = false); } } Future _handleCheckOutById(String logId, {String? scheduleId}) async { setState(() => _loading = true); try { // Ensure location permission before check-out final locGranted = await ensureLocationPermission(); if (!locGranted) { if (mounted) { showWarningSnackBar( context, 'Location permission is required for check-out.', ); } return; } final position = await Geolocator.getCurrentPosition( locationSettings: const LocationSettings( accuracy: LocationAccuracy.high, ), ); // Check if outside geofence — require justification if so String? checkOutJustification; final geoCfg = await ref.read(geofenceProvider.future); final debugBypass = kDebugMode && ref.read(debugSettingsProvider).bypassGeofence; if (geoCfg != null && !debugBypass) { bool inside = false; if (geoCfg.hasPolygon) { inside = geoCfg.containsPolygon( position.latitude, position.longitude, ); } else if (geoCfg.hasCircle) { final dist = Geolocator.distanceBetween( position.latitude, position.longitude, geoCfg.lat!, geoCfg.lng!, ); inside = dist <= (geoCfg.radiusMeters ?? 0); } if (!inside && mounted) { checkOutJustification = await _showCheckOutJustificationDialog( context, ); if (checkOutJustification == null) { // User cancelled return; } } } await ref .read(attendanceControllerProvider) .checkOut( attendanceId: logId, lat: position.latitude, lng: position.longitude, justification: checkOutJustification, ); // Update live position immediately on check-out ref.read(whereaboutsControllerProvider).updatePositionNow(); if (mounted) { setState(() { if (scheduleId != null) { _justCheckedIn.remove(scheduleId); _checkInLogIds.remove(scheduleId); } _overtimeLogId = null; }); showSuccessSnackBar(context, 'Checked out! Running verification...'); _performFaceVerification(logId, isCheckOut: true); } } catch (e) { if (mounted) { showErrorSnackBar(context, 'Check-out failed: $e'); } } finally { if (mounted) setState(() => _loading = false); } } /// Shows a dialog asking for justification when checking out outside geofence. /// Returns the justification text, or null if the user cancelled. Future _showCheckOutJustificationDialog(BuildContext context) async { final controller = TextEditingController(); final result = await m3ShowDialog( context: context, builder: (ctx) { final colors = Theme.of(ctx).colorScheme; final textTheme = Theme.of(ctx).textTheme; return AlertDialog( icon: Icon(Icons.location_off, color: colors.error), title: const Text('Outside Geofence'), content: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( 'You are checking out outside the designated area. ' 'Please provide a justification.', style: textTheme.bodyMedium, ), const SizedBox(height: 16), TextField( controller: controller, maxLines: 3, decoration: const InputDecoration( labelText: 'Justification', hintText: 'Explain why you are checking out outside the geofence...', border: OutlineInputBorder(), ), ), ], ), actions: [ TextButton( onPressed: () => Navigator.of(ctx).pop(null), child: const Text('Cancel'), ), FilledButton( onPressed: () { final text = controller.text.trim(); if (text.isEmpty) return; Navigator.of(ctx).pop(text); }, child: const Text('Submit & Check Out'), ), ], ); }, ); controller.dispose(); return result; } Future _handleOvertimeCheckIn() async { final justification = _justificationController.text.trim(); if (justification.isEmpty) { showWarningSnackBar( context, 'Please provide a justification for overtime.', ); return; } setState(() => _loading = true); try { final geoCfg = await ref.read(geofenceProvider.future); final position = await Geolocator.getCurrentPosition( locationSettings: const LocationSettings( accuracy: LocationAccuracy.high, ), ); final debugBypass = kDebugMode && ref.read(debugSettingsProvider).bypassGeofence; if (geoCfg != null && !debugBypass) { bool inside = false; if (geoCfg.hasPolygon) { inside = geoCfg.containsPolygon( position.latitude, position.longitude, ); } else if (geoCfg.hasCircle) { final dist = Geolocator.distanceBetween( position.latitude, position.longitude, geoCfg.lat!, geoCfg.lng!, ); inside = dist <= (geoCfg.radiusMeters ?? 0); } if (!inside && mounted) { showWarningSnackBar(context, 'You are outside the geofence area.'); return; } } else if (debugBypass && mounted) { showInfoSnackBar(context, '⚠️ DEBUG: Geofence check bypassed'); } final logId = await ref .read(attendanceControllerProvider) .overtimeCheckIn( lat: position.latitude, lng: position.longitude, justification: justification, ); if (mounted) { setState(() { _overtimeLogId = logId; _justificationController.clear(); }); showSuccessSnackBar( context, 'Overtime check-in! Running verification...', ); if (logId != null) _performFaceVerification(logId); } } catch (e) { if (mounted) { showErrorSnackBar(context, 'Overtime check-in failed: $e'); } } finally { if (mounted) setState(() => _loading = false); } } /// Face verification after check-in/out: liveness detection on mobile, /// camera/gallery on web. Uploads selfie and updates attendance log. Future _performFaceVerification( String attendanceLogId, { bool isCheckOut = false, }) async { final profile = ref.read(currentProfileProvider).valueOrNull; if (profile == null || !profile.hasFaceEnrolled) { try { await ref .read(attendanceControllerProvider) .skipVerification(attendanceLogId); } catch (_) {} if (mounted) { showInfoSnackBar( context, 'Face not enrolled — verification skipped. Enroll in Profile.', ); } return; } try { final result = await showFaceVerificationOverlay( context: context, ref: ref, attendanceLogId: attendanceLogId, isCheckOut: isCheckOut, ); if (!mounted) return; if (result == null || !result.verified) { final score = result?.matchScore; if (score != null) { showWarningSnackBar( context, 'Face did not match (${(score * 100).toStringAsFixed(0)}%). Flagged for review.', ); } else { showWarningSnackBar( context, 'Verification skipped — flagged as unverified.', ); } } else { final score = result.matchScore ?? 0; showSuccessSnackBar( context, 'Face verified (${(score * 100).toStringAsFixed(0)}% match).', ); } } catch (e) { try { await ref .read(attendanceControllerProvider) .skipVerification(attendanceLogId); } catch (_) {} if (mounted) { showWarningSnackBar(context, 'Verification failed — flagged.'); } } } /// Card shown when user has no active schedule (or their schedule has ended). /// Offers overtime check-in. Widget _buildOvertimeCard( BuildContext context, ThemeData theme, ColorScheme colors, { required bool hasScheduleToday, }) { final headerText = hasScheduleToday ? 'Your scheduled shift has ended.' : 'No schedule assigned for today.'; return Card( child: Padding( padding: const EdgeInsets.all(16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Icon(Icons.info_outline, size: 20, color: colors.primary), const SizedBox(width: 8), Expanded( child: Text( headerText, style: theme.textTheme.bodyMedium?.copyWith( color: colors.onSurfaceVariant, ), ), ), ], ), const SizedBox(height: 16), Text( 'Overtime Check-in', style: theme.textTheme.titleSmall?.copyWith( fontWeight: FontWeight.w600, ), ), const SizedBox(height: 4), Text( 'You can check in as overtime. A justification is required.', style: theme.textTheme.bodySmall?.copyWith( color: colors.onSurfaceVariant, ), ), const SizedBox(height: 12), Row( children: [ Expanded( child: GeminiAnimatedTextField( controller: _justificationController, labelText: 'Justification for overtime', maxLines: 4, enabled: !_loading && _insideGeofence && !_checkingGeofence, isProcessing: _isGeminiProcessing, ), ), Padding( padding: const EdgeInsets.only(left: 8.0), child: GeminiButton( textController: _justificationController, onTextUpdated: (text) { setState(() { _justificationController.text = text; }); }, onProcessingStateChanged: (processing) { setState(() => _isGeminiProcessing = processing); }, tooltip: 'Translate/Enhance with AI', promptBuilder: (_) => 'Translate this sentence to clear professional English ' 'if needed, and enhance grammar/clarity while preserving ' 'the original meaning. Return ONLY the improved text, ' 'with no explanations, no recommendations, and no extra context.', ), ), ], ), const SizedBox(height: 12), Center( child: SizedBox( width: 220, height: 56, child: FilledButton.icon( onPressed: (_loading || _isGeminiProcessing || !_insideGeofence || _checkingGeofence) ? null : _handleOvertimeCheckIn, icon: _loading ? const SizedBox( width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2), ) : const Icon(Icons.more_time, size: 24), label: Text( 'Overtime Check In', style: theme.textTheme.titleMedium?.copyWith( fontWeight: FontWeight.w600, ), ), ), ), ), ], ), ), ); } /// Card shown when user is actively in an overtime session. Widget _buildActiveOvertimeCard( BuildContext context, ThemeData theme, ColorScheme colors, List activeLog, ) { return Card( child: Padding( padding: const EdgeInsets.all(16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Icon(Icons.more_time, size: 20, color: colors.tertiary), const SizedBox(width: 8), Expanded( child: Text( 'Overtime', style: theme.textTheme.titleSmall?.copyWith( fontWeight: FontWeight.w600, ), ), ), _statusChip(context, 'On Duty'), ], ), const SizedBox(height: 8), if (activeLog.isNotEmpty) Text( 'Checked in at ${AppTime.formatTime(activeLog.first.checkInAt)}', style: theme.textTheme.bodyMedium, ), const SizedBox(height: 12), SizedBox( width: double.infinity, child: FilledButton.tonalIcon( onPressed: _loading ? null : () { if (activeLog.isNotEmpty) { _handleCheckOut(activeLog.first); } else if (_overtimeLogId != null) { _handleCheckOutById(_overtimeLogId!); } }, icon: _loading ? const SizedBox( width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2), ) : const Icon(Icons.logout), label: const Text('Check Out'), ), ), ], ), ), ); } Widget _statusChip(BuildContext context, String label) { final colors = Theme.of(context).colorScheme; Color bg; Color fg; switch (label) { case 'Completed': bg = colors.primaryContainer; fg = colors.onPrimaryContainer; case 'On Duty': bg = colors.tertiaryContainer; fg = colors.onTertiaryContainer; case 'Checked In': bg = colors.secondaryContainer; fg = colors.onSecondaryContainer; case 'Early Out': bg = Colors.orange.shade100; fg = Colors.orange.shade900; case 'Noon Break': bg = Colors.blue.shade100; fg = Colors.blue.shade900; case 'Absent': bg = colors.errorContainer; fg = colors.onErrorContainer; default: bg = colors.surfaceContainerHighest; fg = colors.onSurface; } return Container( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), decoration: BoxDecoration( color: bg, borderRadius: BorderRadius.circular(12), ), child: Text( label, style: Theme.of(context).textTheme.labelSmall?.copyWith(color: fg), ), ); } String _shiftLabel(String shiftType) { switch (shiftType) { case 'normal': return 'Normal Shift'; case 'night': return 'Night Shift'; case 'overtime': return 'Overtime'; case 'on_call': return 'On Call'; default: return shiftType; } } } // ──────────────────────────────────────────────── // Unified logbook entry (real log or absent schedule) // ──────────────────────────────────────────────── class _LogbookEntry { _LogbookEntry({ required this.name, required this.userId, required this.dutyScheduleId, required this.shift, required this.date, required this.checkInAt, required this.checkOutAt, required this.checkIn, required this.checkOut, required this.duration, required this.status, required this.isAbsent, this.isLeave = false, this.leaveType, this.verificationStatus, this.logId, this.logUserId, this.enrolledFaceUrl, this.checkInVerificationFaceUrl, this.checkOutVerificationFaceUrl, this.justification, this.checkOutJustification, this.checkInLat, this.checkInLng, this.checkOutLat, this.checkOutLng, }); final String name; final String userId; final String? dutyScheduleId; final String shift; final DateTime date; final DateTime checkInAt; final DateTime? checkOutAt; final String checkIn; final String checkOut; final String duration; final String status; final bool isAbsent; final bool isLeave; final String? leaveType; final String? verificationStatus; final String? logId; final String? logUserId; final String? enrolledFaceUrl; final String? checkInVerificationFaceUrl; final String? checkOutVerificationFaceUrl; final String? justification; final String? checkOutJustification; final double? checkInLat; final double? checkInLng; final double? checkOutLat; final double? checkOutLng; /// Whether this entry can be re-verified (within 10 min of check-in). bool canReverify(String currentUserId) { if (logId == null || logUserId != currentUserId) return false; if (verificationStatus != 'unverified' && verificationStatus != 'skipped') { return false; } final elapsed = AppTime.now().difference(date); return elapsed.inMinutes <= 10; } // NOTE: logbook entry creation is handled in an async provider (so that // filtering/sorting does not block the UI). Use `_computeLogbookEntries`. factory _LogbookEntry.absent(DutySchedule s, Map byId) { final p = byId[s.userId]; final name = p is Profile ? p.fullName : (p as String?) ?? s.userId; return _LogbookEntry( name: name, userId: s.userId, dutyScheduleId: s.id, shift: _shiftLabelFromType(s.shiftType), date: s.startTime, checkInAt: s.startTime, checkOutAt: null, checkIn: '—', checkOut: '—', duration: '—', status: 'Absent', isAbsent: true, ); } static String _fmtDur(Duration d) { final h = d.inHours; final m = d.inMinutes.remainder(60); return '${h}h ${m}m'; } static String _shiftLabelFromType(String shiftType) { switch (shiftType) { case 'normal': return 'Normal Shift'; case 'night': return 'Night Shift'; case 'overtime': return 'Overtime'; case 'on_call': return 'On Call'; default: return shiftType; } } } /// A shift grouping representing a set of attendance sessions for a single /// scheduled shift (or leave/absence) in a single day. class _ShiftGroup { _ShiftGroup({ required this.groupKey, required this.userId, required this.dutyScheduleId, required this.name, required this.shiftLabel, required this.sessions, }); final String groupKey; final String userId; final String? dutyScheduleId; final String name; final String shiftLabel; final List<_LogbookEntry> sessions; DateTime get date => sessions.first.date; String get checkIn => sessions.isEmpty ? '—' : AppTime.formatTime(sessions.first.checkInAt); String get checkOut { final checkedOut = sessions.where((s) => s.checkOutAt != null).toList(); if (checkedOut.isEmpty) return '—'; final last = checkedOut.reduce( (a, b) => a.checkOutAt!.isAfter(b.checkOutAt!) ? a : b, ); return last.checkOut; } String get duration { if (sessions.isEmpty) return '—'; final first = sessions.first.checkInAt; final checkedOut = sessions.where((s) => s.checkOutAt != null).toList(); if (checkedOut.isEmpty) return 'On duty'; final last = checkedOut.reduce( (a, b) => a.checkOutAt!.isAfter(b.checkOutAt!) ? a : b, ); return _LogbookEntry._fmtDur(last.checkOutAt!.difference(first)); } String get status { if (sessions.any((s) => s.isLeave)) return 'On Leave'; if (sessions.any((s) => s.isAbsent)) return 'Absent'; if (sessions.any((s) => s.checkOutAt == null)) return 'On duty'; return 'Completed'; } /// Whether any session in this group is still in progress. bool get hasOngoingSession => sessions.any((s) => !s.isAbsent && !s.isLeave && s.checkOutAt == null); } // ──────────────────────────────────────────────── // Tab 2 – Logbook // ──────────────────────────────────────────────── class _LogbookTab extends ConsumerStatefulWidget { const _LogbookTab(); @override ConsumerState<_LogbookTab> createState() => _LogbookTabState(); } class _LogbookTabState extends ConsumerState<_LogbookTab> { AsyncValue> _entriesAsync = const AsyncValue.loading(); int? _lastSignature; @override Widget build(BuildContext context) { final theme = Theme.of(context); final colors = theme.colorScheme; final range = ref.watch(attendanceDateRangeProvider); final logsAsync = ref.watch(attendanceLogsProvider); final profilesAsync = ref.watch(profilesProvider); final schedulesAsync = ref.watch(dutySchedulesProvider); final leavesAsync = ref.watch(leavesProvider); final selectedUserIds = ref.watch(attendanceUserFilterProvider); // Only show users with admin-like roles in the selector. const allowedRoles = {'admin', 'dispatcher', 'programmer', 'it_staff'}; final allowedProfiles = (profilesAsync.valueOrNull ?? []) .where((p) => allowedRoles.contains(p.role)) .toList() ..sort((a, b) => a.fullName.compareTo(b.fullName)); return Column( children: [ // Date filter card Padding( padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), child: Card( child: Padding( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), child: Row( children: [ Icon(Icons.calendar_today, size: 18, color: colors.primary), const SizedBox(width: 8), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ Text(range.label, style: theme.textTheme.labelLarge), Text( AppTime.formatDateRange(range.dateTimeRange), style: theme.textTheme.bodySmall?.copyWith( color: colors.onSurfaceVariant, ), ), ], ), ), FilledButton.tonalIcon( onPressed: () => _showDateFilterDialog(context, ref), icon: const Icon(Icons.tune, size: 18), label: const Text('Change'), ), ], ), ), ), ), // User filter card (multi-select) if (allowedProfiles.isNotEmpty) ...[ const SizedBox(height: 8), Padding( padding: const EdgeInsets.symmetric(horizontal: 16), child: Card( child: Padding( padding: const EdgeInsets.symmetric( horizontal: 12, vertical: 8, ), child: MultiSelectPicker( label: 'Personnel', items: allowedProfiles, selectedIds: selectedUserIds, getId: (p) => p.id, getLabel: (p) => p.fullName, onChanged: (selected) { ref.read(attendanceUserFilterProvider.notifier).state = selected; }, ), ), ), ), ], const SizedBox(height: 8), Expanded( child: logsAsync.when( data: (logs) { _recomputeEntriesIfNeeded( logs: logs, range: range, schedules: schedulesAsync.valueOrNull ?? [], leaves: leavesAsync.valueOrNull ?? [], profileList: profilesAsync.valueOrNull ?? [], selectedUserIds: selectedUserIds, ); return _entriesAsync.when( data: (entries) { if (entries.isEmpty) { return Center( child: Text( 'No attendance logs for this period.', style: theme.textTheme.bodyMedium?.copyWith( color: colors.onSurfaceVariant, ), ), ); } final currentUserId = ref.read(currentUserIdProvider) ?? ''; return _buildShiftGroupList( context, entries, currentUserId: currentUserId, onReverify: (logId) => _reverify(context, ref, logId), ); }, loading: () => const Center(child: CircularProgressIndicator()), error: (e, _) => Center(child: Text('Failed to load logs: $e')), ); }, loading: () => const Center(child: CircularProgressIndicator()), error: (e, _) => Center(child: Text('Failed to load logs: $e')), ), ), ], ); } void _recomputeEntriesIfNeeded({ required List logs, required ReportDateRange range, required List schedules, required List leaves, required List profileList, required List selectedUserIds, }) { final signature = Object.hashAll([ logs.length, range.start.millisecondsSinceEpoch, range.end.millisecondsSinceEpoch, schedules.length, leaves.length, profileList.length, selectedUserIds.length, selectedUserIds.hashCode, ]); if (signature == _lastSignature) return; _lastSignature = signature; setState(() { _entriesAsync = const AsyncValue.loading(); }); Future(() { final profileById = {for (final p in profileList) p.id: p}; return _computeEntries( logs: logs, range: range, schedules: schedules, leaves: leaves, profileById: profileById, selectedUserIds: selectedUserIds, ); }) .then((entries) { if (!mounted) return; setState(() { _entriesAsync = AsyncValue.data(entries); }); }) .catchError((e, st) { if (!mounted) return; setState(() { _entriesAsync = AsyncValue.error(e, st); }); }); } List<_LogbookEntry> _computeEntries({ required List logs, required ReportDateRange range, required List schedules, required List leaves, required Map profileById, required List selectedUserIds, }) { final filtered = logs.where((log) { return !log.checkInAt.isBefore(range.start) && log.checkInAt.isBefore(range.end); }).toList(); final logScheduleIds = filtered.map((l) => l.dutyScheduleId).toSet(); final overtimeDaysByUser = >{}; for (final log in filtered) { if (log.shiftType == 'overtime') { final d = log.checkInAt; final key = '${d.year}-${d.month}-${d.day}'; overtimeDaysByUser.putIfAbsent(log.userId, () => {}).add(key); } } final now = AppTime.now(); final absentSchedules = schedules.where((s) { if (s.shiftType == 'overtime' || s.shiftType == 'on_call') return false; if (logScheduleIds.contains(s.id)) return false; if (!s.endTime.isBefore(now)) return false; if (s.startTime.isBefore(range.start) || !s.startTime.isBefore(range.end)) { return false; } final d = s.startTime; final dayKey = '${d.year}-${d.month}-${d.day}'; if (overtimeDaysByUser[s.userId]?.contains(dayKey) ?? false) { return false; } return true; }).toList(); final leaveEntries = leaves .where((l) { return l.status == 'approved' && !l.startTime.isBefore(range.start) && l.startTime.isBefore(range.end); }) .map((l) { final profile = profileById[l.userId]; return _LogbookEntry( name: profile?.fullName ?? l.userId, userId: l.userId, dutyScheduleId: null, shift: '—', date: l.startTime, checkInAt: l.startTime, checkOutAt: l.endTime, checkIn: AppTime.formatTime(l.startTime), checkOut: AppTime.formatTime(l.endTime), duration: '—', status: 'On Leave', isAbsent: false, isLeave: true, leaveType: l.leaveType, enrolledFaceUrl: profile?.facePhotoUrl, ); }); final scheduleById = {for (final s in schedules) s.id: s}; final List<_LogbookEntry> entries = [ for (final l in filtered) _LogbookEntry( name: profileById[l.userId]?.fullName ?? l.userId, userId: l.userId, dutyScheduleId: l.dutyScheduleId, shift: _LogbookEntry._shiftLabelFromType( scheduleById[l.dutyScheduleId]?.shiftType ?? l.shiftType, ), date: l.checkInAt, checkInAt: l.checkInAt, checkOutAt: l.checkOutAt, checkIn: AppTime.formatTime(l.checkInAt), checkOut: l.isCheckedOut ? AppTime.formatTime(l.checkOutAt!) : '—', duration: l.isCheckedOut ? _LogbookEntry._fmtDur(l.checkOutAt!.difference(l.checkInAt)) : 'On duty', status: l.isCheckedOut ? 'Completed' : 'On duty', isAbsent: false, verificationStatus: l.verificationStatus, logId: l.id, logUserId: l.userId, enrolledFaceUrl: profileById[l.userId]?.facePhotoUrl, checkInVerificationFaceUrl: l.checkInVerificationPhotoUrl, checkOutVerificationFaceUrl: l.checkOutVerificationPhotoUrl, justification: l.justification, checkOutJustification: l.checkOutJustification, checkInLat: l.checkInLat, checkInLng: l.checkInLng, checkOutLat: l.checkOutLat, checkOutLng: l.checkOutLng, ), ...absentSchedules.map((s) => _LogbookEntry.absent(s, profileById)), ...leaveEntries, ]; final filteredEntries = selectedUserIds.isEmpty ? entries : entries.where((e) => selectedUserIds.contains(e.userId)).toList(); filteredEntries.sort((a, b) => b.date.compareTo(a.date)); return filteredEntries; } void _reverify(BuildContext context, WidgetRef ref, String logId) async { final profile = ref.read(currentProfileProvider).valueOrNull; if (profile == null || !profile.hasFaceEnrolled) { showInfoSnackBar( context, 'Face not enrolled \u2014 enroll in Profile first.', ); return; } final result = await showFaceVerificationOverlay( context: context, ref: ref, attendanceLogId: logId, ); if (!context.mounted) return; if (result != null && result.verified) { showSuccessSnackBar( context, 'Re-verification successful (${((result.matchScore ?? 0) * 100).toStringAsFixed(0)}% match).', ); } else if (result != null) { showWarningSnackBar( context, 'Re-verification failed. Still flagged as unverified.', ); } } void _showDateFilterDialog(BuildContext context, WidgetRef ref) { m3ShowDialog( context: context, builder: (ctx) => _AttendanceDateFilterDialog( current: ref.read(attendanceDateRangeProvider), onApply: (newRange) { ref.read(attendanceDateRangeProvider.notifier).state = newRange; }, ), ); } Widget _buildShiftGroupList( BuildContext context, List<_LogbookEntry> entries, { required String currentUserId, required void Function(String logId) onReverify, }) { final groupedByDate = _groupByDate(entries); return ListView( padding: const EdgeInsets.symmetric(horizontal: 16), children: groupedByDate.entries.map((group) { final shiftGroups = _groupByShift(group.value); return _DateGroupTile( dateLabel: group.key, shiftGroups: shiftGroups, currentUserId: currentUserId, onReverify: onReverify, ); }).toList(), ); } /// Group list of entries by shift (user + schedule + date). static List<_ShiftGroup> _groupByShift(List<_LogbookEntry> entries) { final groups = {}; for (final entry in entries) { final dateKey = AppTime.formatDate(entry.date); final key = entry.isLeave ? 'leave|${entry.userId}|$dateKey|${entry.leaveType ?? ''}' : 'shift|${entry.userId}|${entry.dutyScheduleId ?? ''}|$dateKey'; final group = groups.putIfAbsent( key, () => _ShiftGroup( groupKey: key, userId: entry.userId, dutyScheduleId: entry.dutyScheduleId, name: entry.name, shiftLabel: entry.shift, sessions: [], ), ); group.sessions.add(entry); } for (final g in groups.values) { g.sessions.sort((a, b) => a.date.compareTo(b.date)); } final list = groups.values.toList(); list.sort((a, b) => b.date.compareTo(a.date)); return list; } /// Group sorted entries by formatted date string (preserving order). static Map> _groupByDate( List<_LogbookEntry> entries, ) { final map = >{}; for (final e in entries) { final key = AppTime.formatDate(e.date); map.putIfAbsent(key, () => []).add(e); } return map; } } // ──────────────────────────────────────────────── // Collapsible date-group tile for Logbook // ──────────────────────────────────────────────── class _DateGroupTile extends StatelessWidget { const _DateGroupTile({ required this.dateLabel, required this.shiftGroups, required this.currentUserId, required this.onReverify, }); final String dateLabel; final List<_ShiftGroup> shiftGroups; final String currentUserId; final void Function(String logId) onReverify; @override Widget build(BuildContext context) { final colors = Theme.of(context).colorScheme; final textTheme = Theme.of(context).textTheme; return Card( margin: const EdgeInsets.symmetric(vertical: 6), child: ExpansionTile( initiallyExpanded: true, tilePadding: const EdgeInsets.symmetric(horizontal: 16), title: Text( dateLabel, style: textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w600), ), subtitle: Text( '${shiftGroups.length} ${shiftGroups.length == 1 ? 'shift' : 'shifts'}', style: textTheme.bodySmall?.copyWith(color: colors.onSurfaceVariant), ), children: shiftGroups.map((group) { return _buildShiftGroupTile(context, group); }).toList(), ), ); } Widget _buildShiftGroupTile(BuildContext context, _ShiftGroup group) { // If the group is an absence or leave, render as a single row. final first = group.sessions.first; if (first.isAbsent || first.isLeave) { return _buildSessionTile(context, first); } final colors = Theme.of(context).colorScheme; final textTheme = Theme.of(context).textTheme; final statusColor = group.status == 'Absent' ? Colors.red : group.status == 'On duty' ? Colors.orange : group.status == 'On Leave' ? Colors.teal : Colors.green; return Card( margin: const EdgeInsets.fromLTRB(12, 8, 12, 0), child: ExpansionTile( tilePadding: const EdgeInsets.symmetric(horizontal: 16), childrenPadding: const EdgeInsets.symmetric(horizontal: 16), title: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( '${group.name} · ${group.shiftLabel}', style: textTheme.titleSmall, ), const SizedBox(height: 4), Text( 'In: ${group.checkIn} · Out: ${group.checkOut} · ${group.duration}', style: textTheme.bodySmall?.copyWith( color: colors.onSurfaceVariant, ), ), ], ), subtitle: Row( children: [ Chip( label: Text(group.status), backgroundColor: statusColor.withValues(alpha: 0.15), ), ], ), children: group.sessions.map((session) { return _buildSessionTile(context, session); }).toList(), ), ); } Widget _buildSessionTile(BuildContext context, _LogbookEntry entry) { final colors = Theme.of(context).colorScheme; final isReverifyable = entry.canReverify(currentUserId); final isLeaveOrAbsent = entry.isLeave || entry.isAbsent; return ListTile( title: Row( children: [ Expanded(child: Text(entry.name)), if (!isLeaveOrAbsent) _verificationBadge(context, entry), if (isReverifyable) IconButton( icon: Icon( Icons.refresh, size: 16, color: Theme.of(context).colorScheme.primary, ), tooltip: 'Re-verify', onPressed: () => onReverify(entry.logId!), padding: EdgeInsets.zero, constraints: const BoxConstraints(minWidth: 28, minHeight: 28), visualDensity: VisualDensity.compact, ), ], ), subtitle: Text( entry.isLeave ? 'On Leave${entry.leaveType != null ? ' — ${_leaveLabel(entry.leaveType!)}' : ''}' : entry.isAbsent ? 'Absent — no check-in recorded' : 'In: ${entry.checkIn} · Out: ${entry.checkOut} · ${entry.duration}${entry.status == 'On duty' ? ' · On duty' : ''}', ), trailing: isLeaveOrAbsent ? Chip( label: Text(entry.isLeave ? 'On Leave' : 'Absent'), backgroundColor: entry.isLeave ? Colors.teal.withValues(alpha: 0.15) : colors.errorContainer, ) : entry.status == 'On duty' ? Chip( label: const Text('On duty'), backgroundColor: colors.tertiaryContainer, ) : Text(entry.duration, style: Theme.of(context).textTheme.bodySmall), ); } /// Verification badge for logbook entries. Widget _verificationBadge(BuildContext context, _LogbookEntry entry) { if (entry.isAbsent || entry.isLeave || entry.verificationStatus == null) { return const SizedBox.shrink(); } final colors = Theme.of(context).colorScheme; final textTheme = Theme.of(context).textTheme; IconData icon; Color color; String tooltip; switch (entry.verificationStatus) { case 'verified': icon = Icons.verified; color = Colors.green; tooltip = 'Verified'; case 'unverified' || 'skipped': icon = Icons.warning_amber_rounded; color = colors.error; tooltip = 'Unverified'; default: icon = Icons.hourglass_bottom; color = Colors.orange; tooltip = 'Pending'; } final badge = Tooltip( message: tooltip, child: Container( padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), decoration: BoxDecoration( color: color.withValues(alpha: 0.12), borderRadius: BorderRadius.circular(8), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ Icon(icon, size: 14, color: color), const SizedBox(width: 4), Text(tooltip, style: textTheme.labelSmall?.copyWith(color: color)), ], ), ), ); final canOpenDetails = entry.logId != null && (entry.verificationStatus == 'verified' || entry.verificationStatus == 'unverified' || entry.verificationStatus == 'skipped'); if (!canOpenDetails) return badge; return InkWell( onTap: () => _showVerificationDetails(context, entry), borderRadius: BorderRadius.circular(8), child: badge, ); } void _showVerificationDetails(BuildContext context, _LogbookEntry entry) { final isMobile = MediaQuery.sizeOf(context).width < 700; if (isMobile) { showModalBottomSheet( context: context, isScrollControlled: true, showDragHandle: true, useSafeArea: true, builder: (ctx) => DraggableScrollableSheet( initialChildSize: 0.9, minChildSize: 0.5, maxChildSize: 0.95, expand: false, builder: (_, scrollController) => SingleChildScrollView( controller: scrollController, padding: const EdgeInsets.fromLTRB(16, 4, 16, 24), child: _VerificationDetailsContent( entry: entry, fixedTabHeight: 440, ), ), ), ); return; } m3ShowDialog( context: context, builder: (ctx) => Dialog( child: SizedBox( width: 980, height: 680, child: Padding( padding: const EdgeInsets.all(16), child: _VerificationDetailsContent(entry: entry), ), ), ), ); } static String _leaveLabel(String leaveType) { switch (leaveType) { case 'emergency_leave': return 'Emergency'; case 'parental_leave': return 'Parental'; case 'sick_leave': return 'Sick'; case 'vacation_leave': return 'Vacation'; default: return leaveType; } } } class _VerificationDetailsContent extends StatelessWidget { const _VerificationDetailsContent({required this.entry, this.fixedTabHeight}); final _LogbookEntry entry; final double? fixedTabHeight; @override Widget build(BuildContext context) { final textTheme = Theme.of(context).textTheme; final colors = Theme.of(context).colorScheme; final hasJustification = (entry.justification ?? '').trim().isNotEmpty || (entry.checkOutJustification ?? '').trim().isNotEmpty; return DefaultTabController( length: 2, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( '${entry.name} · ${AppTime.formatDate(entry.date)}', style: textTheme.titleMedium, ), const SizedBox(height: 6), Text( 'Shift: ${entry.shift}', style: textTheme.bodySmall?.copyWith( color: colors.onSurfaceVariant, ), ), // Justification section if (hasJustification) ...[ const SizedBox(height: 12), _JustificationSection(entry: entry), ], const SizedBox(height: 12), const TabBar( tabs: [ Tab(text: 'Face Verification'), Tab(text: 'Check In / Out Map'), ], ), const SizedBox(height: 12), if (fixedTabHeight != null) SizedBox( height: fixedTabHeight, child: TabBarView( children: [ _FaceVerificationTab(entry: entry), _CheckInOutMapTab(entry: entry), ], ), ) else Expanded( child: TabBarView( children: [ _FaceVerificationTab(entry: entry), _CheckInOutMapTab(entry: entry), ], ), ), ], ), ); } } /// Displays justification notes for overtime check-in and/or off-site checkout. class _JustificationSection extends StatelessWidget { const _JustificationSection({required this.entry}); final _LogbookEntry entry; @override Widget build(BuildContext context) { final colors = Theme.of(context).colorScheme; final textTheme = Theme.of(context).textTheme; final overtimeJustification = (entry.justification ?? '').trim(); final checkOutJustification = (entry.checkOutJustification ?? '').trim(); return Container( width: double.infinity, padding: const EdgeInsets.all(12), decoration: BoxDecoration( color: colors.tertiaryContainer.withValues(alpha: 0.3), borderRadius: BorderRadius.circular(12), border: Border.all(color: colors.outlineVariant.withValues(alpha: 0.5)), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Icon(Icons.notes_rounded, size: 16, color: colors.tertiary), const SizedBox(width: 6), Text( 'Justification', style: textTheme.labelMedium?.copyWith( color: colors.tertiary, fontWeight: FontWeight.w600, ), ), ], ), if (overtimeJustification.isNotEmpty) ...[ const SizedBox(height: 8), Text( 'Overtime:', style: textTheme.labelSmall?.copyWith( color: colors.onSurfaceVariant, ), ), const SizedBox(height: 2), Text(overtimeJustification, style: textTheme.bodySmall), ], if (checkOutJustification.isNotEmpty) ...[ const SizedBox(height: 8), Text( 'Check-out (outside geofence):', style: textTheme.labelSmall?.copyWith( color: colors.onSurfaceVariant, ), ), const SizedBox(height: 2), Text(checkOutJustification, style: textTheme.bodySmall), ], ], ), ); } } class _FaceVerificationTab extends StatefulWidget { const _FaceVerificationTab({required this.entry}); final _LogbookEntry entry; @override State<_FaceVerificationTab> createState() => _FaceVerificationTabState(); } class _FaceVerificationTabState extends State<_FaceVerificationTab> { late final PageController _pageController; int _currentPage = 0; static const _labels = ['Check-In Verification', 'Check-Out Verification']; @override void initState() { super.initState(); _pageController = PageController(); } @override void dispose() { _pageController.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final colors = Theme.of(context).colorScheme; final textTheme = Theme.of(context).textTheme; final entry = widget.entry; return Column( children: [ Expanded( child: PageView( controller: _pageController, onPageChanged: (i) => setState(() => _currentPage = i), children: [ // Page 1: Enrolled Face + Check-In Verification _SideBySidePanel( enrolledFaceUrl: entry.enrolledFaceUrl, verificationUrl: entry.checkInVerificationFaceUrl, verificationLabel: 'Check-In Verification', verificationIcon: Icons.login_rounded, emptyMessage: 'No check-in verification photo.', ), // Page 2: Enrolled Face + Check-Out Verification _SideBySidePanel( enrolledFaceUrl: entry.enrolledFaceUrl, verificationUrl: entry.checkOutVerificationFaceUrl, verificationLabel: 'Check-Out Verification', verificationIcon: Icons.logout_rounded, emptyMessage: 'No check-out verification photo.', ), ], ), ), const SizedBox(height: 12), // Label AnimatedSwitcher( duration: M3Motion.micro, child: Text( _labels[_currentPage], key: ValueKey(_currentPage), style: textTheme.labelMedium?.copyWith( color: colors.onSurfaceVariant, ), ), ), const SizedBox(height: 8), // Page indicator dots Row( mainAxisAlignment: MainAxisAlignment.center, children: List.generate(_labels.length, (i) { final isActive = i == _currentPage; return GestureDetector( onTap: () => _pageController.animateToPage( i, duration: M3Motion.standard, curve: M3Motion.standard_, ), child: AnimatedContainer( duration: M3Motion.short, curve: M3Motion.standard_, margin: const EdgeInsets.symmetric(horizontal: 4), width: isActive ? 24 : 8, height: 8, decoration: BoxDecoration( color: isActive ? colors.primary : colors.onSurfaceVariant.withValues(alpha: 0.3), borderRadius: BorderRadius.circular(4), ), ), ); }), ), const SizedBox(height: 4), ], ); } } /// Side-by-side panel: enrolled face on the left, verification photo on the right. class _SideBySidePanel extends StatelessWidget { const _SideBySidePanel({ required this.enrolledFaceUrl, required this.verificationUrl, required this.verificationLabel, required this.verificationIcon, required this.emptyMessage, }); final String? enrolledFaceUrl; final String? verificationUrl; final String verificationLabel; final IconData verificationIcon; final String emptyMessage; @override Widget build(BuildContext context) { return LayoutBuilder( builder: (context, constraints) { final panelWidth = constraints.maxWidth >= 760 ? (constraints.maxWidth - 20) / 2 : (constraints.maxWidth - 20) / 2; return Padding( padding: const EdgeInsets.symmetric(horizontal: 4), child: Row( children: [ Expanded( child: _ImagePanel( width: panelWidth, title: 'Enrolled Face', imageUrl: enrolledFaceUrl, bucket: 'face-enrollment', emptyMessage: 'No enrolled face photo found.', icon: Icons.person, ), ), const SizedBox(width: 8), Expanded( child: _ImagePanel( width: panelWidth, title: verificationLabel, imageUrl: verificationUrl, bucket: 'attendance-verification', emptyMessage: emptyMessage, icon: verificationIcon, ), ), ], ), ); }, ); } } class _CheckInOutMapTab extends StatelessWidget { const _CheckInOutMapTab({required this.entry}); final _LogbookEntry entry; @override Widget build(BuildContext context) { return LayoutBuilder( builder: (context, constraints) { final panelWidth = constraints.maxWidth >= 760 ? (constraints.maxWidth - 12) / 2 : 300.0; return SingleChildScrollView( scrollDirection: Axis.horizontal, child: Row( children: [ SizedBox( width: panelWidth, child: _MapPanel( title: 'Check In', lat: entry.checkInLat, lng: entry.checkInLng, markerColor: Colors.green, ), ), const SizedBox(width: 12), SizedBox( width: panelWidth, child: _MapPanel( title: 'Check Out', lat: entry.checkOutLat, lng: entry.checkOutLng, markerColor: Colors.red, ), ), ], ), ); }, ); } } class _ImagePanel extends StatelessWidget { const _ImagePanel({ required this.width, required this.title, required this.imageUrl, required this.bucket, required this.emptyMessage, required this.icon, }); final double width; final String title; final String? imageUrl; final String bucket; final String emptyMessage; final IconData icon; @override Widget build(BuildContext context) { final colors = Theme.of(context).colorScheme; final hasImage = imageUrl != null && imageUrl!.isNotEmpty; return Card( child: SizedBox( width: width, child: Padding( padding: const EdgeInsets.all(12), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Icon(icon, size: 18, color: colors.primary), const SizedBox(width: 6), Text(title, style: Theme.of(context).textTheme.titleSmall), ], ), const SizedBox(height: 12), Expanded( child: ClipRRect( borderRadius: BorderRadius.circular(12), child: hasImage ? _SignedBucketImage( sourceUrl: imageUrl!, bucket: bucket, emptyMessage: emptyMessage, ) : _EmptyPanelState(message: emptyMessage), ), ), ], ), ), ), ); } } class _SignedBucketImage extends StatefulWidget { const _SignedBucketImage({ required this.sourceUrl, required this.bucket, required this.emptyMessage, }); final String sourceUrl; final String bucket; final String emptyMessage; @override State<_SignedBucketImage> createState() => _SignedBucketImageState(); } class _SignedBucketImageState extends State<_SignedBucketImage> { late final Future _resolvedUrlFuture; @override void initState() { super.initState(); _resolvedUrlFuture = _resolveAccessibleUrl( sourceUrl: widget.sourceUrl, bucket: widget.bucket, ); } Future _resolveAccessibleUrl({ required String sourceUrl, required String bucket, }) async { final trimmed = sourceUrl.trim(); if (trimmed.isEmpty) return null; // If already signed, keep it as-is. if (trimmed.contains('/storage/v1/object/sign/')) { return trimmed; } String? storagePath; // Handle full storage URLs and extract the object path. final bucketToken = '/$bucket/'; final bucketIndex = trimmed.indexOf(bucketToken); if (bucketIndex >= 0) { storagePath = trimmed.substring(bucketIndex + bucketToken.length); final queryIndex = storagePath.indexOf('?'); if (queryIndex >= 0) { storagePath = storagePath.substring(0, queryIndex); } } // If DB already stores a direct object path, use it directly. storagePath ??= trimmed.startsWith('http') ? null : trimmed; if (storagePath == null || storagePath.isEmpty) { return trimmed; } try { return await Supabase.instance.client.storage .from(bucket) .createSignedUrl(storagePath, 3600); } catch (_) { // Fall back to original URL so public buckets still work. return trimmed; } } @override Widget build(BuildContext context) { final colors = Theme.of(context).colorScheme; return FutureBuilder( future: _resolvedUrlFuture, builder: (context, snapshot) { if (snapshot.connectionState == ConnectionState.waiting) { return Container( color: colors.surfaceContainerLow, alignment: Alignment.center, child: const SizedBox( width: 24, height: 24, child: CircularProgressIndicator(strokeWidth: 2), ), ); } final resolvedUrl = snapshot.data; if (resolvedUrl == null || resolvedUrl.isEmpty) { return _EmptyPanelState(message: widget.emptyMessage); } return Container( color: colors.surfaceContainerLow, alignment: Alignment.center, child: Image.network( resolvedUrl, fit: BoxFit.contain, errorBuilder: (context, error, stack) { return _EmptyPanelState(message: widget.emptyMessage); }, ), ); }, ); } } class _MapPanel extends StatelessWidget { const _MapPanel({ required this.title, required this.lat, required this.lng, required this.markerColor, }); final String title; final double? lat; final double? lng; final Color markerColor; @override Widget build(BuildContext context) { final hasCoords = lat != null && lng != null; final center = hasCoords ? LatLng(lat!, lng!) : const LatLng(7.2046, 124.2460); return Card( child: Padding( padding: const EdgeInsets.all(12), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(title, style: Theme.of(context).textTheme.titleSmall), const SizedBox(height: 4), Text( hasCoords ? '${lat!.toStringAsFixed(6)}, ${lng!.toStringAsFixed(6)}' : 'No location captured.', style: Theme.of(context).textTheme.bodySmall?.copyWith( color: Theme.of(context).colorScheme.onSurfaceVariant, ), ), const SizedBox(height: 10), Expanded( child: ClipRRect( borderRadius: BorderRadius.circular(12), child: hasCoords ? FlutterMap( options: MapOptions( initialCenter: center, initialZoom: 16, interactionOptions: const InteractionOptions( flags: InteractiveFlag.pinchZoom | InteractiveFlag.drag | InteractiveFlag.doubleTapZoom, ), ), children: [ TileLayer( urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', userAgentPackageName: 'com.tasq.app', ), MarkerLayer( markers: [ Marker( width: 48, height: 48, point: center, child: Icon( Icons.location_pin, color: markerColor, size: 36, ), ), ], ), ], ) : const _EmptyPanelState( message: 'Location is unavailable for this event.', ), ), ), ], ), ), ); } } class _EmptyPanelState extends StatelessWidget { const _EmptyPanelState({required this.message}); final String message; @override Widget build(BuildContext context) { final colors = Theme.of(context).colorScheme; return Container( color: colors.surfaceContainerLow, alignment: Alignment.center, padding: const EdgeInsets.all(16), child: Text( message, textAlign: TextAlign.center, style: Theme.of( context, ).textTheme.bodyMedium?.copyWith(color: colors.onSurfaceVariant), ), ); } } // ──────────────────────────────────────────────── // Date filter dialog (reuses Metabase-style pattern) // ──────────────────────────────────────────────── class _AttendanceDateFilterDialog extends StatefulWidget { const _AttendanceDateFilterDialog({ required this.current, required this.onApply, }); final ReportDateRange current; final ValueChanged onApply; @override State<_AttendanceDateFilterDialog> createState() => _AttendanceDateFilterDialogState(); } class _AttendanceDateFilterDialogState extends State<_AttendanceDateFilterDialog> with SingleTickerProviderStateMixin { late TabController _tabCtrl; int _relativeAmount = 7; String _relativeUnit = 'days'; DateTime? _customStart; DateTime? _customEnd; static const _units = ['days', 'weeks', 'months', 'quarters', 'years']; @override void initState() { super.initState(); _tabCtrl = TabController(length: 3, vsync: this); _customStart = widget.current.start; _customEnd = widget.current.end; } @override void dispose() { _tabCtrl.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final theme = Theme.of(context); final colors = theme.colorScheme; final text = theme.textTheme; return Dialog( shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 440, maxHeight: 520), child: Column( mainAxisSize: MainAxisSize.min, children: [ Padding( padding: const EdgeInsets.fromLTRB(20, 16, 20, 0), child: Row( children: [ Icon(Icons.date_range, color: colors.primary), const SizedBox(width: 8), Text('Filter Date Range', style: text.titleMedium), const Spacer(), IconButton( icon: const Icon(Icons.close, size: 20), onPressed: () => Navigator.pop(context), ), ], ), ), TabBar( controller: _tabCtrl, labelStyle: text.labelMedium, tabs: const [ Tab(text: 'Presets'), Tab(text: 'Relative'), Tab(text: 'Custom'), ], ), Flexible( child: TabBarView( controller: _tabCtrl, children: [ _buildPresetsTab(context), _buildRelativeTab(context), _buildCustomTab(context), ], ), ), ], ), ), ); } Widget _buildPresetsTab(BuildContext context) { final now = AppTime.now(); final today = DateTime(now.year, now.month, now.day); final presets = <_Preset>[ _Preset('Today', today, today.add(const Duration(days: 1))), _Preset('Yesterday', today.subtract(const Duration(days: 1)), today), _Preset( 'Last 7 Days', today.subtract(const Duration(days: 7)), today.add(const Duration(days: 1)), ), _Preset( 'Last 30 Days', today.subtract(const Duration(days: 30)), today.add(const Duration(days: 1)), ), _Preset( 'Last 90 Days', today.subtract(const Duration(days: 90)), today.add(const Duration(days: 1)), ), _Preset( 'This Week', today.subtract(Duration(days: today.weekday - 1)), today.add(const Duration(days: 1)), ), _Preset( 'This Month', DateTime(now.year, now.month, 1), DateTime(now.year, now.month + 1, 1), ), _Preset( 'This Year', DateTime(now.year, 1, 1), DateTime(now.year + 1, 1, 1), ), ]; return SingleChildScrollView( padding: const EdgeInsets.all(16), child: Wrap( spacing: 8, runSpacing: 8, children: presets.map((p) { final isSelected = widget.current.label == p.label; return ChoiceChip( label: Text(p.label), selected: isSelected, onSelected: (_) { widget.onApply( ReportDateRange(start: p.start, end: p.end, label: p.label), ); Navigator.pop(context); }, ); }).toList(), ), ); } Widget _buildRelativeTab(BuildContext context) { final theme = Theme.of(context); final text = theme.textTheme; final preview = _computeRelativeRange(_relativeAmount, _relativeUnit); final previewText = AppTime.formatDateRange(preview.dateTimeRange); return Padding( padding: const EdgeInsets.all(20), child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ Text('Previous', style: text.labelLarge), const SizedBox(height: 12), Row( children: [ SizedBox( width: 80, child: TextFormField( initialValue: _relativeAmount.toString(), keyboardType: TextInputType.number, decoration: const InputDecoration( isDense: true, contentPadding: EdgeInsets.symmetric( horizontal: 12, vertical: 10, ), ), onChanged: (v) => setState(() { _relativeAmount = int.tryParse(v) ?? _relativeAmount; }), ), ), const SizedBox(width: 12), Expanded( child: DropdownButtonFormField( initialValue: _relativeUnit, decoration: const InputDecoration( isDense: true, contentPadding: EdgeInsets.symmetric( horizontal: 12, vertical: 10, ), ), items: _units .map((u) => DropdownMenuItem(value: u, child: Text(u))) .toList(), onChanged: (v) { if (v != null) setState(() => _relativeUnit = v); }, ), ), const SizedBox(width: 12), Text('ago', style: text.bodyLarge), ], ), const SizedBox(height: 16), Text( previewText, style: text.bodyMedium?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), ), const SizedBox(height: 20), Align( alignment: Alignment.centerRight, child: FilledButton( onPressed: () { widget.onApply(preview); Navigator.pop(context); }, child: const Text('Apply'), ), ), ], ), ); } ReportDateRange _computeRelativeRange(int amount, String unit) { final now = AppTime.now(); final end = now; DateTime start; switch (unit) { case 'days': start = now.subtract(Duration(days: amount)); case 'weeks': start = now.subtract(Duration(days: amount * 7)); case 'months': start = DateTime(now.year, now.month - amount, now.day); case 'quarters': start = DateTime(now.year, now.month - (amount * 3), now.day); case 'years': start = DateTime(now.year - amount, now.month, now.day); default: start = now.subtract(Duration(days: amount)); } return ReportDateRange(start: start, end: end, label: 'Last $amount $unit'); } Widget _buildCustomTab(BuildContext context) { final theme = Theme.of(context); final text = theme.textTheme; return Padding( padding: const EdgeInsets.all(20), child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ Text('Start Date', style: text.labelLarge), const SizedBox(height: 8), OutlinedButton.icon( icon: const Icon(Icons.calendar_today, size: 16), label: Text( _customStart != null ? AppTime.formatDate(_customStart!) : 'Select start date', ), onPressed: () async { final picked = await showDatePicker( context: context, initialDate: _customStart ?? AppTime.now(), firstDate: DateTime(2020), lastDate: DateTime(2030), ); if (picked != null) setState(() => _customStart = picked); }, ), const SizedBox(height: 16), Text('End Date', style: text.labelLarge), const SizedBox(height: 8), OutlinedButton.icon( icon: const Icon(Icons.calendar_today, size: 16), label: Text( _customEnd != null ? AppTime.formatDate(_customEnd!) : 'Select end date', ), onPressed: () async { final picked = await showDatePicker( context: context, initialDate: _customEnd ?? AppTime.now(), firstDate: DateTime(2020), lastDate: DateTime(2030), ); if (picked != null) setState(() => _customEnd = picked); }, ), const SizedBox(height: 20), Align( alignment: Alignment.centerRight, child: FilledButton( onPressed: (_customStart != null && _customEnd != null) ? () { widget.onApply( ReportDateRange( start: _customStart!, end: _customEnd!.add(const Duration(days: 1)), label: 'Custom', ), ); Navigator.pop(context); } : null, child: const Text('Apply'), ), ), ], ), ); } } class _Preset { const _Preset(this.label, this.start, this.end); final String label; final DateTime start; final DateTime end; } // ──────────────────────────────────────────────── // Tab 3 – Pass Slip // ──────────────────────────────────────────────── class _PassSlipTab extends ConsumerStatefulWidget { const _PassSlipTab(); @override ConsumerState<_PassSlipTab> createState() => _PassSlipTabState(); } class _PassSlipTabState extends ConsumerState<_PassSlipTab> { bool _submitting = false; @override Widget build(BuildContext context) { final theme = Theme.of(context); final colors = theme.colorScheme; final profile = ref.watch(currentProfileProvider).valueOrNull; final slipsAsync = ref.watch(passSlipsProvider); final profilesAsync = ref.watch(profilesProvider); final activeSlip = ref.watch(activePassSlipProvider); final isAdmin = profile?.role == 'admin' || profile?.role == 'dispatcher'; final Map profileById = { for (final p in profilesAsync.valueOrNull ?? []) p.id: p, }; if (profile == null) { return const Center(child: CircularProgressIndicator()); } return slipsAsync.when( data: (slips) { return ListView( padding: const EdgeInsets.all(16), children: [ // Active slip banner if (activeSlip != null) ...[ Card( color: activeSlip.isExceeded ? colors.errorContainer : colors.tertiaryContainer, child: Padding( padding: const EdgeInsets.all(16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Icon( activeSlip.isExceeded ? Icons.warning : Icons.directions_walk, color: activeSlip.isExceeded ? colors.onErrorContainer : colors.onTertiaryContainer, ), const SizedBox(width: 8), Expanded( child: Text( activeSlip.isExceeded ? 'Pass Slip Exceeded (>1 hour)' : 'Active Pass Slip', style: theme.textTheme.titleSmall?.copyWith( fontWeight: FontWeight.w600, color: activeSlip.isExceeded ? colors.onErrorContainer : colors.onTertiaryContainer, ), ), ), ], ), const SizedBox(height: 8), Text( 'Reason: ${activeSlip.reason}', style: theme.textTheme.bodyMedium, ), if (activeSlip.slipStart != null) Text( 'Started: ${AppTime.formatTime(activeSlip.slipStart!)}', style: theme.textTheme.bodySmall, ), const SizedBox(height: 12), SizedBox( width: double.infinity, child: FilledButton.icon( onPressed: _submitting ? null : () => _completeSlip(activeSlip.id), icon: const Icon(Icons.check), label: const Text('Complete / Return'), ), ), ], ), ), ), const SizedBox(height: 16), ], // Request form removed — use FAB instead // Pending slips for admin approval if (isAdmin) ...[ Text( 'Pending Approvals', style: theme.textTheme.titleMedium?.copyWith( fontWeight: FontWeight.w600, ), ), const SizedBox(height: 8), ...slips .where((s) => s.status == 'pending') .map( (slip) => _buildSlipCard( context, slip, profileById, showActions: true, ), ), if (slips.where((s) => s.status == 'pending').isEmpty) Padding( padding: const EdgeInsets.symmetric(vertical: 8), child: Text( 'No pending pass slip requests.', style: theme.textTheme.bodyMedium?.copyWith( color: colors.onSurfaceVariant, ), ), ), const SizedBox(height: 16), ], // History Text( 'Pass Slip History', style: theme.textTheme.titleMedium?.copyWith( fontWeight: FontWeight.w600, ), ), const SizedBox(height: 8), ...slips .where((s) => s.status != 'pending' || !isAdmin) .take(50) .map( (slip) => _buildSlipCard( context, slip, profileById, showActions: false, ), ), if (slips.isEmpty) Center( child: Padding( padding: const EdgeInsets.symmetric(vertical: 24), child: Text( 'No pass slip records.', style: theme.textTheme.bodyMedium?.copyWith( color: colors.onSurfaceVariant, ), ), ), ), ], ); }, loading: () => const Center(child: CircularProgressIndicator()), error: (e, _) => Center(child: Text('Failed to load pass slips: $e')), ); } Widget _buildSlipCard( BuildContext context, PassSlip slip, Map profileById, { required bool showActions, }) { final theme = Theme.of(context); final colors = theme.colorScheme; final p = profileById[slip.userId]; final name = p?.fullName ?? slip.userId; Color statusColor; switch (slip.status) { case 'approved': statusColor = Colors.green; case 'rejected': statusColor = colors.error; case 'completed': statusColor = colors.primary; default: statusColor = Colors.orange; } return Card( child: Padding( padding: const EdgeInsets.all(12), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Expanded(child: Text(name, style: theme.textTheme.titleSmall)), Container( padding: const EdgeInsets.symmetric( horizontal: 10, vertical: 4, ), decoration: BoxDecoration( color: statusColor.withValues(alpha: 0.15), borderRadius: BorderRadius.circular(12), ), child: Text( slip.status.toUpperCase(), style: theme.textTheme.labelSmall?.copyWith( color: statusColor, fontWeight: FontWeight.w600, ), ), ), ], ), const SizedBox(height: 4), Text(slip.reason, style: theme.textTheme.bodyMedium), Text( 'Requested: ${AppTime.formatDate(slip.requestedAt)} ${AppTime.formatTime(slip.requestedAt)}', style: theme.textTheme.bodySmall?.copyWith( color: colors.onSurfaceVariant, ), ), if (slip.slipStart != null) Text( 'Started: ${AppTime.formatTime(slip.slipStart!)}' '${slip.slipEnd != null ? " · Ended: ${AppTime.formatTime(slip.slipEnd!)}" : ""}', style: theme.textTheme.bodySmall?.copyWith( color: colors.onSurfaceVariant, ), ), if (showActions && slip.status == 'pending') ...[ const SizedBox(height: 8), Row( mainAxisAlignment: MainAxisAlignment.end, children: [ TextButton( onPressed: _submitting ? null : () => _rejectSlip(slip.id), child: Text( 'Reject', style: TextStyle(color: colors.error), ), ), const SizedBox(width: 8), FilledButton( onPressed: _submitting ? null : () => _approveSlip(slip.id), child: const Text('Approve'), ), ], ), ], ], ), ), ); } Future _approveSlip(String slipId) async { setState(() => _submitting = true); try { await ref.read(passSlipControllerProvider).approveSlip(slipId); if (mounted) { showSuccessSnackBar(context, 'Pass slip approved.'); } } catch (e) { if (mounted) { showErrorSnackBar(context, 'Failed: $e'); } } finally { if (mounted) setState(() => _submitting = false); } } Future _rejectSlip(String slipId) async { setState(() => _submitting = true); try { await ref.read(passSlipControllerProvider).rejectSlip(slipId); if (mounted) { showSuccessSnackBar(context, 'Pass slip rejected.'); } } catch (e) { if (mounted) { showErrorSnackBar(context, 'Failed: $e'); } } finally { if (mounted) setState(() => _submitting = false); } } Future _completeSlip(String slipId) async { setState(() => _submitting = true); try { await ref.read(passSlipControllerProvider).completeSlip(slipId); if (mounted) { showSuccessSnackBar(context, 'Pass slip completed.'); } } catch (e) { if (mounted) { showErrorSnackBar(context, 'Failed: $e'); } } finally { if (mounted) setState(() => _submitting = false); } } } // ──────────────────────────────────────────────── // Tab 4 – Leave of Absence // ──────────────────────────────────────────────── class _LeaveTab extends ConsumerStatefulWidget { const _LeaveTab(); @override ConsumerState<_LeaveTab> createState() => _LeaveTabState(); } class _LeaveTabState extends ConsumerState<_LeaveTab> { bool _submitting = false; @override Widget build(BuildContext context) { final theme = Theme.of(context); final colors = theme.colorScheme; final profile = ref.watch(currentProfileProvider).valueOrNull; final leavesAsync = ref.watch(leavesProvider); final profilesAsync = ref.watch(profilesProvider); if (profile == null) { return const Center(child: CircularProgressIndicator()); } final isAdmin = profile.role == 'admin'; final profiles = profilesAsync.valueOrNull ?? []; final profileById = {for (final p in profiles) p.id: p}; return leavesAsync.when( data: (leaves) { final myLeaves = leaves.where((l) => l.userId == profile.id).toList(); final pendingApprovals = isAdmin ? leaves .where((l) => l.status == 'pending' && l.userId != profile.id) .toList() : []; return ListView( padding: const EdgeInsets.all(16), children: [ // ── Pending Approvals (admin only) ── if (isAdmin) ...[ Text( 'Pending Approvals', style: theme.textTheme.titleMedium?.copyWith( fontWeight: FontWeight.w600, ), ), const SizedBox(height: 8), if (pendingApprovals.isEmpty) Padding( padding: const EdgeInsets.symmetric(vertical: 8), child: Text( 'No pending leave requests.', style: theme.textTheme.bodyMedium?.copyWith( color: colors.onSurfaceVariant, ), ), ), ...pendingApprovals.map( (leave) => _buildLeaveCard( context, leave, profileById, showApproval: true, ), ), const SizedBox(height: 24), ], // ── My Leave Applications ── Text( 'My Leave Applications', style: theme.textTheme.titleMedium?.copyWith( fontWeight: FontWeight.w600, ), ), const SizedBox(height: 8), if (myLeaves.isEmpty) Center( child: Padding( padding: const EdgeInsets.symmetric(vertical: 24), child: Text( 'You have no leave applications.', style: theme.textTheme.bodyMedium?.copyWith( color: colors.onSurfaceVariant, ), ), ), ), ...myLeaves .take(50) .map( (leave) => _buildLeaveCard( context, leave, profileById, showApproval: false, ), ), // ── All Leave History (admin only) ── if (isAdmin) ...[ const SizedBox(height: 24), Text( 'All Leave History', style: theme.textTheme.titleMedium?.copyWith( fontWeight: FontWeight.w600, ), ), const SizedBox(height: 8), ...leaves .where((l) => l.status != 'pending' && l.userId != profile.id) .take(50) .map( (leave) => _buildLeaveCard( context, leave, profileById, showApproval: false, ), ), if (leaves .where((l) => l.status != 'pending' && l.userId != profile.id) .isEmpty) Padding( padding: const EdgeInsets.symmetric(vertical: 8), child: Text( 'No leave history from other staff.', style: theme.textTheme.bodyMedium?.copyWith( color: colors.onSurfaceVariant, ), ), ), ], const SizedBox(height: 80), ], ); }, loading: () => const Center(child: CircularProgressIndicator()), error: (e, _) => Center(child: Text('Failed to load leaves: $e')), ); } Widget _buildLeaveCard( BuildContext context, LeaveOfAbsence leave, Map profileById, { required bool showApproval, }) { final theme = Theme.of(context); final colors = theme.colorScheme; final p = profileById[leave.userId]; final name = p?.fullName ?? leave.userId; Color statusColor; switch (leave.status) { case 'approved': statusColor = Colors.teal; case 'rejected': statusColor = colors.error; case 'cancelled': statusColor = colors.onSurfaceVariant; default: statusColor = Colors.orange; } return Card( child: Padding( padding: const EdgeInsets.all(12), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Expanded(child: Text(name, style: theme.textTheme.titleSmall)), Container( padding: const EdgeInsets.symmetric( horizontal: 10, vertical: 4, ), decoration: BoxDecoration( color: statusColor.withValues(alpha: 0.15), borderRadius: BorderRadius.circular(12), ), child: Text( leave.status.toUpperCase(), style: theme.textTheme.labelSmall?.copyWith( color: statusColor, fontWeight: FontWeight.w600, ), ), ), ], ), const SizedBox(height: 4), Text( leave.leaveTypeLabel, style: theme.textTheme.bodyMedium?.copyWith( fontWeight: FontWeight.w500, ), ), Text(leave.justification, style: theme.textTheme.bodyMedium), Text( '${AppTime.formatDate(leave.startTime)} ' '${AppTime.formatTime(leave.startTime)} – ' '${AppTime.formatTime(leave.endTime)}', style: theme.textTheme.bodySmall?.copyWith( color: colors.onSurfaceVariant, ), ), // Approve / Reject for admins on pending leaves if (showApproval && leave.status == 'pending') ...[ const SizedBox(height: 8), Row( mainAxisAlignment: MainAxisAlignment.end, children: [ TextButton( onPressed: _submitting ? null : () => _rejectLeave(leave.id), child: Text( 'Reject', style: TextStyle(color: colors.error), ), ), const SizedBox(width: 8), FilledButton( onPressed: _submitting ? null : () => _approveLeave(leave.id), child: const Text('Approve'), ), ], ), ], // Cancel future approved leaves: // - user can cancel own // - admin can cancel anyone if (!showApproval && _canCancelFutureApproved(leave)) ...[ const SizedBox(height: 8), Align( alignment: Alignment.centerRight, child: TextButton( onPressed: _submitting ? null : () => _cancelLeave(leave.id), child: Text('Cancel', style: TextStyle(color: colors.error)), ), ), ], ], ), ), ); } bool _canCancelFutureApproved(LeaveOfAbsence leave) { if (leave.status != 'approved' || !leave.startTime.isAfter(AppTime.now())) { return false; } final profile = ref.read(currentProfileProvider).valueOrNull; if (profile == null) return false; final isAdmin = profile.role == 'admin'; return isAdmin || leave.userId == profile.id; } Future _approveLeave(String leaveId) async { setState(() => _submitting = true); try { await ref.read(leaveControllerProvider).approveLeave(leaveId); ref.invalidate(leavesProvider); ref.invalidate(dashboardMetricsProvider); if (mounted) { showSuccessSnackBar(context, 'Leave approved.'); } } catch (e) { if (mounted) { showErrorSnackBar(context, 'Failed: $e'); } } finally { if (mounted) setState(() => _submitting = false); } } Future _rejectLeave(String leaveId) async { setState(() => _submitting = true); try { await ref.read(leaveControllerProvider).rejectLeave(leaveId); ref.invalidate(leavesProvider); ref.invalidate(dashboardMetricsProvider); if (mounted) { showSuccessSnackBar(context, 'Leave rejected.'); } } catch (e) { if (mounted) { showErrorSnackBar(context, 'Failed: $e'); } } finally { if (mounted) setState(() => _submitting = false); } } Future _cancelLeave(String leaveId) async { setState(() => _submitting = true); try { await ref.read(leaveControllerProvider).cancelLeave(leaveId); ref.invalidate(leavesProvider); ref.invalidate(dashboardMetricsProvider); if (mounted) { showSuccessSnackBar(context, 'Leave cancelled.'); } } catch (e) { if (mounted) { showErrorSnackBar(context, 'Failed: $e'); } } finally { if (mounted) setState(() => _submitting = false); } } } // ─── FAB Menu Item ────────────────────────────────────────────── class _FabMenuItem extends StatelessWidget { const _FabMenuItem({ required this.heroTag, required this.label, required this.icon, required this.color, required this.onColor, required this.onTap, }); final String heroTag; final String label; final IconData icon; final Color color; final Color onColor; final VoidCallback onTap; @override Widget build(BuildContext context) { return Row( mainAxisSize: MainAxisSize.min, children: [ Material( color: color, borderRadius: BorderRadius.circular(12), child: Padding( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), child: Text( label, style: Theme.of( context, ).textTheme.labelLarge?.copyWith(color: onColor), ), ), ), const SizedBox(width: 12), FloatingActionButton.small( heroTag: heroTag, backgroundColor: color, foregroundColor: onColor, onPressed: onTap, child: Icon(icon), ), ], ); } } // ─── Pass Slip Dialog (with Gemini) ───────────────────────────── class _PassSlipDialog extends ConsumerStatefulWidget { const _PassSlipDialog({required this.scheduleId, required this.onSubmitted}); final String scheduleId; final VoidCallback onSubmitted; @override ConsumerState<_PassSlipDialog> createState() => _PassSlipDialogState(); } class _PassSlipDialogState extends ConsumerState<_PassSlipDialog> { final _reasonController = TextEditingController(); bool _submitting = false; bool _isGeminiProcessing = false; @override void dispose() { _reasonController.dispose(); super.dispose(); } Future _submit() async { final reason = _reasonController.text.trim(); if (reason.isEmpty) { showWarningSnackBar(context, 'Please enter a reason.'); return; } setState(() => _submitting = true); try { await ref .read(passSlipControllerProvider) .requestSlip(dutyScheduleId: widget.scheduleId, reason: reason); if (mounted) { Navigator.of(context).pop(); widget.onSubmitted(); } } catch (e) { if (mounted) showErrorSnackBar(context, 'Failed: $e'); } finally { if (mounted) setState(() => _submitting = false); } } @override Widget build(BuildContext context) { final theme = Theme.of(context); return Dialog( shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(28)), child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 480), child: Padding( padding: const EdgeInsets.all(24), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Text('Request Pass Slip', style: theme.textTheme.headlineSmall), const SizedBox(height: 16), Row( children: [ Expanded( child: GeminiAnimatedTextField( controller: _reasonController, labelText: 'Reason', maxLines: 3, enabled: !_submitting, isProcessing: _isGeminiProcessing, ), ), Padding( padding: const EdgeInsets.only(left: 8.0), child: GeminiButton( textController: _reasonController, onTextUpdated: (text) { setState(() => _reasonController.text = text); }, onProcessingStateChanged: (processing) { setState(() => _isGeminiProcessing = processing); }, tooltip: 'Translate/Enhance with AI', promptBuilder: (_) => 'Translate this sentence to clear professional English ' 'if needed, and enhance grammar/clarity while preserving ' 'the original meaning. Return ONLY the improved text, ' 'with no explanations, no recommendations, and no extra context.', ), ), ], ), const SizedBox(height: 24), Row( mainAxisAlignment: MainAxisAlignment.end, children: [ TextButton( onPressed: _submitting ? null : () => Navigator.of(context).pop(), child: const Text('Cancel'), ), const SizedBox(width: 8), FilledButton( onPressed: _submitting ? null : _submit, child: _submitting ? const SizedBox( width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2), ) : const Text('Submit'), ), ], ), ], ), ), ), ); } } // ─── File Leave Dialog ────────────────────────────────────────── class _FileLeaveDialog extends ConsumerStatefulWidget { const _FileLeaveDialog({required this.isAdmin, required this.onSubmitted}); final bool isAdmin; final VoidCallback onSubmitted; @override ConsumerState<_FileLeaveDialog> createState() => _FileLeaveDialogState(); } class _FileLeaveDialogState extends ConsumerState<_FileLeaveDialog> { final _justificationController = TextEditingController(); bool _submitting = false; bool _isGeminiProcessing = false; String _leaveType = 'emergency_leave'; DateTime? _startDate; TimeOfDay? _startTime; TimeOfDay? _endTime; static const _leaveTypes = { 'emergency_leave': 'Emergency Leave', 'parental_leave': 'Parental Leave', 'sick_leave': 'Sick Leave', 'vacation_leave': 'Vacation Leave', }; @override void dispose() { _justificationController.dispose(); super.dispose(); } void _autoFillShiftTimes(List schedules, String userId) { if (_startDate == null) return; final day = DateTime(_startDate!.year, _startDate!.month, _startDate!.day); final match = schedules.where((s) { final sDay = DateTime( s.startTime.year, s.startTime.month, s.startTime.day, ); return s.userId == userId && sDay == day; }).toList(); if (match.isNotEmpty) { setState(() { _startTime = TimeOfDay.fromDateTime(match.first.startTime); _endTime = TimeOfDay.fromDateTime(match.first.endTime); }); } } Future _pickDate() async { final now = AppTime.now(); final today = DateTime(now.year, now.month, now.day); final picked = await showDatePicker( context: context, initialDate: _startDate ?? today, firstDate: today, lastDate: today.add(const Duration(days: 365)), ); if (picked != null) { setState(() => _startDate = picked); final profile = ref.read(currentProfileProvider).valueOrNull; if (profile != null) { final schedules = ref.read(dutySchedulesProvider).valueOrNull ?? []; _autoFillShiftTimes(schedules, profile.id); } } } Future _pickStartTime() async { final picked = await showTimePicker( context: context, initialTime: _startTime ?? const TimeOfDay(hour: 8, minute: 0), ); if (picked != null) setState(() => _startTime = picked); } Future _pickEndTime() async { final picked = await showTimePicker( context: context, initialTime: _endTime ?? const TimeOfDay(hour: 17, minute: 0), ); if (picked != null) setState(() => _endTime = picked); } Future _submit() async { if (_startDate == null) { showWarningSnackBar(context, 'Please select a date.'); return; } if (_startTime == null || _endTime == null) { showWarningSnackBar(context, 'Please set start and end times.'); return; } if (_justificationController.text.trim().isEmpty) { showWarningSnackBar(context, 'Please enter a justification.'); return; } var startDt = DateTime( _startDate!.year, _startDate!.month, _startDate!.day, _startTime!.hour, _startTime!.minute, ); var endDt = DateTime( _startDate!.year, _startDate!.month, _startDate!.day, _endTime!.hour, _endTime!.minute, ); if (!endDt.isAfter(startDt)) { showWarningSnackBar(context, 'End time must be after start time.'); return; } // convert to app timezone to avoid device-local mismatches startDt = AppTime.toAppTime(startDt); endDt = AppTime.toAppTime(endDt); setState(() => _submitting = true); try { await ref .read(leaveControllerProvider) .fileLeave( leaveType: _leaveType, justification: _justificationController.text.trim(), startTime: startDt, endTime: endDt, autoApprove: widget.isAdmin, ); // ensure UI and dashboard will refresh promptly even if realtime is // delayed or temporarily disconnected ref.invalidate(leavesProvider); ref.invalidate(dashboardMetricsProvider); if (mounted) { Navigator.of(context).pop(); widget.onSubmitted(); } } catch (e) { if (mounted) { showErrorSnackBar(context, 'Failed to file leave: $e'); } } finally { if (mounted) setState(() => _submitting = false); } } @override Widget build(BuildContext context) { final theme = Theme.of(context); final colors = theme.colorScheme; return Dialog( shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(28)), child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 480), child: Padding( padding: const EdgeInsets.all(24), child: SingleChildScrollView( child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( 'File Leave of Absence', style: theme.textTheme.headlineSmall, ), const SizedBox(height: 16), // Leave type DropdownButtonFormField( // ignore: deprecated_member_use value: _leaveType, decoration: const InputDecoration(labelText: 'Leave Type'), items: _leaveTypes.entries .map( (e) => DropdownMenuItem( value: e.key, child: Text(e.value), ), ) .toList(), onChanged: (v) { if (v != null) setState(() => _leaveType = v); }, ), const SizedBox(height: 12), // Date picker ListTile( contentPadding: EdgeInsets.zero, leading: const Icon(Icons.calendar_today), title: Text( _startDate == null ? 'Select Date' : AppTime.formatDate(_startDate!), ), subtitle: const Text('Current or future dates only'), onTap: _pickDate, ), // Time range Row( children: [ Expanded( child: ListTile( contentPadding: EdgeInsets.zero, leading: const Icon(Icons.access_time), title: Text( _startTime == null ? 'Start Time' : _startTime!.format(context), ), onTap: _pickStartTime, ), ), const Padding( padding: EdgeInsets.symmetric(horizontal: 8), child: Icon(Icons.arrow_forward), ), Expanded( child: ListTile( contentPadding: EdgeInsets.zero, leading: const Icon(Icons.access_time), title: Text( _endTime == null ? 'End Time' : _endTime!.format(context), ), subtitle: const Text('From shift schedule'), onTap: _pickEndTime, ), ), ], ), const SizedBox(height: 12), // Justification with AI Row( children: [ Expanded( child: GeminiAnimatedTextField( controller: _justificationController, labelText: 'Justification', maxLines: 3, enabled: !_submitting, isProcessing: _isGeminiProcessing, ), ), Padding( padding: const EdgeInsets.only(left: 8.0), child: GeminiButton( textController: _justificationController, onTextUpdated: (text) { setState(() { _justificationController.text = text; }); }, onProcessingStateChanged: (processing) { setState(() => _isGeminiProcessing = processing); }, tooltip: 'Translate/Enhance with AI', promptBuilder: (_) => 'Translate this sentence to clear professional English ' 'if needed, and enhance grammar/clarity while preserving ' 'the original meaning. Return ONLY the improved text, ' 'with no explanations, no recommendations, and no extra context.', ), ), ], ), const SizedBox(height: 16), if (widget.isAdmin) Padding( padding: const EdgeInsets.only(bottom: 8), child: Text( 'As admin, your leave will be auto-approved.', style: theme.textTheme.bodySmall?.copyWith( color: colors.primary, ), ), ), // Actions Row( mainAxisAlignment: MainAxisAlignment.end, children: [ TextButton( onPressed: _submitting ? null : () => Navigator.of(context).pop(), child: const Text('Cancel'), ), const SizedBox(width: 8), FilledButton.icon( onPressed: _submitting ? null : _submit, icon: _submitting ? const SizedBox( width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2), ) : const Icon(Icons.event_busy), label: const Text('File Leave'), ), ], ), ], ), ), ), ), ); } }