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 '../../models/rotation_config.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/rotation_config_provider.dart'; import '../../providers/whereabouts_provider.dart'; import '../../providers/workforce_provider.dart'; import '../../providers/notifications_provider.dart'; import '../../providers/it_service_request_provider.dart'; import '../../models/it_service_request.dart'; import '../../models/swap_request.dart'; import '../../theme/m3_motion.dart'; import '../../theme/app_surfaces.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 '../../widgets/app_state_view.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: 5, 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 >= 3; // 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: 'My Schedule'), Tab(text: 'Pass Slip'), Tab(text: 'Leave'), ], ), Expanded( child: TabBarView( controller: _tabController, children: [ const _CheckInTab(), const _LogbookTab(), _MyScheduleTab(), const _PassSlipTab(), const _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 M3Fab( heroTag: 'fab_close', onPressed: () => setState(() => _fabMenuOpen = false), icon: 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 Padding( padding: const EdgeInsets.only(bottom: 8), child: 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 – My Schedule // ──────────────────────────────────────────────── class _MyScheduleTab extends ConsumerStatefulWidget { const _MyScheduleTab(); @override ConsumerState<_MyScheduleTab> createState() => _MyScheduleTabState(); } class _MyScheduleTabState extends ConsumerState<_MyScheduleTab> { // Tracks which swap-group cards are expanded (keyed by requesterId_recipientId). final Set _expandedGroupKeys = {}; @override Widget build(BuildContext context) { final schedulesAsync = ref.watch(dutySchedulesProvider); final swapsAsync = ref.watch(swapRequestsProvider); final removedSwapIds = ref.watch(locallyRemovedSwapIdsProvider); final currentUserId = ref.watch(currentUserIdProvider); final profilesAsync = ref.watch(profilesProvider); final rotationConfig = ref.watch(rotationConfigProvider).valueOrNull; final userProfile = ref.watch(currentProfileProvider).valueOrNull; final isItStaff = userProfile?.role == 'it_staff'; final colors = Theme.of(context).colorScheme; return Column( children: [ Expanded( child: schedulesAsync.when( data: (allSchedules) { final now = AppTime.now(); final today = DateTime(now.year, now.month, now.day); // Filter to current user's upcoming non-overtime schedules final mySchedules = allSchedules .where((s) => s.userId == currentUserId && s.shiftType != 'overtime' && !s.endTime.isBefore(today)) .toList() ..sort((a, b) => a.startTime.compareTo(b.startTime)); // Get pending swaps for current user, excluding any locally // acted-on IDs so the card disappears immediately without // waiting for the stream to re-emit. final pendingSwaps = (swapsAsync.valueOrNull ?? []) .where((s) => !removedSwapIds.contains(s.id) && (s.requesterId == currentUserId || s.recipientId == currentUserId) && (s.status == 'pending' || s.status == 'admin_review')) .toList(); final Map profileById = { for (final profile in profilesAsync.valueOrNull ?? []) profile.id: profile, }; if (mySchedules.isEmpty && pendingSwaps.isEmpty) { return const Center( child: Text('No upcoming schedules or swap requests.'), ); } return CustomScrollView( slivers: [ if (mySchedules.isNotEmpty) ...[ SliverToBoxAdapter( child: Padding( padding: const EdgeInsets.only(left: 16, top: 16, bottom: 8), child: Text( 'My Shifts', style: Theme.of(context).textTheme.titleMedium ?.copyWith(fontWeight: FontWeight.w700), ), ), ), SliverList.separated( itemCount: mySchedules.length, separatorBuilder: (_, _) => const SizedBox(height: 8), itemBuilder: (context, index) { final schedule = mySchedules[index]; final hasPendingSwap = pendingSwaps.any((s) => s.requesterScheduleId == schedule.id && s.requesterId == currentUserId && s.status == 'pending'); return Padding( padding: const EdgeInsets.symmetric(horizontal: 16), child: Card( child: Padding( padding: const EdgeInsets.all(12), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Flexible( child: Text( _shiftLabel( schedule.shiftType, rotationConfig, ), style: Theme.of(context) .textTheme .titleSmall ?.copyWith( fontWeight: FontWeight.w700, ), overflow: TextOverflow.ellipsis, ), ), if (schedule.swapRequestId != null) ...[ const SizedBox(width: 6), Container( padding: const EdgeInsets .symmetric( horizontal: 6, vertical: 2, ), decoration: BoxDecoration( color: colors .tertiaryContainer, borderRadius: BorderRadius.circular( 6, ), ), child: Text( 'Swapped', style: Theme.of(context) .textTheme .labelSmall ?.copyWith( color: colors .onTertiaryContainer, fontWeight: FontWeight.w600, ), ), ), ], ], ), const SizedBox(height: 2), Text( '${AppTime.formatDate(schedule.startTime)} · ${AppTime.formatTime(schedule.startTime)}-${AppTime.formatTime(schedule.endTime)}', style: Theme.of(context) .textTheme .bodySmall, ), ], ), ), if (!hasPendingSwap && schedule.status != 'absent' && isItStaff) OutlinedButton.icon( onPressed: () => _showSwapDialog(context, ref, schedule, profileById), icon: const Icon(Icons.swap_horiz, size: 18), label: const Text('Swap'), ) else if (hasPendingSwap) Chip( label: const Text('Swap Pending'), side: BorderSide( color: Theme.of(context) .colorScheme .primary, ), ), ], ), ], ), ), ), ); }, ), const SliverToBoxAdapter(child: SizedBox(height: 16)), ], if (pendingSwaps.isNotEmpty) ...[ SliverToBoxAdapter( child: Padding( padding: const EdgeInsets.only(left: 16, bottom: 8), child: Text( 'Swap Requests', style: Theme.of(context).textTheme.titleMedium ?.copyWith(fontWeight: FontWeight.w700), ), ), ), // Group swaps by (requesterId, recipientId) so that a // week/range batch appears as a single collapsible card. Builder(builder: (context) { final swapGroups = >{}; for (final swap in pendingSwaps) { final key = '${swap.requesterId}_${swap.recipientId}'; swapGroups.putIfAbsent(key, () => []).add(swap); } for (final group in swapGroups.values) { group.sort((a, b) => (a.shiftStartTime ?? a.createdAt).compareTo( b.shiftStartTime ?? b.createdAt)); } final groupEntries = swapGroups.entries.toList() ..sort((a, b) => a.value.first.createdAt .compareTo(b.value.first.createdAt)); return SliverList.separated( itemCount: groupEntries.length, separatorBuilder: (context, i) => const SizedBox(height: 8), itemBuilder: (context, index) { final entry = groupEntries[index]; final groupKey = entry.key; final group = entry.value; if (group.length == 1) { // ── Single swap card (existing layout) ── final swap = group.first; final isRecipient = swap.recipientId == currentUserId; final isRequester = swap.requesterId == currentUserId; final requesterName = profileById[swap.requesterId]?.fullName ?? swap.requesterId; final recipientName = profileById[swap.recipientId]?.fullName ?? swap.recipientId; return Padding( padding: const EdgeInsets.symmetric( horizontal: 16), child: Card( child: Padding( padding: const EdgeInsets.all(12), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( '$requesterName → $recipientName', style: Theme.of(context) .textTheme .titleSmall ?.copyWith( fontWeight: FontWeight.w700, ), ), const SizedBox(height: 4), Text( '${_shiftLabel(swap.shiftType ?? 'normal', rotationConfig)} · ${AppTime.formatDate(swap.shiftStartTime ?? DateTime.now())} · ${AppTime.formatTime(swap.shiftStartTime ?? DateTime.now())}', style: Theme.of(context) .textTheme .bodySmall, ), const SizedBox(height: 8), Text( swap.status == 'admin_review' ? 'Awaiting admin approval' : 'Pending', style: Theme.of(context) .textTheme .labelSmall, ), const SizedBox(height: 12), if (isRecipient && swap.status == 'pending') ...[ Row( mainAxisAlignment: MainAxisAlignment.end, children: [ OutlinedButton( onPressed: () => _respondSwap( context, ref, swap.id, 'rejected'), child: const Text('Reject'), ), const SizedBox(width: 8), FilledButton( onPressed: () => _respondSwap( context, ref, swap.id, 'accepted'), child: const Text('Accept'), ), ], ), ] else if (isRequester && swap.status == 'pending') ...[ Align( alignment: Alignment.centerRight, child: OutlinedButton( onPressed: () => _respondSwap( context, ref, swap.id, 'admin_review'), child: const Text('Escalate'), ), ), ], ], ), ), ), ); } // ── Multi-swap collapsible group card ── final firstSwap = group.first; final isRecipient = firstSwap.recipientId == currentUserId; final isRequester = firstSwap.requesterId == currentUserId; final requesterName = profileById[firstSwap.requesterId]?.fullName ?? firstSwap.requesterId; final recipientName = profileById[firstSwap.recipientId]?.fullName ?? firstSwap.recipientId; final dates = group .map((s) => s.shiftStartTime) .whereType() .toList() ..sort(); final dateLabel = dates.isEmpty ? '' : dates.length == 1 ? AppTime.formatDate(dates.first) : '${AppTime.formatDate(dates.first)} – ${AppTime.formatDate(dates.last)}'; final hasPending = group.any((s) => s.status == 'pending'); final isExpanded = _expandedGroupKeys.contains(groupKey); final statusLabel = group.every( (s) => s.status == 'admin_review') ? 'Awaiting admin approval' : hasPending ? 'Pending' : 'Awaiting admin approval'; return Padding( padding: const EdgeInsets.symmetric( horizontal: 16), child: Card( clipBehavior: Clip.antiAlias, child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ // ─ Tappable header ─ InkWell( onTap: () => setState(() { if (isExpanded) { _expandedGroupKeys.remove(groupKey); } else { _expandedGroupKeys.add(groupKey); } }), child: Padding( padding: const EdgeInsets.fromLTRB( 12, 12, 12, 8), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( '$requesterName → $recipientName', style: Theme.of(context) .textTheme .titleSmall ?.copyWith( fontWeight: FontWeight.w700, ), ), const SizedBox(height: 2), Text( '${group.length} shifts${dateLabel.isNotEmpty ? ' · $dateLabel' : ''}', style: Theme.of(context) .textTheme .bodySmall, ), const SizedBox(height: 4), Text( statusLabel, style: Theme.of(context) .textTheme .labelSmall, ), ], ), ), Icon( isExpanded ? Icons.expand_less : Icons.expand_more, size: 20, ), ], ), ), ), // ─ Batch action buttons ─ if (isRecipient && hasPending) ...[ Padding( padding: const EdgeInsets.fromLTRB( 12, 0, 12, 12), child: Row( mainAxisAlignment: MainAxisAlignment.end, children: [ OutlinedButton( onPressed: () => _respondToGroup( context, ref, group, 'rejected'), child: const Text('Reject All'), ), const SizedBox(width: 8), FilledButton( onPressed: () => _respondToGroup( context, ref, group, 'accepted'), child: const Text('Accept All'), ), ], ), ), ] else if (isRequester && hasPending) ...[ Padding( padding: const EdgeInsets.fromLTRB( 12, 0, 12, 12), child: Align( alignment: Alignment.centerRight, child: OutlinedButton( onPressed: () => _respondToGroup( context, ref, group, 'admin_review'), child: const Text('Escalate All'), ), ), ), ], // ─ Expanded individual rows ─ if (isExpanded) ...[ const Divider(height: 1), ...group.map((swap) { final rowIsRecipient = swap.recipientId == currentUserId; final rowIsRequester = swap.requesterId == currentUserId; return Padding( padding: const EdgeInsets.fromLTRB( 12, 8, 12, 8), child: Row( children: [ Expanded( child: Text( '${_shiftLabel(swap.shiftType ?? 'normal', rotationConfig)} · ${AppTime.formatDate(swap.shiftStartTime ?? DateTime.now())} · ${AppTime.formatTime(swap.shiftStartTime ?? DateTime.now())}', style: Theme.of(context) .textTheme .bodySmall, ), ), if (rowIsRecipient && swap.status == 'pending') ...[ const SizedBox(width: 8), OutlinedButton( style: OutlinedButton .styleFrom( padding: const EdgeInsets .symmetric( horizontal: 10), visualDensity: VisualDensity.compact, ), onPressed: () => _respondSwap( context, ref, swap.id, 'rejected'), child: const Text('Reject'), ), const SizedBox(width: 6), FilledButton( style: FilledButton.styleFrom( padding: const EdgeInsets .symmetric( horizontal: 10), visualDensity: VisualDensity.compact, ), onPressed: () => _respondSwap( context, ref, swap.id, 'accepted'), child: const Text('Accept'), ), ] else if (rowIsRequester && swap.status == 'pending') ...[ const SizedBox(width: 8), OutlinedButton( style: OutlinedButton .styleFrom( padding: const EdgeInsets .symmetric( horizontal: 10), visualDensity: VisualDensity.compact, ), onPressed: () => _respondSwap( context, ref, swap.id, 'admin_review'), child: const Text( 'Escalate'), ), ], ], ), ); }), ], ], ), ), ); }, ); }), ], const SliverToBoxAdapter(child: SizedBox(height: 16)), ], ); }, loading: () => const Center(child: CircularProgressIndicator()), error: (error, _) => AppErrorView( error: error, onRetry: () => ref.invalidate(dutySchedulesProvider), ), ), ), ], ); } String _shiftLabel(String value, RotationConfig? config) { final configured = config?.shiftTypes.firstWhere( (s) => s.id == value, orElse: () => ShiftTypeConfig( id: value, label: value, startHour: 0, startMinute: 0, durationMinutes: 0, ), ); if (configured != null && configured.label.isNotEmpty) { return configured.label; } switch (value) { case 'am': return 'AM Duty'; case 'pm': return 'PM Duty'; case 'on_call': return 'On Call'; case 'normal': return 'Normal'; case 'weekend': return 'Weekend'; default: return value; } } void _showSwapDialog( BuildContext context, WidgetRef ref, DutySchedule schedule, Map profileById, ) { final isMobile = MediaQuery.sizeOf(context).width < 600; if (isMobile) { showModalBottomSheet( context: context, isScrollControlled: true, useSafeArea: true, builder: (_) => DraggableScrollableSheet( initialChildSize: 0.7, minChildSize: 0.4, maxChildSize: 0.95, expand: false, builder: (ctx, scrollController) => _SwapRequestSheet( schedule: schedule, profileById: profileById, scrollController: scrollController, ), ), ); } else { m3ShowDialog( context: context, builder: (_) => _SwapRequestSheet( schedule: schedule, profileById: profileById, ), ); } } Future _respondSwap( BuildContext context, WidgetRef ref, String swapId, String action, ) async { // Capture swap + profile data before the async call so we have it // regardless of what the provider state is afterward. final swap = ref .read(swapRequestsProvider) .valueOrNull ?.where((s) => s.id == swapId) .firstOrNull; final profilesAsync = ref.read(profilesProvider).valueOrNull ?? []; final profileById = {for (final p in profilesAsync) p.id: p}; try { await ref .read(workforceControllerProvider) .respondSwap(swapId: swapId, action: action); // Hide the swap card immediately — don't wait for the stream to re-emit. ref.read(locallyRemovedSwapIdsProvider.notifier).update((s) => {...s, swapId}); // Client-side orphan removal: when a swap is accepted the DB auto-rejects // all other pending swaps for the same schedules. Mirror that here so the // UI catches up instantly without waiting for the next stream emission. if (action == 'accepted' && swap != null) { final allSwaps = ref.read(swapRequestsProvider).valueOrNull ?? []; final orphanIds = allSwaps .where((s) => s.id != swapId && (s.requesterScheduleId == swap.requesterScheduleId || s.requesterScheduleId == swap.targetScheduleId || (swap.targetScheduleId != null && s.targetScheduleId == swap.requesterScheduleId) || (swap.targetScheduleId != null && s.targetScheduleId == swap.targetScheduleId))) .map((s) => s.id) .toSet(); if (orphanIds.isNotEmpty) { ref .read(locallyRemovedSwapIdsProvider.notifier) .update((s) => {...s, ...orphanIds}); } } // Send push notifications if we have the swap details if (swap != null) { final notificationsController = ref.read(notificationsControllerProvider); final recipientName = profileById[swap.recipientId]?.fullName ?? 'Someone'; final shiftDate = swap.shiftStartTime != null ? AppTime.formatDate(swap.shiftStartTime!) : 'your shift'; if (action == 'accepted') { await notificationsController.sendPush( userIds: [swap.requesterId], title: 'Swap accepted', body: '$recipientName accepted your swap request for $shiftDate.', data: {'type': 'swap_update', 'navigate_to': '/attendance'}, ); } else if (action == 'rejected') { await notificationsController.sendPush( userIds: [swap.requesterId], title: 'Swap rejected', body: '$recipientName rejected your swap request for $shiftDate.', data: {'type': 'swap_update', 'navigate_to': '/attendance'}, ); } else if (action == 'admin_review') { final adminIds = profilesAsync .where((p) => p.role == 'admin' || p.role == 'programmer' || p.role == 'dispatcher') .map((p) => p.id) .toList(); final requesterName = profileById[swap.requesterId]?.fullName ?? 'Someone'; await notificationsController.sendPush( userIds: adminIds, title: 'Swap escalated for review', body: '$requesterName escalated a swap request for $shiftDate.', data: {'type': 'swap_request', 'navigate_to': '/workforce'}, ); } } if (!context.mounted) return; showSuccessSnackBar(context, 'Swap ${action.replaceAll('_', ' ')}.'); } catch (e) { // If the swap was already processed (ownership changed = already swapped, // or not found = deleted), hide the card silently rather than surfacing // a confusing error. The finally block below will refresh the stream. final alreadyProcessed = e.toString().contains('ownership') || e.toString().contains('not found') || e.toString().contains('already'); if (alreadyProcessed) { ref.read(locallyRemovedSwapIdsProvider.notifier).update((s) => {...s, swapId}); } if (!context.mounted) return; if (!alreadyProcessed) { showErrorSnackBar(context, 'Failed: $e'); } } finally { // Refresh the swap list — the new provider fires an immediate REST poll // so any device sees the latest status within milliseconds. ref.invalidate(swapRequestsProvider); ref.invalidate(dutySchedulesProvider); } } /// Batch-respond to a group of swap requests (Accept All / Reject All / /// Escalate All). Optimistically removes the whole group from the UI, then /// fires RPC calls sequentially (already-processed entries are silently /// skipped thanks to the idempotency guard in the DB function). Future _respondToGroup( BuildContext context, WidgetRef ref, List group, String action, ) async { // 1. Optimistic removal of the entire group. ref.read(locallyRemovedSwapIdsProvider.notifier).update( (s) => {...s, ...group.map((sw) => sw.id)}, ); final profilesAsync = ref.read(profilesProvider).valueOrNull ?? []; final profileById = {for (final p in profilesAsync) p.id: p}; int succeeded = 0; for (final swap in group) { try { await ref .read(workforceControllerProvider) .respondSwap(swapId: swap.id, action: action); succeeded++; } catch (_) { // Already processed or ownership changed — skip silently. } } // 2. One combined push notification for the batch. if (succeeded > 0 && group.isNotEmpty) { final firstSwap = group.first; final dates = group .map((sw) => sw.shiftStartTime) .whereType() .toList() ..sort(); final dateLabel = dates.isEmpty ? 'your shifts' : dates.length == 1 ? AppTime.formatDate(dates.first) : '${AppTime.formatDate(dates.first)} – ${AppTime.formatDate(dates.last)}'; final notificationsController = ref.read(notificationsControllerProvider); final recipientName = profileById[firstSwap.recipientId]?.fullName ?? 'Someone'; if (action == 'accepted') { await notificationsController.sendPush( userIds: [firstSwap.requesterId], title: 'Swaps accepted', body: '$recipientName accepted your swap request for $dateLabel.', data: {'type': 'swap_update', 'navigate_to': '/attendance'}, ); } else if (action == 'rejected') { await notificationsController.sendPush( userIds: [firstSwap.requesterId], title: 'Swaps rejected', body: '$recipientName rejected your swap request for $dateLabel.', data: {'type': 'swap_update', 'navigate_to': '/attendance'}, ); } else if (action == 'admin_review') { final adminIds = profilesAsync .where((p) => p.role == 'admin' || p.role == 'programmer' || p.role == 'dispatcher') .map((p) => p.id) .toList(); final requesterName = profileById[firstSwap.requesterId]?.fullName ?? 'Someone'; await notificationsController.sendPush( userIds: adminIds, title: 'Swaps escalated for review', body: '$requesterName escalated swap requests for $dateLabel.', data: {'type': 'swap_request', 'navigate_to': '/workforce'}, ); } } ref.invalidate(swapRequestsProvider); ref.invalidate(dutySchedulesProvider); if (!context.mounted) return; showSuccessSnackBar( context, '${group.length} swap${group.length == 1 ? '' : 's'} ${action.replaceAll('_', ' ')}.', ); } } // ──────────────────────────────────────────────── // Swap Request Sheet for My Schedule Tab // ──────────────────────────────────────────────── class _SwapRequestSheet extends ConsumerStatefulWidget { const _SwapRequestSheet({ required this.schedule, required this.profileById, this.scrollController, }); final DutySchedule schedule; final Map profileById; final ScrollController? scrollController; @override ConsumerState<_SwapRequestSheet> createState() => _SwapRequestSheetState(); } enum _SwapType { singleDay, wholeWeek, dateRange } class _SwapRequestSheetState extends ConsumerState<_SwapRequestSheet> { late _SwapType swapType = _SwapType.singleDay; String? selectedRecipientId; DateTimeRange? selectedDateRange; // 1 Day mode: requester and recipient may pick DIFFERENT dates. DateTime? selectedRequesterDay; DateTime? selectedRecipientDay; DateTimeRange? selectedWeek; bool _submitting = false; @override void initState() { super.initState(); selectedRequesterDay = widget.schedule.startTime; selectedRecipientDay = widget.schedule.startTime; } /// Returns the requester's non-overtime/non-on_call schedule on [day], or /// null if no schedule exists for that date. DutySchedule? _scheduleOnDay(List schedules, String userId, DateTime day) { return schedules.where((s) => s.userId == userId && s.shiftType != 'overtime' && s.shiftType != 'on_call' && s.startTime.year == day.year && s.startTime.month == day.month && s.startTime.day == day.day, ).firstOrNull; } List _buildUpcomingWeeks(List mySchedules) { final today = DateTime.now(); final todayNormalized = DateTime(today.year, today.month, today.day); // Compute unique weeks from schedules that are today or in the future final weekStarts = {}; for (final s in mySchedules) { // Only include schedules that start today or later final scheduleDate = DateTime(s.startTime.year, s.startTime.month, s.startTime.day); if (scheduleDate.isBefore(todayNormalized)) { continue; // Skip past schedules } final startDate = s.startTime; final mondayOffset = startDate.weekday - 1; final monday = startDate.subtract(Duration(days: mondayOffset)); final mondayNormalized = DateTime(monday.year, monday.month, monday.day); // Only add if the Monday is today or in the future if (!mondayNormalized.isBefore(todayNormalized)) { weekStarts.add(mondayNormalized); } } // Convert to DateTimeRange and sort final result = weekStarts .map((monday) => DateTimeRange( start: monday, end: monday.add(const Duration(days: 6)), )) .toList() ..sort((a, b) => a.start.compareTo(b.start)); return result; } DateTimeRange _getEffectiveDateRange() { switch (swapType) { case _SwapType.singleDay: // For single-day mode the date range is used only by the recipient // schedule preview (not by _submitSwapRequest, which uses the two // day fields directly). final day = selectedRequesterDay ?? widget.schedule.startTime; return DateTimeRange(start: day, end: day); case _SwapType.wholeWeek: return selectedWeek ?? DateTimeRange( start: widget.schedule.startTime, end: widget.schedule.startTime.add(const Duration(days: 6)), ); case _SwapType.dateRange: return selectedDateRange ?? DateTimeRange( start: widget.schedule.startTime, end: widget.schedule.startTime.add(const Duration(days: 7)), ); } } @override Widget build(BuildContext context) { final theme = Theme.of(context); final profilesAsync = ref.watch(profilesProvider); final mySchedulesAsync = ref.watch(dutySchedulesProvider); final currentUserId = ref.read(currentUserIdProvider); // Get IT staff profiles for recipient dropdown final itStaff = (profilesAsync.valueOrNull ?? []) .where((p) => p.role == 'it_staff' && p.id != currentUserId) .toList() ..sort((a, b) => a.fullName.compareTo(b.fullName)); final mySchedules = mySchedulesAsync.valueOrNull ?? []; final upcomingWeeks = _buildUpcomingWeeks( mySchedules.where((s) => s.userId == currentUserId).toList(), ); final effectiveDateRange = _getEffectiveDateRange(); final isBottomSheet = widget.scrollController != null; final dialogShape = isBottomSheet ? null : AppSurfaces.of(context).dialogShape; Widget content = SingleChildScrollView( controller: widget.scrollController, child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ // Swap Type Selection Text( 'Swap Type', style: theme.textTheme.labelMedium, ), const SizedBox(height: 8), SegmentedButton<_SwapType>( segments: const [ ButtonSegment<_SwapType>( value: _SwapType.singleDay, label: Text('1 Day'), ), ButtonSegment<_SwapType>( value: _SwapType.wholeWeek, label: Text('Week'), ), ButtonSegment<_SwapType>( value: _SwapType.dateRange, label: Text('Range'), ), ], selected: {swapType}, onSelectionChanged: (Set<_SwapType> newSelection) { setState(() => swapType = newSelection.first); }, ), const SizedBox(height: 16), // Date Selection UI based on swap type Text( 'Select Date', style: theme.textTheme.labelMedium, ), const SizedBox(height: 8), if (swapType == _SwapType.singleDay) ...[ // ── "Your shift" date picker ────────────────────────────────── _DatePickerRow( label: 'Your shift', date: selectedRequesterDay ?? widget.schedule.startTime, schedule: _scheduleOnDay( mySchedules, currentUserId ?? '', selectedRequesterDay ?? widget.schedule.startTime, ), onPick: () async { final picked = await showDatePicker( context: context, initialDate: selectedRequesterDay ?? widget.schedule.startTime, firstDate: DateTime.now(), lastDate: DateTime.now().add(const Duration(days: 90)), ); if (picked != null) setState(() => selectedRequesterDay = picked); }, ), const SizedBox(height: 8), // ── "Their shift" date picker ───────────────────────────────── _DatePickerRow( label: 'Their shift', date: selectedRecipientDay ?? widget.schedule.startTime, schedule: selectedRecipientId == null ? null : _scheduleOnDay( mySchedules, selectedRecipientId!, selectedRecipientDay ?? widget.schedule.startTime, ), noScheduleLabel: selectedRecipientId == null ? 'Select a recipient first' : null, onPick: () async { final picked = await showDatePicker( context: context, initialDate: selectedRecipientDay ?? widget.schedule.startTime, firstDate: DateTime.now(), lastDate: DateTime.now().add(const Duration(days: 90)), ); if (picked != null) setState(() => selectedRecipientDay = picked); }, ), ] else if (swapType == _SwapType.wholeWeek) ...[ if (upcomingWeeks.isEmpty) Text( 'No upcoming weeks found', style: theme.textTheme.bodySmall, ) else Column( children: [ for (final week in upcomingWeeks) RadioListTile( title: Text( '${AppTime.formatDate(week.start)} – ${AppTime.formatDate(week.end)}', ), value: week, groupValue: selectedWeek, // ignore: deprecated_member_use onChanged: (value) { // ignore: deprecated_member_use if (value != null) { setState(() => selectedWeek = value); } }, ), ], ), ] else if (swapType == _SwapType.dateRange) ...[ Text( '${AppTime.formatDate(effectiveDateRange.start)} – ${AppTime.formatDate(effectiveDateRange.end)}', style: theme.textTheme.bodySmall, ), const SizedBox(height: 8), OutlinedButton( onPressed: () async { final picked = await showDateRangePicker( context: context, firstDate: widget.schedule.startTime, lastDate: widget.schedule.startTime.add(const Duration(days: 90)), initialDateRange: effectiveDateRange, ); if (picked != null) { setState(() => selectedDateRange = picked); } }, child: const Text('Change Range'), ), ], const SizedBox(height: 16), // Recipient Selection Text( 'Select Recipient', style: theme.textTheme.labelMedium, ), const SizedBox(height: 8), DropdownButtonFormField( initialValue: selectedRecipientId, items: [ for (final profile in itStaff) DropdownMenuItem( value: profile.id, child: Text(profile.fullName), ), ], onChanged: (value) => setState(() => selectedRecipientId = value), decoration: const InputDecoration( labelText: 'Select staff member', ), ), const SizedBox(height: 16), // Recipient's schedule preview (week/range modes only — 1 Day shows // the preview inline in the _DatePickerRow widgets above). if (swapType != _SwapType.singleDay && selectedRecipientId != null) ...[ Text( "Recipient's schedule", style: theme.textTheme.labelMedium, ), const SizedBox(height: 8), ref .watch(dutySchedulesForUserProvider(selectedRecipientId!)) .when( data: (shifts) { final inRange = shifts.where((s) { final sStart = s.startTime; final rangeStart = effectiveDateRange.start; final rangeEnd = effectiveDateRange.end.add(const Duration(days: 1)); return !sStart.isBefore(rangeStart) && sStart.isBefore(rangeEnd); }).toList(); if (inRange.isEmpty) { return Text( 'No schedules in this range', style: theme.textTheme.bodySmall, ); } return Column( children: [ for (final s in inRange) ListTile( dense: true, leading: const Icon(Icons.schedule, size: 20), title: Text(s.shiftType), subtitle: Text( '${AppTime.formatDate(s.startTime)} · ${AppTime.formatTime(s.startTime)}–${AppTime.formatTime(s.endTime)}', ), ), ], ); }, loading: () => const LinearProgressIndicator(), error: (e, st) => Text( 'Could not load schedule', style: theme.textTheme.bodySmall, ), ), ], const SizedBox(height: 16), ], ), ); // For "1 Day" mode, disable submit if either schedule is missing. final singleDayReady = swapType != _SwapType.singleDay || (selectedRecipientId != null && _scheduleOnDay( mySchedules, currentUserId ?? '', selectedRequesterDay ?? widget.schedule.startTime, ) != null && _scheduleOnDay( mySchedules, selectedRecipientId!, selectedRecipientDay ?? widget.schedule.startTime, ) != null); // Action buttons final actions = [ TextButton( onPressed: _submitting ? null : () => Navigator.pop(context), child: const Text('Cancel'), ), FilledButton( onPressed: _submitting || selectedRecipientId == null || !singleDayReady ? null : () => _submitSwapRequest(context, effectiveDateRange), child: const Text('Send Request'), ), ]; if (isBottomSheet) { return Scaffold( appBar: AppBar( title: const Text('Request Shift Swap'), leading: const SizedBox(), ), body: content, bottomNavigationBar: Padding( padding: const EdgeInsets.all(16), child: Row( mainAxisAlignment: MainAxisAlignment.end, children: actions, ), ), ); } else { return AlertDialog( shape: dialogShape, title: const Text('Request Shift Swap'), content: content, actions: actions, ); } } Future _submitSwapRequest( BuildContext context, DateTimeRange dateRange, ) async { if (selectedRecipientId == null) return; setState(() => _submitting = true); try { // Get all schedules for the requester (current user) in the date range final currentUserId = ref.read(currentUserIdProvider); final allSchedules = ref.read(dutySchedulesProvider).valueOrNull ?? []; // Collect all matching requester↔recipient schedule pairs. final profilesAsync = ref.read(profilesProvider).valueOrNull ?? []; final profileById = {for (final p in profilesAsync) p.id: p}; final requesterName = profileById[currentUserId]?.fullName ?? 'Someone'; final notificationsController = ref.read(notificationsControllerProvider); final matchedPairs = <({DutySchedule requester, DutySchedule recipient})>[]; if (swapType == _SwapType.singleDay) { // Cross-date single-day swap: requester and recipient may pick // DIFFERENT calendar days. Look them up independently. final reqDay = selectedRequesterDay ?? widget.schedule.startTime; final recDay = selectedRecipientDay ?? widget.schedule.startTime; final reqSchedule = _scheduleOnDay(allSchedules, currentUserId ?? '', reqDay); final recSchedule = _scheduleOnDay(allSchedules, selectedRecipientId!, recDay); if (reqSchedule == null || recSchedule == null) { if (mounted) { showErrorSnackBar( context, reqSchedule == null ? 'You have no schedule on ${AppTime.formatDate(reqDay)}.' : 'Recipient has no schedule on ${AppTime.formatDate(recDay)}.', ); } return; } matchedPairs.add((requester: reqSchedule, recipient: recSchedule)); } else { // Week / date-range mode: pair schedules that fall on the same // calendar day within the selected range. // on_call is excluded — it travels with PM automatically via DB. final requesterSchedulesInRange = allSchedules .where((s) => s.userId == currentUserId && s.shiftType != 'overtime' && s.shiftType != 'on_call' && !s.startTime.isBefore(dateRange.start) && !s.endTime.isAfter(dateRange.end.add(const Duration(days: 1)))) .toList() ..sort((a, b) => a.startTime.compareTo(b.startTime)); final recipientSchedulesInRange = allSchedules .where((s) => s.userId == selectedRecipientId && s.shiftType != 'overtime' && s.shiftType != 'on_call' && !s.startTime.isBefore(dateRange.start) && !s.endTime.isAfter(dateRange.end.add(const Duration(days: 1)))) .toList() ..sort((a, b) => a.startTime.compareTo(b.startTime)); for (final requesterSchedule in requesterSchedulesInRange) { final matching = recipientSchedulesInRange.where( (s) => s.startTime.year == requesterSchedule.startTime.year && s.startTime.month == requesterSchedule.startTime.month && s.startTime.day == requesterSchedule.startTime.day, ); if (matching.isNotEmpty) { matchedPairs .add((requester: requesterSchedule, recipient: matching.first)); } } } // Submit all swap requests for (final pair in matchedPairs) { await ref.read(workforceControllerProvider).requestSwap( requesterScheduleId: pair.requester.id, targetScheduleId: pair.recipient.id, recipientId: selectedRecipientId!, ); } // Send ONE push notification summarising all matched dates if (matchedPairs.isNotEmpty) { final dates = matchedPairs.map((p) => p.recipient.startTime).toList()..sort(); final dateLabel = dates.length == 1 ? AppTime.formatDate(dates.first) : '${AppTime.formatDate(dates.first)} – ${AppTime.formatDate(dates.last)}'; await notificationsController.sendPush( userIds: [selectedRecipientId!], title: 'Shift swap request', body: '$requesterName wants to swap shifts on $dateLabel.', data: {'type': 'swap_request', 'navigate_to': '/attendance'}, ); } final swapCount = matchedPairs.length; ref.invalidate(swapRequestsProvider); if (!context.mounted) return; Navigator.pop(context); showSuccessSnackBar( context, swapCount == 1 ? 'Swap request sent!' : '$swapCount swap requests sent!', ); } catch (e) { if (!context.mounted) return; showErrorSnackBar(context, 'Failed to send swap request: $e'); } finally { if (mounted) { setState(() => _submitting = false); } } } } // ──────────────────────────────────────────────── // Tab 4 – 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) ...[ Builder(builder: (context) { final hasStarted = activeSlip.slipStart == null || AppTime.now().isAfter(activeSlip.slipStart!); final Color cardColor; final Color onCardColor; final IconData cardIcon; final String cardTitle; if (activeSlip.isExceeded) { cardColor = colors.errorContainer; onCardColor = colors.onErrorContainer; cardIcon = Icons.warning; cardTitle = 'Pass Slip Exceeded (>1 hour)'; } else if (!hasStarted) { cardColor = colors.primaryContainer; onCardColor = colors.onPrimaryContainer; cardIcon = Icons.schedule; cardTitle = 'Pass Slip Approved — Starts at ${AppTime.formatTime(activeSlip.slipStart!)}'; } else { cardColor = colors.tertiaryContainer; onCardColor = colors.onTertiaryContainer; cardIcon = Icons.directions_walk; cardTitle = 'Active Pass Slip'; } return Card( color: cardColor, child: Padding( padding: const EdgeInsets.all(16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Icon(cardIcon, color: onCardColor), const SizedBox(width: 8), Expanded( child: Text( cardTitle, style: theme.textTheme.titleSmall?.copyWith( fontWeight: FontWeight.w600, color: onCardColor, ), ), ), ], ), const SizedBox(height: 8), Text( 'Reason: ${activeSlip.reason}', style: theme.textTheme.bodyMedium, ), if (activeSlip.slipStart != null && hasStarted) 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 Padding( padding: const EdgeInsets.only(bottom: 8), child: 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.requestedStart != null) Text( 'Preferred start: ${AppTime.formatTime(slip.requestedStart!)}', 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 Padding( padding: const EdgeInsets.only(bottom: 8), child: 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), M3Fab( heroTag: heroTag, onPressed: onTap, icon: Icon(icon), small: true, backgroundColor: color, foregroundColor: onColor, ), ], ); } } // ─── 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; TimeOfDay? _requestedStartTime; @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 { DateTime? requestedStart; if (_requestedStartTime != null) { final now = AppTime.now(); requestedStart = AppTime.fromComponents( year: now.year, month: now.month, day: now.day, hour: _requestedStartTime!.hour, minute: _requestedStartTime!.minute, ); } await ref.read(passSlipControllerProvider).requestSlip( dutyScheduleId: widget.scheduleId, reason: reason, requestedStart: requestedStart, ); 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: 16), // Optional start time picker InkWell( borderRadius: BorderRadius.circular(12), onTap: _submitting ? null : () async { final picked = await showTimePicker( context: context, initialTime: _requestedStartTime ?? TimeOfDay.now(), ); if (picked != null && mounted) { setState(() => _requestedStartTime = picked); } }, child: InputDecorator( decoration: InputDecoration( labelText: 'Preferred start time (optional)', border: OutlineInputBorder( borderRadius: BorderRadius.circular(12), ), prefixIcon: const Icon(Icons.schedule), suffixIcon: _requestedStartTime != null ? IconButton( icon: const Icon(Icons.clear), onPressed: _submitting ? null : () => setState( () => _requestedStartTime = null), tooltip: 'Clear', ) : null, ), child: Text( _requestedStartTime != null ? _requestedStartTime!.format(context) : 'Immediately upon approval', style: theme.textTheme.bodyLarge?.copyWith( color: _requestedStartTime != null ? null : theme.colorScheme.onSurfaceVariant, ), ), ), ), 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'), ), ], ), ], ), ), ), ), ); } } // ──────────────────────────────────────────────────────────────────────────── // Helper widget: single date picker row with a live schedule preview chip. // Used inside the "1 Day" mode of _SwapRequestSheetState. // ──────────────────────────────────────────────────────────────────────────── class _DatePickerRow extends StatelessWidget { const _DatePickerRow({ required this.label, required this.date, required this.onPick, this.schedule, this.noScheduleLabel, }); final String label; final DateTime date; final VoidCallback onPick; /// The resolved schedule for [date], or null if not found. final DutySchedule? schedule; /// Override message shown when no schedule found (e.g. "Select a recipient first"). final String? noScheduleLabel; @override Widget build(BuildContext context) { final theme = Theme.of(context); final colors = theme.colorScheme; final hasSchedule = schedule != null; return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(label, style: theme.textTheme.labelSmall), const SizedBox(height: 2), Text( AppTime.formatDate(date), style: theme.textTheme.bodyMedium, ), ], ), ), OutlinedButton( onPressed: onPick, child: const Text('Pick Date'), ), ], ), const SizedBox(height: 4), if (hasSchedule) Row( children: [ Icon(Icons.schedule, size: 14, color: colors.primary), const SizedBox(width: 4), Flexible( child: Text( '${_shiftDisplayName(schedule!.shiftType)} · ' '${AppTime.formatTime(schedule!.startTime)} – ' '${AppTime.formatTime(schedule!.endTime)}', style: theme.textTheme.bodySmall ?.copyWith(color: colors.primary), ), ), ], ) else Row( children: [ Icon(Icons.warning_amber_rounded, size: 14, color: colors.error), const SizedBox(width: 4), Flexible( child: Text( noScheduleLabel ?? 'No schedule on this date', style: theme.textTheme.bodySmall ?.copyWith(color: colors.error), ), ), ], ), ], ); } static String _shiftDisplayName(String type) { switch (type) { case 'normal': return 'Normal'; case 'am': return 'AM'; case 'pm': return 'PM'; case 'on_call': return 'On Call'; case 'weekend': return 'Weekend'; case 'overtime': return 'Overtime'; case 'on_call_saturday': return 'On Call (Sat)'; case 'on_call_sunday': return 'On Call (Sun)'; default: return type; } } }