import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:geolocator/geolocator.dart'; import '../../models/attendance_log.dart'; import '../../models/duty_schedule.dart'; import '../../models/pass_slip.dart'; import '../../models/profile.dart'; import '../../providers/attendance_provider.dart'; import '../../providers/notifications_provider.dart'; import '../../providers/pass_slip_provider.dart'; import '../../providers/profile_provider.dart'; import '../../providers/reports_provider.dart'; import '../../providers/whereabouts_provider.dart'; import '../../providers/workforce_provider.dart'; import '../../theme/m3_motion.dart'; import '../../utils/app_time.dart'; import '../../widgets/responsive_body.dart'; class AttendanceScreen extends ConsumerStatefulWidget { const AttendanceScreen({super.key}); @override ConsumerState createState() => _AttendanceScreenState(); } class _AttendanceScreenState extends ConsumerState with SingleTickerProviderStateMixin { late TabController _tabController; @override void initState() { super.initState(); _tabController = TabController(length: 3, vsync: this); } @override void dispose() { _tabController.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final theme = Theme.of(context); return ResponsiveBody( child: Column( children: [ Padding( padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), child: Row( children: [ Expanded( child: Text( 'Attendance', style: theme.textTheme.titleLarge?.copyWith( fontWeight: FontWeight.w700, ), ), ), ], ), ), TabBar( controller: _tabController, tabs: const [ Tab(text: 'Check In'), Tab(text: 'Logbook'), Tab(text: 'Pass Slip'), ], ), Expanded( child: TabBarView( controller: _tabController, children: const [_CheckInTab(), _LogbookTab(), _PassSlipTab()], ), ), ], ), ); } } // ──────────────────────────────────────────────── // 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; @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; if (profile == null) { return const Center(child: CircularProgressIndicator()); } final now = AppTime.now(); final today = DateTime(now.year, now.month, now.day); // Find today's schedule for the current user final schedules = schedulesAsync.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; }).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(); 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: allowTracking, onChanged: (v) => ref.read(whereaboutsControllerProvider).setTracking(v), ), ], ), ), ), const SizedBox(height: 16), // Today's schedule Text( "Today's Schedule", style: theme.textTheme.titleMedium?.copyWith( fontWeight: FontWeight.w600, ), ), const SizedBox(height: 8), if (todaySchedule.isEmpty) Card( child: Padding( padding: const EdgeInsets.all(24), child: Center( child: Text( 'No schedule assigned for today.', style: theme.textTheme.bodyMedium?.copyWith( color: colors.onSurfaceVariant, ), ), ), ), ) else ...todaySchedule.map((schedule) { final hasCheckedIn = schedule.checkInAt != null; final isActive = activeLog.any( (l) => l.dutyScheduleId == schedule.id, ); final completedLog = logs .where( (l) => l.dutyScheduleId == schedule.id && l.isCheckedOut, ) .toList(); final isCompleted = completedLog.isNotEmpty; return Card( child: Padding( padding: const EdgeInsets.all(16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Icon(Icons.schedule, size: 20, color: colors.primary), const SizedBox(width: 8), Expanded( child: Text( _shiftLabel(schedule.shiftType), style: theme.textTheme.titleSmall?.copyWith( fontWeight: FontWeight.w600, ), ), ), _statusChip( context, isCompleted ? 'Completed' : isActive ? 'On Duty' : hasCheckedIn ? 'Checked In' : 'Scheduled', ), ], ), const SizedBox(height: 8), Text( '${AppTime.formatTime(schedule.startTime)} – ${AppTime.formatTime(schedule.endTime)}', style: theme.textTheme.bodyMedium, ), const SizedBox(height: 12), if (!hasCheckedIn && !isCompleted) SizedBox( width: double.infinity, child: FilledButton.icon( onPressed: _loading ? null : () => _handleCheckIn(schedule), icon: _loading ? const SizedBox( width: 16, height: 16, child: CircularProgressIndicator( strokeWidth: 2, ), ) : const Icon(Icons.login), label: const Text('Check In'), ), ) else if (isActive) SizedBox( width: double.infinity, child: FilledButton.tonalIcon( onPressed: _loading ? null : () => _handleCheckOut( activeLog.firstWhere( (l) => l.dutyScheduleId == schedule.id, ), ), icon: _loading ? const SizedBox( width: 16, height: 16, child: CircularProgressIndicator( strokeWidth: 2, ), ) : const Icon(Icons.logout), label: const Text('Check Out'), ), ) else if (isCompleted) Row( children: [ Icon( Icons.check_circle, size: 16, color: colors.primary, ), const SizedBox(width: 6), Expanded( child: Text( 'Checked out at ${AppTime.formatTime(completedLog.first.checkOutAt!)}', style: theme.textTheme.bodySmall?.copyWith( color: colors.onSurfaceVariant, ), ), ), ], ), ], ), ), ); }), ], ), ); } Future _handleCheckIn(DutySchedule schedule) async { setState(() => _loading = true); try { final geoCfg = await ref.read(geofenceProvider.future); final position = await Geolocator.getCurrentPosition( locationSettings: const LocationSettings( accuracy: LocationAccuracy.high, ), ); // Client-side geofence check if (geoCfg != null) { 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) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('You are outside the geofence area.')), ); return; } } await ref .read(attendanceControllerProvider) .checkIn( dutyScheduleId: schedule.id, lat: position.latitude, lng: position.longitude, ); if (mounted) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('Checked in successfully.')), ); } } catch (e) { if (mounted) { ScaffoldMessenger.of( context, ).showSnackBar(SnackBar(content: Text('Check-in failed: $e'))); } } finally { if (mounted) setState(() => _loading = false); } } Future _handleCheckOut(AttendanceLog log) async { setState(() => _loading = true); try { final position = await Geolocator.getCurrentPosition( locationSettings: const LocationSettings( accuracy: LocationAccuracy.high, ), ); await ref .read(attendanceControllerProvider) .checkOut( attendanceId: log.id, lat: position.latitude, lng: position.longitude, ); if (mounted) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('Checked out successfully.')), ); } } catch (e) { if (mounted) { ScaffoldMessenger.of( context, ).showSnackBar(SnackBar(content: Text('Check-out failed: $e'))); } } finally { if (mounted) setState(() => _loading = false); } } 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; 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'; default: return shiftType; } } } // ──────────────────────────────────────────────── // Tab 2 – Logbook // ──────────────────────────────────────────────── class _LogbookTab extends ConsumerWidget { const _LogbookTab(); @override Widget build(BuildContext context, WidgetRef ref) { 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 Map profileById = { for (final p in profilesAsync.valueOrNull ?? []) p.id: p, }; 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'), ), ], ), ), ), ), const SizedBox(height: 8), Expanded( child: logsAsync.when( data: (logs) { final filtered = logs.where((log) { return !log.checkInAt.isBefore(range.start) && log.checkInAt.isBefore(range.end); }).toList(); if (filtered.isEmpty) { return Center( child: Text( 'No attendance logs for this period.', style: theme.textTheme.bodyMedium?.copyWith( color: colors.onSurfaceVariant, ), ), ); } return LayoutBuilder( builder: (context, constraints) { if (constraints.maxWidth >= 700) { return _buildDataTable(context, filtered, profileById); } return _buildLogList(context, filtered, profileById); }, ); }, loading: () => const Center(child: CircularProgressIndicator()), error: (e, _) => Center(child: Text('Failed to load logs: $e')), ), ), ], ); } 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 _buildDataTable( BuildContext context, List logs, Map profileById, ) { return SingleChildScrollView( padding: const EdgeInsets.symmetric(horizontal: 16), child: DataTable( columns: const [ DataColumn(label: Text('Staff')), DataColumn(label: Text('Role')), DataColumn(label: Text('Date')), DataColumn(label: Text('Check In')), DataColumn(label: Text('Check Out')), DataColumn(label: Text('Duration')), DataColumn(label: Text('Status')), ], rows: logs.map((log) { final p = profileById[log.userId]; final name = p?.fullName ?? log.userId; final role = p?.role ?? '-'; final date = AppTime.formatDate(log.checkInAt); final checkIn = AppTime.formatTime(log.checkInAt); final checkOut = log.isCheckedOut ? AppTime.formatTime(log.checkOutAt!) : '—'; final duration = log.isCheckedOut ? _formatDuration(log.checkOutAt!.difference(log.checkInAt)) : 'On duty'; final status = log.isCheckedOut ? 'Completed' : 'On duty'; return DataRow( cells: [ DataCell(Text(name)), DataCell(Text(_roleLabel(role))), DataCell(Text(date)), DataCell(Text(checkIn)), DataCell(Text(checkOut)), DataCell(Text(duration)), DataCell( Text( status, style: TextStyle( color: log.isCheckedOut ? Colors.green : Colors.orange, ), ), ), ], ); }).toList(), ), ); } Widget _buildLogList( BuildContext context, List logs, Map profileById, ) { return ListView.builder( padding: const EdgeInsets.symmetric(horizontal: 16), itemCount: logs.length, itemBuilder: (context, index) { final log = logs[index]; final p = profileById[log.userId]; final name = p?.fullName ?? log.userId; final role = p?.role ?? '-'; return Card( child: ListTile( title: Text(name), subtitle: Text( '${_roleLabel(role)} · ${AppTime.formatDate(log.checkInAt)}\n' 'In: ${AppTime.formatTime(log.checkInAt)}' '${log.isCheckedOut ? " · Out: ${AppTime.formatTime(log.checkOutAt!)}" : " · On duty"}', ), isThreeLine: true, trailing: log.isCheckedOut ? Text( _formatDuration(log.checkOutAt!.difference(log.checkInAt)), style: Theme.of(context).textTheme.bodySmall, ) : Chip( label: const Text('On duty'), backgroundColor: Theme.of( context, ).colorScheme.tertiaryContainer, ), ), ); }, ); } String _formatDuration(Duration d) { final hours = d.inHours; final minutes = d.inMinutes.remainder(60); return '${hours}h ${minutes}m'; } String _roleLabel(String role) { switch (role) { case 'admin': return 'Admin'; case 'dispatcher': return 'Dispatcher'; case 'it_staff': return 'IT Staff'; default: return 'Standard'; } } } // ──────────────────────────────────────────────── // 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!, label: 'Custom', ), ); Navigator.pop(context); } : null, child: const Text('Apply'), ), ), ], ), ); } } class _Preset { const _Preset(this.label, this.start, this.end); final String label; final DateTime start; final DateTime end; } // ──────────────────────────────────────────────── // Tab 3 – Pass Slip // ──────────────────────────────────────────────── class _PassSlipTab extends ConsumerStatefulWidget { const _PassSlipTab(); @override ConsumerState<_PassSlipTab> createState() => _PassSlipTabState(); } class _PassSlipTabState extends ConsumerState<_PassSlipTab> { final _reasonController = TextEditingController(); bool _submitting = false; @override void dispose() { _reasonController.dispose(); super.dispose(); } @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 schedulesAsync = ref.watch(dutySchedulesProvider); 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()); } // Find today's schedule for passing to request form final now = AppTime.now(); final today = DateTime(now.year, now.month, now.day); final schedules = schedulesAsync.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; }).toList(); return slipsAsync.when( data: (slips) { return ListView( padding: const EdgeInsets.all(16), children: [ // Active slip banner if (activeSlip != null) ...[ Card( color: activeSlip.isExceeded ? colors.errorContainer : colors.tertiaryContainer, child: Padding( padding: const EdgeInsets.all(16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Icon( activeSlip.isExceeded ? Icons.warning : Icons.directions_walk, color: activeSlip.isExceeded ? colors.onErrorContainer : colors.onTertiaryContainer, ), const SizedBox(width: 8), Expanded( child: Text( activeSlip.isExceeded ? 'Pass Slip Exceeded (>1 hour)' : 'Active Pass Slip', style: theme.textTheme.titleSmall?.copyWith( fontWeight: FontWeight.w600, color: activeSlip.isExceeded ? colors.onErrorContainer : colors.onTertiaryContainer, ), ), ), ], ), const SizedBox(height: 8), Text( 'Reason: ${activeSlip.reason}', style: theme.textTheme.bodyMedium, ), if (activeSlip.slipStart != null) Text( 'Started: ${AppTime.formatTime(activeSlip.slipStart!)}', style: theme.textTheme.bodySmall, ), const SizedBox(height: 12), SizedBox( width: double.infinity, child: FilledButton.icon( onPressed: _submitting ? null : () => _completeSlip(activeSlip.id), icon: const Icon(Icons.check), label: const Text('Complete / Return'), ), ), ], ), ), ), const SizedBox(height: 16), ], // Request form (only for non-admin staff with a schedule today) if (!isAdmin && activeSlip == null && todaySchedule.isNotEmpty) ...[ Text( 'Request Pass Slip', style: theme.textTheme.titleMedium?.copyWith( fontWeight: FontWeight.w600, ), ), const SizedBox(height: 8), Card( child: Padding( padding: const EdgeInsets.all(16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ TextField( controller: _reasonController, decoration: const InputDecoration( labelText: 'Reason', hintText: 'Brief reason for pass slip', ), maxLines: 2, ), const SizedBox(height: 12), SizedBox( width: double.infinity, child: FilledButton.tonalIcon( onPressed: _submitting ? null : () => _requestSlip(todaySchedule.first.id), icon: _submitting ? const SizedBox( width: 16, height: 16, child: CircularProgressIndicator( strokeWidth: 2, ), ) : const Icon(Icons.send), label: const Text('Submit Request'), ), ), ], ), ), ), const SizedBox(height: 16), ], // Pending slips for admin approval if (isAdmin) ...[ Text( 'Pending Approvals', style: theme.textTheme.titleMedium?.copyWith( fontWeight: FontWeight.w600, ), ), const SizedBox(height: 8), ...slips .where((s) => s.status == 'pending') .map( (slip) => _buildSlipCard( context, slip, profileById, showActions: true, ), ), if (slips.where((s) => s.status == 'pending').isEmpty) Padding( padding: const EdgeInsets.symmetric(vertical: 8), child: Text( 'No pending pass slip requests.', style: theme.textTheme.bodyMedium?.copyWith( color: colors.onSurfaceVariant, ), ), ), const SizedBox(height: 16), ], // History Text( 'Pass Slip History', style: theme.textTheme.titleMedium?.copyWith( fontWeight: FontWeight.w600, ), ), const SizedBox(height: 8), ...slips .where((s) => s.status != 'pending' || !isAdmin) .take(50) .map( (slip) => _buildSlipCard( context, slip, profileById, showActions: false, ), ), if (slips.isEmpty) Center( child: Padding( padding: const EdgeInsets.symmetric(vertical: 24), child: Text( 'No pass slip records.', style: theme.textTheme.bodyMedium?.copyWith( color: colors.onSurfaceVariant, ), ), ), ), ], ); }, loading: () => const Center(child: CircularProgressIndicator()), error: (e, _) => Center(child: Text('Failed to load pass slips: $e')), ); } Widget _buildSlipCard( BuildContext context, PassSlip slip, Map profileById, { required bool showActions, }) { final theme = Theme.of(context); final colors = theme.colorScheme; final p = profileById[slip.userId]; final name = p?.fullName ?? slip.userId; Color statusColor; switch (slip.status) { case 'approved': statusColor = Colors.green; case 'rejected': statusColor = colors.error; case 'completed': statusColor = colors.primary; default: statusColor = Colors.orange; } return Card( child: Padding( padding: const EdgeInsets.all(12), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Expanded(child: Text(name, style: theme.textTheme.titleSmall)), Container( padding: const EdgeInsets.symmetric( horizontal: 10, vertical: 4, ), decoration: BoxDecoration( color: statusColor.withValues(alpha: 0.15), borderRadius: BorderRadius.circular(12), ), child: Text( slip.status.toUpperCase(), style: theme.textTheme.labelSmall?.copyWith( color: statusColor, fontWeight: FontWeight.w600, ), ), ), ], ), const SizedBox(height: 4), Text(slip.reason, style: theme.textTheme.bodyMedium), Text( 'Requested: ${AppTime.formatDate(slip.requestedAt)} ${AppTime.formatTime(slip.requestedAt)}', style: theme.textTheme.bodySmall?.copyWith( color: colors.onSurfaceVariant, ), ), if (slip.slipStart != null) Text( 'Started: ${AppTime.formatTime(slip.slipStart!)}' '${slip.slipEnd != null ? " · Ended: ${AppTime.formatTime(slip.slipEnd!)}" : ""}', style: theme.textTheme.bodySmall?.copyWith( color: colors.onSurfaceVariant, ), ), if (showActions && slip.status == 'pending') ...[ const SizedBox(height: 8), Row( mainAxisAlignment: MainAxisAlignment.end, children: [ TextButton( onPressed: _submitting ? null : () => _rejectSlip(slip.id), child: Text( 'Reject', style: TextStyle(color: colors.error), ), ), const SizedBox(width: 8), FilledButton( onPressed: _submitting ? null : () => _approveSlip(slip.id), child: const Text('Approve'), ), ], ), ], ], ), ), ); } Future _requestSlip(String scheduleId) async { final reason = _reasonController.text.trim(); if (reason.isEmpty) { ScaffoldMessenger.of( context, ).showSnackBar(const SnackBar(content: Text('Please enter a reason.'))); return; } setState(() => _submitting = true); try { await ref .read(passSlipControllerProvider) .requestSlip(dutyScheduleId: scheduleId, reason: reason); _reasonController.clear(); // Notify all admin users via push notification final profiles = ref.read(profilesProvider).valueOrNull ?? []; final adminIds = profiles .where((p) => p.role == 'admin') .map((p) => p.id) .toList(); final currentProfile = ref.read(currentProfileProvider).valueOrNull; final actorName = currentProfile?.fullName ?? 'A staff member'; if (adminIds.isNotEmpty && currentProfile != null) { ref .read(notificationsControllerProvider) .createNotification( userIds: adminIds, type: 'pass_slip_request', actorId: currentProfile.id, pushTitle: 'Pass Slip Request', pushBody: '$actorName requested a pass slip: $reason', ); } if (mounted) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('Pass slip request submitted.')), ); } } catch (e) { if (mounted) { ScaffoldMessenger.of( context, ).showSnackBar(SnackBar(content: Text('Failed: $e'))); } } finally { if (mounted) setState(() => _submitting = false); } } Future _approveSlip(String slipId) async { setState(() => _submitting = true); try { await ref.read(passSlipControllerProvider).approveSlip(slipId); if (mounted) { ScaffoldMessenger.of( context, ).showSnackBar(const SnackBar(content: Text('Pass slip approved.'))); } } catch (e) { if (mounted) { ScaffoldMessenger.of( context, ).showSnackBar(SnackBar(content: Text('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) { ScaffoldMessenger.of( context, ).showSnackBar(const SnackBar(content: Text('Pass slip rejected.'))); } } catch (e) { if (mounted) { ScaffoldMessenger.of( context, ).showSnackBar(SnackBar(content: Text('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) { ScaffoldMessenger.of( context, ).showSnackBar(const SnackBar(content: Text('Pass slip completed.'))); } } catch (e) { if (mounted) { ScaffoldMessenger.of( context, ).showSnackBar(SnackBar(content: Text('Failed: $e'))); } } finally { if (mounted) setState(() => _submitting = false); } } }