import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:geolocator/geolocator.dart'; import 'package:timezone/timezone.dart' as tz; import '../../models/duty_schedule.dart'; import '../../models/profile.dart'; import '../../models/swap_request.dart'; import '../../providers/profile_provider.dart'; import '../../providers/workforce_provider.dart'; import '../../utils/app_time.dart'; import '../../widgets/responsive_body.dart'; import '../../theme/app_surfaces.dart'; class WorkforceScreen extends ConsumerWidget { const WorkforceScreen({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { final profileAsync = ref.watch(currentProfileProvider); final role = profileAsync.valueOrNull?.role ?? 'standard'; final isAdmin = role == 'admin' || role == 'dispatcher'; return ResponsiveBody( child: LayoutBuilder( builder: (context, constraints) { final isWide = constraints.maxWidth >= 980; final schedulePanel = _SchedulePanel(isAdmin: isAdmin); final swapsPanel = _SwapRequestsPanel(isAdmin: isAdmin); final generatorPanel = _ScheduleGeneratorPanel(enabled: isAdmin); if (isWide) { return Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Expanded(flex: 3, child: schedulePanel), const SizedBox(width: 16), Expanded( flex: 2, child: Column( children: [ if (isAdmin) generatorPanel, if (isAdmin) const SizedBox(height: 16), Expanded(child: swapsPanel), ], ), ), ], ); } return DefaultTabController( length: isAdmin ? 3 : 2, child: Column( children: [ const SizedBox(height: 8), TabBar( tabs: [ const Tab(text: 'Schedule'), const Tab(text: 'Swaps'), if (isAdmin) const Tab(text: 'Generator'), ], ), const SizedBox(height: 8), Expanded( child: TabBarView( children: [ schedulePanel, swapsPanel, if (isAdmin) generatorPanel, ], ), ), ], ), ); }, ), ); } } class _SchedulePanel extends ConsumerWidget { const _SchedulePanel({required this.isAdmin}); final bool isAdmin; @override Widget build(BuildContext context, WidgetRef ref) { final schedulesAsync = ref.watch(dutySchedulesProvider); final profilesAsync = ref.watch(profilesProvider); final currentUserId = ref.watch(currentUserIdProvider); return schedulesAsync.when( data: (schedules) { if (schedules.isEmpty) { return const Center(child: Text('No schedules yet.')); } final Map profileById = { for (final profile in profilesAsync.valueOrNull ?? []) profile.id: profile, }; final grouped = >{}; for (final schedule in schedules) { final day = DateTime( schedule.startTime.year, schedule.startTime.month, schedule.startTime.day, ); grouped.putIfAbsent(day, () => []).add(schedule); } final days = grouped.keys.toList()..sort(); return ListView.builder( padding: const EdgeInsets.only(bottom: 24), itemCount: days.length, itemBuilder: (context, index) { final day = days[index]; final items = grouped[day]! ..sort((a, b) => a.startTime.compareTo(b.startTime)); return Padding( padding: const EdgeInsets.only(bottom: 12), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( _formatDay(day), style: Theme.of(context).textTheme.titleMedium?.copyWith( fontWeight: FontWeight.w700, ), ), const SizedBox(height: 8), ...items.map( (schedule) => _ScheduleTile( schedule: schedule, displayName: _scheduleName( profileById, schedule, isAdmin, ), relieverLabels: _relieverLabelsFromIds( schedule.relieverIds, profileById, ), isMine: schedule.userId == currentUserId, ), ), ], ), ); }, ); }, loading: () => const Center(child: CircularProgressIndicator()), error: (error, _) => Center(child: Text('Failed to load schedules: $error')), ); } String _scheduleName( Map profileById, DutySchedule schedule, bool isAdmin, ) { if (!isAdmin) { return _shiftLabel(schedule.shiftType); } final profile = profileById[schedule.userId]; final name = profile?.fullName.isNotEmpty == true ? profile!.fullName : schedule.userId; return '${_shiftLabel(schedule.shiftType)} · $name'; } String _shiftLabel(String value) { 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; } } String _formatDay(DateTime value) { return _formatFullDate(value); } String _formatFullDate(DateTime value) { const months = [ 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec', ]; const weekdays = [ 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday', ]; final month = months[value.month - 1]; final day = value.day.toString().padLeft(2, '0'); final weekday = weekdays[value.weekday - 1]; return '$weekday, $month $day, ${value.year}'; } List _relieverLabelsFromIds( List relieverIds, Map profileById, ) { if (relieverIds.isEmpty) return const []; return relieverIds .map( (id) => profileById[id]?.fullName.isNotEmpty == true ? profileById[id]!.fullName : id, ) .toList(); } } class _ScheduleTile extends ConsumerWidget { const _ScheduleTile({ required this.schedule, required this.displayName, required this.relieverLabels, required this.isMine, }); final DutySchedule schedule; final String displayName; final List relieverLabels; final bool isMine; @override Widget build(BuildContext context, WidgetRef ref) { final currentUserId = ref.watch(currentUserIdProvider); final swaps = ref.watch(swapRequestsProvider).valueOrNull ?? []; final now = AppTime.now(); final isPast = schedule.startTime.isBefore(now); final canCheckIn = isMine && schedule.checkInAt == null && (schedule.status == 'scheduled' || schedule.status == 'late') && now.isAfter(schedule.startTime.subtract(const Duration(hours: 2))) && now.isBefore(schedule.endTime); final hasRequestedSwap = swaps.any( (swap) => swap.shiftId == schedule.id && swap.requesterId == currentUserId && swap.status == 'pending', ); final canRequestSwap = isMine && schedule.status != 'absent' && !isPast; return Card( child: Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), child: Column( children: [ Row( children: [ Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( displayName, style: Theme.of(context).textTheme.titleSmall?.copyWith( fontWeight: FontWeight.w700, ), ), const SizedBox(height: 4), Text( '${_formatTime(schedule.startTime)} - ${_formatTime(schedule.endTime)}', style: Theme.of(context).textTheme.bodySmall, ), const SizedBox(height: 4), Text( _statusLabel(schedule.status), style: Theme.of(context).textTheme.bodySmall?.copyWith( color: _statusColor(context, schedule.status), ), ), ], ), ), Column( crossAxisAlignment: CrossAxisAlignment.end, children: [ if (canCheckIn) FilledButton.icon( onPressed: () => _handleCheckIn(context, ref, schedule), icon: const Icon(Icons.location_on), label: const Text('Check in'), ), if (canRequestSwap) ...[ if (canCheckIn) const SizedBox(height: 8), OutlinedButton.icon( onPressed: hasRequestedSwap ? () => _openSwapsTab(context) : () => _requestSwap(context, ref, schedule), icon: const Icon(Icons.swap_horiz), label: Text( hasRequestedSwap ? 'Swap Requested' : 'Request swap', ), ), ], ], ), ], ), if (relieverLabels.isNotEmpty) ExpansionTile( tilePadding: EdgeInsets.zero, title: const Text('Relievers'), children: [ for (final label in relieverLabels) Align( alignment: Alignment.centerLeft, child: Padding( padding: const EdgeInsets.only( left: 8, right: 8, bottom: 6, ), child: Text(label), ), ), ], ), ], ), ), ); } Future _handleCheckIn( BuildContext context, WidgetRef ref, DutySchedule schedule, ) async { final geofence = await ref.read(geofenceProvider.future); if (geofence == null) { if (!context.mounted) return; await _showAlert( context, title: 'Geofence missing', message: 'Geofence is not configured.', ); return; } final serviceEnabled = await Geolocator.isLocationServiceEnabled(); if (!serviceEnabled) { if (!context.mounted) return; await _showAlert( context, title: 'Location disabled', message: 'Location services are disabled.', ); return; } var permission = await Geolocator.checkPermission(); if (permission == LocationPermission.denied) { permission = await Geolocator.requestPermission(); } if (permission == LocationPermission.denied || permission == LocationPermission.deniedForever) { if (!context.mounted) return; await _showAlert( context, title: 'Permission denied', message: 'Location permission denied.', ); return; } if (!context.mounted) return; final progressContext = await _showCheckInProgress(context); try { final position = await Geolocator.getCurrentPosition( locationSettings: const LocationSettings( accuracy: LocationAccuracy.high, ), ); final isInside = geofence.hasPolygon ? geofence.containsPolygon(position.latitude, position.longitude) : geofence.hasCircle && Geolocator.distanceBetween( position.latitude, position.longitude, geofence.lat!, geofence.lng!, ) <= geofence.radiusMeters!; if (!isInside) { if (!context.mounted) return; await _showAlert( context, title: 'Outside geofence', message: 'You are outside the geofence. Wala ka sa CRMC.', ); return; } final status = await ref .read(workforceControllerProvider) .checkIn( dutyScheduleId: schedule.id, lat: position.latitude, lng: position.longitude, ); ref.invalidate(dutySchedulesProvider); if (!context.mounted) return; await _showAlert( context, title: 'Checked in', message: 'Checked in ($status).', ); } catch (error) { if (!context.mounted) return; await _showAlert( context, title: 'Check-in failed', message: 'Check-in failed: $error', ); } finally { if (progressContext.mounted) { Navigator.of(progressContext).pop(); } } } Future _requestSwap( BuildContext context, WidgetRef ref, DutySchedule schedule, ) async { final profiles = ref.read(profilesProvider).valueOrNull ?? []; final currentUserId = ref.read(currentUserIdProvider); final staff = profiles .where((profile) => profile.role == 'it_staff') .where((profile) => profile.id != currentUserId) .toList(); if (staff.isEmpty) { _showMessage(context, 'No IT staff available for swaps.'); return; } String? selectedId = staff.first.id; final confirmed = await showDialog( context: context, builder: (dialogContext) { return AlertDialog( shape: AppSurfaces.of(context).dialogShape, title: const Text('Request swap'), content: DropdownButtonFormField( initialValue: selectedId, items: [ for (final profile in staff) DropdownMenuItem( value: profile.id, child: Text( profile.fullName.isNotEmpty ? profile.fullName : profile.id, ), ), ], onChanged: (value) => selectedId = value, decoration: const InputDecoration(labelText: 'Recipient'), ), actions: [ TextButton( onPressed: () => Navigator.of(dialogContext).pop(false), child: const Text('Cancel'), ), FilledButton( onPressed: () => Navigator.of(dialogContext).pop(true), child: const Text('Send request'), ), ], ); }, ); if (!context.mounted) return; if (confirmed != true || selectedId == null) return; try { await ref .read(workforceControllerProvider) .requestSwap(shiftId: schedule.id, recipientId: selectedId!); ref.invalidate(swapRequestsProvider); if (!context.mounted) return; _showMessage(context, 'Swap request sent.'); } catch (error) { if (!context.mounted) return; _showMessage(context, 'Swap request failed: $error'); } } void _showMessage(BuildContext context, String message) { ScaffoldMessenger.of( context, ).showSnackBar(SnackBar(content: Text(message))); } Future _showAlert( BuildContext context, { required String title, required String message, }) async { await showDialog( context: context, builder: (dialogContext) { return AlertDialog( title: Text(title), content: Text(message), actions: [ TextButton( onPressed: () => Navigator.of(dialogContext).pop(), child: const Text('OK'), ), ], ); }, ); } Future _showCheckInProgress(BuildContext context) { final completer = Completer(); showDialog( context: context, barrierDismissible: false, builder: (dialogContext) { if (!completer.isCompleted) { completer.complete(dialogContext); } return AlertDialog( shape: AppSurfaces.of(context).dialogShape, title: const Text('Validating location'), content: Row( children: [ const SizedBox( height: 24, width: 24, child: CircularProgressIndicator(strokeWidth: 2), ), const SizedBox(width: 12), Flexible( child: Text( 'Please wait while we verify your location.', style: Theme.of(context).textTheme.bodyMedium, ), ), ], ), ); }, ); return completer.future; } void _openSwapsTab(BuildContext context) { final controller = DefaultTabController.maybeOf(context); if (controller != null) { controller.animateTo(1); return; } _showMessage(context, 'Swap request already sent. See Swaps panel.'); } String _formatTime(DateTime value) { final rawHour = value.hour; final hour = (rawHour % 12 == 0 ? 12 : rawHour % 12).toString().padLeft( 2, '0', ); final minute = value.minute.toString().padLeft(2, '0'); final suffix = rawHour >= 12 ? 'PM' : 'AM'; return '$hour:$minute $suffix'; } String _statusLabel(String status) { switch (status) { case 'arrival': return 'Arrival'; case 'late': return 'Late'; case 'absent': return 'Absent'; default: return 'Scheduled'; } } Color _statusColor(BuildContext context, String status) { switch (status) { case 'arrival': return Colors.green; case 'late': return Colors.orange; case 'absent': return Colors.red; default: return Theme.of(context).colorScheme.onSurfaceVariant; } } } class _DraftSchedule { _DraftSchedule({ required this.localId, required this.userId, required this.shiftType, required this.startTime, required this.endTime, List? relieverIds, }) : relieverIds = relieverIds ?? []; final int localId; String userId; String shiftType; DateTime startTime; DateTime endTime; List relieverIds; } class _RotationEntry { _RotationEntry({ required this.userId, required this.shiftType, required this.startTime, }); final String userId; final String shiftType; final DateTime startTime; } class _ShiftTemplate { _ShiftTemplate({ required this.startHour, required this.startMinute, required this.duration, }); final int startHour; final int startMinute; final Duration duration; DateTime buildStart(DateTime day) { return tz.TZDateTime( tz.local, day.year, day.month, day.day, startHour, startMinute, ); } DateTime buildEnd(DateTime start) { return start.add(duration); } } class _ScheduleGeneratorPanel extends ConsumerStatefulWidget { const _ScheduleGeneratorPanel({required this.enabled}); final bool enabled; @override ConsumerState<_ScheduleGeneratorPanel> createState() => _ScheduleGeneratorPanelState(); } class _ScheduleGeneratorPanelState extends ConsumerState<_ScheduleGeneratorPanel> { DateTime? _startDate; DateTime? _endDate; bool _isGenerating = false; bool _isSaving = false; int _draftCounter = 0; List<_DraftSchedule> _draftSchedules = []; List _warnings = []; @override Widget build(BuildContext context) { if (!widget.enabled) { return const SizedBox.shrink(); } return Card( child: Padding( padding: const EdgeInsets.all(16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( 'Schedule Generator', style: Theme.of( context, ).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700), ), const SizedBox(height: 12), _dateField( context, label: 'Start date', value: _startDate, onTap: () => _pickDate(isStart: true), ), const SizedBox(height: 8), _dateField( context, label: 'End date', value: _endDate, onTap: () => _pickDate(isStart: false), ), const SizedBox(height: 16), Row( children: [ Expanded( child: FilledButton( onPressed: _isGenerating ? null : _handleGenerate, child: _isGenerating ? const SizedBox( height: 18, width: 18, child: CircularProgressIndicator(strokeWidth: 2), ) : const Text('Generate preview'), ), ), const SizedBox(width: 12), OutlinedButton( onPressed: _draftSchedules.isEmpty ? null : _clearDraft, child: const Text('Clear'), ), ], ), if (_warnings.isNotEmpty) ...[ const SizedBox(height: 12), _buildWarningPanel(context), ], if (_draftSchedules.isNotEmpty) ...[ const SizedBox(height: 12), _buildDraftHeader(context), const SizedBox(height: 8), Flexible(fit: FlexFit.loose, child: _buildDraftList(context)), const SizedBox(height: 12), Row( children: [ OutlinedButton.icon( onPressed: _isSaving ? null : _addDraft, icon: const Icon(Icons.add), label: const Text('Add shift'), ), const Spacer(), FilledButton.icon( onPressed: _isSaving ? null : _commitDraft, icon: const Icon(Icons.check_circle), label: _isSaving ? const SizedBox( height: 18, width: 18, child: CircularProgressIndicator(strokeWidth: 2), ) : const Text('Commit schedule'), ), ], ), ], ], ), ), ); } Widget _dateField( BuildContext context, { required String label, required DateTime? value, required VoidCallback onTap, }) { return InkWell( onTap: onTap, child: InputDecorator( decoration: InputDecoration(labelText: label), child: Text(value == null ? 'Select date' : _formatDate(value)), ), ); } Future _pickDate({required bool isStart}) async { final now = AppTime.now(); final initial = isStart ? _startDate ?? now : _endDate ?? _startDate ?? now; final picked = await showDatePicker( context: context, initialDate: initial, firstDate: DateTime(now.year - 1), lastDate: DateTime(now.year + 2), ); if (picked == null) return; setState(() { if (isStart) { _startDate = picked; } else { _endDate = picked; } }); } Future _handleGenerate() async { final start = _startDate; final end = _endDate; if (start == null || end == null) { _showMessage('Select a date range.'); return; } if (end.isBefore(start)) { _showMessage('End date must be after start date.'); return; } setState(() => _isGenerating = true); try { final staff = _sortedStaff(); if (staff.isEmpty) { if (!mounted) return; _showMessage('No IT staff available for scheduling.'); return; } final schedules = ref.read(dutySchedulesProvider).valueOrNull ?? []; final templates = _buildTemplates(schedules); final generated = _generateDrafts( start, end, staff, schedules, templates, ); generated.sort((a, b) => a.startTime.compareTo(b.startTime)); final warnings = _buildWarnings(start, end, generated); if (!mounted) return; setState(() { _draftSchedules = generated; _warnings = warnings; }); if (generated.isEmpty) { _showMessage('No shifts could be generated.'); } } finally { if (mounted) { setState(() => _isGenerating = false); } } } void _clearDraft() { setState(() { _draftSchedules = []; _warnings = []; }); } Widget _buildWarningPanel(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; return Container( padding: const EdgeInsets.all(12), decoration: BoxDecoration( color: colorScheme.tertiaryContainer, borderRadius: BorderRadius.circular( AppSurfaces.of(context).compactCardRadius, ), ), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Icon(Icons.warning_amber, color: colorScheme.onTertiaryContainer), const SizedBox(width: 8), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( 'Uncovered shifts', style: Theme.of(context).textTheme.titleSmall?.copyWith( fontWeight: FontWeight.w700, color: colorScheme.onTertiaryContainer, ), ), const SizedBox(height: 6), for (final warning in _warnings) Text( warning, style: Theme.of(context).textTheme.bodySmall?.copyWith( color: colorScheme.onTertiaryContainer, ), ), ], ), ), ], ), ); } Widget _buildDraftHeader(BuildContext context) { final start = _startDate == null ? '' : _formatDate(_startDate!); final end = _endDate == null ? '' : _formatDate(_endDate!); return Row( children: [ Text( 'Preview (${_draftSchedules.length})', style: Theme.of( context, ).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w700), ), const Spacer(), if (start.isNotEmpty && end.isNotEmpty) Text('$start → $end', style: Theme.of(context).textTheme.bodySmall), ], ); } Widget _buildDraftList(BuildContext context) { return ListView.separated( primary: false, itemCount: _draftSchedules.length, separatorBuilder: (context, index) => const SizedBox(height: 8), itemBuilder: (context, index) { final draft = _draftSchedules[index]; final profileById = _profileById(); final profile = profileById[draft.userId]; final userLabel = profile?.fullName.isNotEmpty == true ? profile!.fullName : draft.userId; final relieverLabels = draft.relieverIds .map( (id) => profileById[id]?.fullName.isNotEmpty == true ? profileById[id]!.fullName : id, ) .toList(); return Card( child: Padding( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), child: Column( children: [ Row( children: [ Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( '${_shiftLabel(draft.shiftType)} · $userLabel', style: Theme.of(context).textTheme.bodyMedium ?.copyWith(fontWeight: FontWeight.w600), ), const SizedBox(height: 4), Text( '${_formatDate(draft.startTime)} · ${_formatTime(draft.startTime)} - ${_formatTime(draft.endTime)}', style: Theme.of(context).textTheme.bodySmall, ), ], ), ), IconButton( tooltip: 'Edit', onPressed: () => _editDraft(draft), icon: const Icon(Icons.edit), ), IconButton( tooltip: 'Delete', onPressed: () => _deleteDraft(draft), icon: const Icon(Icons.delete_outline), ), ], ), if (relieverLabels.isNotEmpty) ExpansionTile( tilePadding: EdgeInsets.zero, title: const Text('Relievers'), children: [ for (final label in relieverLabels) Align( alignment: Alignment.centerLeft, child: Padding( padding: const EdgeInsets.only( left: 8, right: 8, bottom: 6, ), child: Text(label), ), ), ], ), ], ), ), ); }, ); } void _addDraft() { _openDraftEditor(); } void _editDraft(_DraftSchedule draft) { _openDraftEditor(existing: draft); } void _deleteDraft(_DraftSchedule draft) { setState(() { _draftSchedules.removeWhere((item) => item.localId == draft.localId); _warnings = _buildWarnings( _startDate ?? AppTime.now(), _endDate ?? AppTime.now(), _draftSchedules, ); }); } Future _openDraftEditor({_DraftSchedule? existing}) async { final staff = _sortedStaff(); if (staff.isEmpty) { _showMessage('No IT staff available.'); return; } final start = existing?.startTime ?? _startDate ?? AppTime.now(); var selectedDate = DateTime(start.year, start.month, start.day); var selectedUserId = existing?.userId ?? staff.first.id; var selectedShift = existing?.shiftType ?? 'am'; var startTime = TimeOfDay.fromDateTime(existing?.startTime ?? start); var endTime = TimeOfDay.fromDateTime( existing?.endTime ?? start.add(const Duration(hours: 8)), ); final result = await showDialog<_DraftSchedule>( context: context, builder: (dialogContext) { return StatefulBuilder( builder: (context, setDialogState) { return AlertDialog( title: Text(existing == null ? 'Add shift' : 'Edit shift'), content: SingleChildScrollView( child: Column( mainAxisSize: MainAxisSize.min, children: [ DropdownButtonFormField( initialValue: selectedUserId, items: [ for (final profile in staff) DropdownMenuItem( value: profile.id, child: Text( profile.fullName.isNotEmpty ? profile.fullName : profile.id, ), ), ], onChanged: (value) { if (value == null) return; setDialogState(() => selectedUserId = value); }, decoration: const InputDecoration(labelText: 'Assignee'), ), const SizedBox(height: 12), DropdownButtonFormField( initialValue: selectedShift, items: const [ DropdownMenuItem(value: 'am', child: Text('AM Duty')), DropdownMenuItem( value: 'on_call', child: Text('On Call'), ), DropdownMenuItem(value: 'pm', child: Text('PM Duty')), ], onChanged: (value) { if (value == null) return; setDialogState(() => selectedShift = value); }, decoration: const InputDecoration( labelText: 'Shift type', ), ), const SizedBox(height: 12), _dialogDateField( label: 'Date', value: _formatDate(selectedDate), onTap: () async { final picked = await showDatePicker( context: context, initialDate: selectedDate, firstDate: DateTime(2020), lastDate: DateTime(2100), ); if (picked == null) return; setDialogState(() { selectedDate = picked; }); }, ), const SizedBox(height: 12), _dialogTimeField( label: 'Start time', value: startTime, onTap: () async { final picked = await showTimePicker( context: context, initialTime: startTime, ); if (picked == null) return; setDialogState(() => startTime = picked); }, ), const SizedBox(height: 12), _dialogTimeField( label: 'End time', value: endTime, onTap: () async { final picked = await showTimePicker( context: context, initialTime: endTime, ); if (picked == null) return; setDialogState(() => endTime = picked); }, ), ], ), ), actions: [ TextButton( onPressed: () => Navigator.of(dialogContext).pop(), child: const Text('Cancel'), ), FilledButton( onPressed: () { final startDateTime = DateTime( selectedDate.year, selectedDate.month, selectedDate.day, startTime.hour, startTime.minute, ); var endDateTime = DateTime( selectedDate.year, selectedDate.month, selectedDate.day, endTime.hour, endTime.minute, ); if (!endDateTime.isAfter(startDateTime)) { endDateTime = endDateTime.add(const Duration(days: 1)); } final draft = existing ?? _DraftSchedule( localId: _draftCounter++, userId: selectedUserId, shiftType: selectedShift, startTime: startDateTime, endTime: endDateTime, ); draft ..userId = selectedUserId ..shiftType = selectedShift ..startTime = startDateTime ..endTime = endDateTime; Navigator.of(dialogContext).pop(draft); }, child: const Text('Save'), ), ], ); }, ); }, ); if (!mounted || result == null) return; setState(() { if (existing == null) { _draftSchedules.add(result); } _draftSchedules.sort((a, b) => a.startTime.compareTo(b.startTime)); _warnings = _buildWarnings( _startDate ?? result.startTime, _endDate ?? result.endTime, _draftSchedules, ); }); } Widget _dialogDateField({ required String label, required String value, required VoidCallback onTap, }) { return InkWell( onTap: onTap, child: InputDecorator( decoration: InputDecoration(labelText: label), child: Text(value), ), ); } Widget _dialogTimeField({ required String label, required TimeOfDay value, required VoidCallback onTap, }) { return InkWell( onTap: onTap, child: InputDecorator( decoration: InputDecoration(labelText: label), child: Text(value.format(context)), ), ); } Future _commitDraft() async { final start = _startDate; final end = _endDate; if (start == null || end == null) { _showMessage('Select a date range.'); return; } final schedules = ref.read(dutySchedulesProvider).valueOrNull ?? []; final conflict = _findConflict(_draftSchedules, schedules); if (conflict != null) { _showMessage(conflict); return; } final payload = _draftSchedules .map( (draft) => { 'user_id': draft.userId, 'shift_type': draft.shiftType, 'start_time': draft.startTime.toIso8601String(), 'end_time': draft.endTime.toIso8601String(), 'status': 'scheduled', 'reliever_ids': draft.relieverIds, }, ) .toList(); setState(() => _isSaving = true); try { await ref.read(workforceControllerProvider).insertSchedules(payload); ref.invalidate(dutySchedulesProvider); if (!mounted) return; _showMessage('Schedule committed.'); setState(() { _draftSchedules = []; _warnings = []; }); } catch (error) { if (!mounted) return; _showMessage('Commit failed: $error'); } finally { if (mounted) { setState(() => _isSaving = false); } } } List _sortedStaff() { final profiles = ref.read(profilesProvider).valueOrNull ?? []; final staff = profiles .where((profile) => profile.role == 'it_staff') .toList(); staff.sort((a, b) { final nameA = a.fullName.isNotEmpty ? a.fullName : a.id; final nameB = b.fullName.isNotEmpty ? b.fullName : b.id; final result = nameA.compareTo(nameB); if (result != 0) return result; return a.id.compareTo(b.id); }); return staff; } Map _profileById() { final profiles = ref.read(profilesProvider).valueOrNull ?? []; return {for (final profile in profiles) profile.id: profile}; } Map _buildTemplates(List schedules) { final templates = {}; for (final schedule in schedules) { final key = _normalizeShiftType(schedule.shiftType); if (templates.containsKey(key)) continue; final start = schedule.startTime; var end = schedule.endTime; if (!end.isAfter(start)) { end = end.add(const Duration(days: 1)); } templates[key] = _ShiftTemplate( startHour: start.hour, startMinute: start.minute, duration: end.difference(start), ); } templates['am'] = _ShiftTemplate( startHour: 7, startMinute: 0, duration: const Duration(hours: 8), ); templates['pm'] = _ShiftTemplate( startHour: 15, startMinute: 0, duration: const Duration(hours: 8), ); templates['on_call'] = _ShiftTemplate( startHour: 23, startMinute: 0, duration: const Duration(hours: 8), ); templates['normal'] = _ShiftTemplate( startHour: 8, startMinute: 0, duration: const Duration(hours: 9), ); return templates; } List<_DraftSchedule> _generateDrafts( DateTime start, DateTime end, List staff, List schedules, Map templates, ) { final draft = <_DraftSchedule>[]; final normalizedStart = DateTime(start.year, start.month, start.day); final normalizedEnd = DateTime(end.year, end.month, end.day); final existing = schedules; var weekStart = _startOfWeek(normalizedStart); while (!weekStart.isAfter(normalizedEnd)) { final weekEnd = weekStart.add(const Duration(days: 6)); final prevWeekStart = weekStart.subtract(const Duration(days: 7)); final prevWeekEnd = weekStart.subtract(const Duration(days: 1)); final lastWeek = <_RotationEntry>[ ...existing .where( (schedule) => !schedule.startTime.isBefore(prevWeekStart) && !schedule.startTime.isAfter(prevWeekEnd), ) .map( (schedule) => _RotationEntry( userId: schedule.userId, shiftType: schedule.shiftType, startTime: schedule.startTime, ), ), ...draft .where( (item) => !item.startTime.isBefore(prevWeekStart) && !item.startTime.isAfter(prevWeekEnd), ) .map( (item) => _RotationEntry( userId: item.userId, shiftType: item.shiftType, startTime: item.startTime, ), ), ]; final amBaseIndex = _nextIndexFromLastWeek( shiftType: 'am', staff: staff, lastWeek: lastWeek, defaultIndex: 0, ); final pmBaseIndex = _nextIndexFromLastWeek( shiftType: 'pm', staff: staff, lastWeek: lastWeek, defaultIndex: staff.length > 1 ? 1 : 0, ); final amUserId = staff.isEmpty ? null : staff[amBaseIndex % staff.length].id; final pmUserId = staff.isEmpty ? null : staff[pmBaseIndex % staff.length].id; final nextWeekPmUserId = staff.isEmpty ? null : staff[(pmBaseIndex + 1) % staff.length].id; final pmRelievers = _buildRelievers(pmBaseIndex, staff); final nextWeekRelievers = staff.isEmpty ? [] : _buildRelievers((pmBaseIndex + 1) % staff.length, staff); var weekendNormalOffset = 0; for ( var day = weekStart; !day.isAfter(weekEnd); day = day.add(const Duration(days: 1)) ) { if (day.isBefore(normalizedStart) || day.isAfter(normalizedEnd)) { continue; } final isWeekend = day.weekday == DateTime.saturday || day.weekday == DateTime.sunday; if (isWeekend) { if (staff.isNotEmpty) { final normalIndex = (amBaseIndex + pmBaseIndex + weekendNormalOffset) % staff.length; _tryAddDraft( draft, existing, templates, 'normal', staff[normalIndex].id, day, const [], ); weekendNormalOffset += 1; } if (nextWeekPmUserId != null) { _tryAddDraft( draft, existing, templates, 'on_call', nextWeekPmUserId, day, nextWeekRelievers, ); } } else { if (amUserId != null) { _tryAddDraft( draft, existing, templates, 'am', amUserId, day, const [], ); } if (pmUserId != null) { _tryAddDraft( draft, existing, templates, 'pm', pmUserId, day, pmRelievers, ); _tryAddDraft( draft, existing, templates, 'on_call', pmUserId, day, pmRelievers, ); } final assignedToday = [ amUserId, pmUserId, ].whereType().toSet(); for (final profile in staff) { if (assignedToday.contains(profile.id)) continue; _tryAddDraft( draft, existing, templates, 'normal', profile.id, day, const [], ); } } } weekStart = weekStart.add(const Duration(days: 7)); } return draft; } void _tryAddDraft( List<_DraftSchedule> draft, List existing, Map templates, String shiftType, String userId, DateTime day, List relieverIds, ) { final template = templates[_normalizeShiftType(shiftType)]!; final start = template.buildStart(day); final end = template.buildEnd(start); final candidate = _DraftSchedule( localId: _draftCounter++, userId: userId, shiftType: shiftType, startTime: start, endTime: end, relieverIds: relieverIds, ); if (_hasConflict(candidate, draft, existing)) { return; } draft.add(candidate); } int _nextIndexFromLastWeek({ required String shiftType, required List staff, required Iterable<_RotationEntry> lastWeek, required int defaultIndex, }) { if (staff.isEmpty) return 0; final normalized = _normalizeShiftType(shiftType); final lastAssignment = lastWeek .where( (entry) => _normalizeShiftType(entry.shiftType) == normalized, ) .toList() ..sort((a, b) => a.startTime.compareTo(b.startTime)); if (lastAssignment.isNotEmpty) { final lastUserId = lastAssignment.last.userId; final index = staff.indexWhere((profile) => profile.id == lastUserId); if (index != -1) { return (index + 1) % staff.length; } } return defaultIndex % staff.length; } List _buildRelievers(int primaryIndex, List staff) { if (staff.length <= 1) return const []; final relievers = []; for (var offset = 1; offset < staff.length; offset += 1) { relievers.add(staff[(primaryIndex + offset) % staff.length].id); if (relievers.length == 3) break; } return relievers; } bool _hasConflict( _DraftSchedule candidate, List<_DraftSchedule> drafts, List existing, ) { for (final draft in drafts) { if (draft.userId != candidate.userId) continue; if (_overlaps( candidate.startTime, candidate.endTime, draft.startTime, draft.endTime, )) { return true; } } for (final schedule in existing) { if (schedule.userId != candidate.userId) continue; if (_overlaps( candidate.startTime, candidate.endTime, schedule.startTime, schedule.endTime, )) { return true; } } return false; } String? _findConflict( List<_DraftSchedule> drafts, List existing, ) { for (final draft in drafts) { if (_hasConflict( draft, drafts.where((d) => d.localId != draft.localId).toList(), existing, )) { return 'Conflict found for ${_formatDate(draft.startTime)}.'; } } return null; } bool _overlaps( DateTime startA, DateTime endA, DateTime startB, DateTime endB, ) { return startA.isBefore(endB) && endA.isAfter(startB); } List _buildWarnings( DateTime start, DateTime end, List<_DraftSchedule> drafts, ) { final warnings = []; var day = DateTime(start.year, start.month, start.day); final lastDay = DateTime(end.year, end.month, end.day); while (!day.isAfter(lastDay)) { final isWeekend = day.weekday == DateTime.saturday || day.weekday == DateTime.sunday; final required = {'on_call'}; if (isWeekend) { required.add('normal'); } else { required.addAll({'am', 'pm'}); } final available = drafts .where( (draft) => draft.startTime.year == day.year && draft.startTime.month == day.month && draft.startTime.day == day.day, ) .map((draft) => _normalizeShiftType(draft.shiftType)) .toSet(); for (final shift in required) { if (!available.contains(shift)) { warnings.add('${_formatDate(day)} missing ${_shiftLabel(shift)}'); } } day = day.add(const Duration(days: 1)); } return warnings; } DateTime _startOfWeek(DateTime day) { return day.subtract(Duration(days: day.weekday - 1)); } String _normalizeShiftType(String value) { return value; } String _shiftLabel(String value) { 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 _showMessage(String message) { ScaffoldMessenger.of( context, ).showSnackBar(SnackBar(content: Text(message))); } String _formatTime(DateTime value) { final rawHour = value.hour; final hour = (rawHour % 12 == 0 ? 12 : rawHour % 12).toString().padLeft( 2, '0', ); final minute = value.minute.toString().padLeft(2, '0'); final suffix = rawHour >= 12 ? 'PM' : 'AM'; return '$hour:$minute $suffix'; } String _formatDate(DateTime value) { const months = [ 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec', ]; const weekdays = [ 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday', ]; final month = months[value.month - 1]; final day = value.day.toString().padLeft(2, '0'); final weekday = weekdays[value.weekday - 1]; return '$weekday, $month $day, ${value.year}'; } } class _SwapRequestsPanel extends ConsumerWidget { const _SwapRequestsPanel({required this.isAdmin}); final bool isAdmin; @override Widget build(BuildContext context, WidgetRef ref) { final swapsAsync = ref.watch(swapRequestsProvider); final schedulesAsync = ref.watch(dutySchedulesProvider); final profilesAsync = ref.watch(profilesProvider); final currentUserId = ref.watch(currentUserIdProvider); final Map scheduleById = { for (final schedule in schedulesAsync.valueOrNull ?? []) schedule.id: schedule, }; final Map profileById = { for (final profile in profilesAsync.valueOrNull ?? []) profile.id: profile, }; return swapsAsync.when( data: (items) { if (items.isEmpty) { return const Center(child: Text('No swap requests.')); } return ListView.separated( padding: const EdgeInsets.only(bottom: 24), itemCount: items.length, separatorBuilder: (context, index) => const SizedBox(height: 12), itemBuilder: (context, index) { final item = items[index]; final schedule = scheduleById[item.shiftId]; final requesterProfile = profileById[item.requesterId]; final recipientProfile = profileById[item.recipientId]; final requester = requesterProfile?.fullName.isNotEmpty == true ? requesterProfile!.fullName : item.requesterId; final recipient = recipientProfile?.fullName.isNotEmpty == true ? recipientProfile!.fullName : item.recipientId; final subtitle = schedule == null ? 'Shift not found' : '${_shiftLabel(schedule.shiftType)} · ${_formatDate(schedule.startTime)} · ${_formatTime(schedule.startTime)}'; final relieverLabels = schedule == null ? const [] : _relieverLabelsFromIds(schedule.relieverIds, profileById); final isPending = item.status == 'pending'; final canRespond = (isAdmin || item.recipientId == currentUserId) && isPending; final canEscalate = item.requesterId == currentUserId && isPending; return Card( child: Padding( padding: const EdgeInsets.all(16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( '$requester → $recipient', style: Theme.of(context).textTheme.titleSmall?.copyWith( fontWeight: FontWeight.w700, ), ), const SizedBox(height: 6), Text(subtitle), const SizedBox(height: 6), Text('Status: ${item.status}'), const SizedBox(height: 12), if (relieverLabels.isNotEmpty) ExpansionTile( tilePadding: EdgeInsets.zero, title: const Text('Relievers'), children: [ for (final label in relieverLabels) Align( alignment: Alignment.centerLeft, child: Padding( padding: const EdgeInsets.only( left: 8, right: 8, bottom: 6, ), child: Text(label), ), ), ], ), Row( children: [ if (canRespond) ...[ OutlinedButton( onPressed: () => _respond(ref, item, 'accepted'), child: const Text('Accept'), ), const SizedBox(width: 8), OutlinedButton( onPressed: () => _respond(ref, item, 'rejected'), child: const Text('Reject'), ), ], if (canEscalate) ...[ const SizedBox(width: 8), OutlinedButton( onPressed: () => _respond(ref, item, 'admin_review'), child: const Text('Escalate'), ), ], ], ), ], ), ), ); }, ); }, loading: () => const Center(child: CircularProgressIndicator()), error: (error, _) => Center(child: Text('Failed to load swaps: $error')), ); } Future _respond( WidgetRef ref, SwapRequest request, String action, ) async { await ref .read(workforceControllerProvider) .respondSwap(swapId: request.id, action: action); ref.invalidate(swapRequestsProvider); } String _shiftLabel(String value) { 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; } } String _formatTime(DateTime value) { final rawHour = value.hour; final hour = (rawHour % 12 == 0 ? 12 : rawHour % 12).toString().padLeft( 2, '0', ); final minute = value.minute.toString().padLeft(2, '0'); final suffix = rawHour >= 12 ? 'PM' : 'AM'; return '$hour:$minute $suffix'; } String _formatDate(DateTime value) { const months = [ 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec', ]; const weekdays = [ 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday', ]; final month = months[value.month - 1]; final day = value.day.toString().padLeft(2, '0'); final weekday = weekdays[value.weekday - 1]; return '$weekday, $month $day, ${value.year}'; } List _relieverLabelsFromIds( List relieverIds, Map profileById, ) { if (relieverIds.isEmpty) return const []; return relieverIds .map( (id) => profileById[id]?.fullName.isNotEmpty == true ? profileById[id]!.fullName : id, ) .toList(); } }