import 'package:flutter/material.dart'; import '../../theme/m3_motion.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:tasq/utils/app_time.dart'; import 'package:timezone/timezone.dart' as tz; import '../../models/duty_schedule.dart'; import '../../models/profile.dart'; import '../../models/rotation_config.dart'; import '../../models/swap_request.dart'; import '../../providers/profile_provider.dart'; import '../../providers/rotation_config_provider.dart'; import '../../providers/workforce_provider.dart'; import '../../providers/chat_provider.dart'; import '../../providers/ramadan_provider.dart'; import '../../providers/notifications_provider.dart'; import '../../widgets/app_page_header.dart'; import '../../widgets/app_state_view.dart'; import '../../widgets/responsive_body.dart'; import '../../theme/app_surfaces.dart'; import '../../utils/snackbar.dart'; import 'rotation_settings_dialog.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 == 'programmer' || role == 'dispatcher'; return ResponsiveBody( child: Column( children: [ const AppPageHeader( title: 'Workforce', subtitle: 'Duty schedules and shift management', ), Expanded( 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) { if (!isAdmin) { return schedulePanel; } return Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Expanded(flex: 3, child: schedulePanel), const SizedBox(width: 16), Expanded( flex: 2, child: Column( children: [ generatorPanel, const SizedBox(height: 16), Expanded(child: swapsPanel), ], ), ), ], ); } if (!isAdmin) { return schedulePanel; } return DefaultTabController( length: 3, child: Column( children: [ const SizedBox(height: 8), const TabBar( tabs: [ Tab(text: 'Schedule'), Tab(text: 'Swaps'), Tab(text: 'Generator'), ], ), const SizedBox(height: 8), Expanded( child: TabBarView( children: [ schedulePanel, swapsPanel, 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 rotationConfig = ref.watch(rotationConfigProvider).valueOrNull; final currentUserId = ref.watch(currentUserIdProvider); final showPast = ref.watch(showPastSchedulesProvider); return Column( children: [ Padding( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), child: Row( children: [ Text( 'Duty Schedules', style: Theme.of( context, ).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700), ), const Spacer(), FilterChip( label: const Text('Show past'), selected: showPast, onSelected: (v) => ref.read(showPastSchedulesProvider.notifier).state = v, ), ], ), ), const SizedBox(height: 4), Expanded( child: schedulesAsync.when( data: (allSchedules) { final now = AppTime.now(); final today = DateTime(now.year, now.month, now.day); // Exclude overtime schedules – they only belong in the Logbook. final nonOvertime = allSchedules .where((s) => s.shiftType != 'overtime') .toList(); final schedules = showPast ? nonOvertime : nonOvertime .where((s) => !s.endTime.isBefore(today)) .toList(); if (schedules.isEmpty) { return const AppEmptyView( icon: Icons.calendar_month_outlined, title: 'No schedules yet', subtitle: 'Generated schedules will appear here. Use the Generator tab to create them.', ); } 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( '${_dayOfWeek(day)}, ${AppTime.formatDate(day)}', style: Theme.of(context).textTheme.titleMedium ?.copyWith(fontWeight: FontWeight.w700), ), const SizedBox(height: 8), for (int i = 0; i < items.length; i++) M3FadeSlideIn( delay: Duration(milliseconds: i * 40), child: Padding( padding: const EdgeInsets.only(bottom: 8), child: _ScheduleTile( schedule: items[i], displayName: _scheduleName( profileById, items[i], isAdmin, rotationConfig, ), relieverLabels: _relieverLabelsFromIds( items[i].relieverIds, profileById, ), isMine: items[i].userId == currentUserId, isAdmin: isAdmin, role: profileById[items[i].userId]?.role, ), ), ), ], ), ); }, ); }, loading: () => const Center(child: CircularProgressIndicator()), error: (error, _) => AppErrorView( error: error, onRetry: () => ref.invalidate(dutySchedulesProvider), ), ), ), ], ); } String _scheduleName( Map profileById, DutySchedule schedule, bool isAdmin, RotationConfig? rotationConfig, ) { final profile = profileById[schedule.userId]; final name = profile?.fullName.isNotEmpty == true ? profile!.fullName : schedule.userId; return '${_shiftLabel(schedule.shiftType, rotationConfig)} · $name'; } static String _dayOfWeek(DateTime day) { const days = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']; return days[day.weekday - 1]; } 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; } } 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, required this.isAdmin, this.role, }); final DutySchedule schedule; final String displayName; final List relieverLabels; final bool isMine; final bool isAdmin; final String? role; @override Widget build(BuildContext context, WidgetRef ref) { final rotationConfig = ref.watch(rotationConfigProvider).valueOrNull; ShiftTypeConfig? shiftTypeConfig; if (rotationConfig != null) { try { shiftTypeConfig = rotationConfig.shiftTypes.firstWhere( (s) => s.id == schedule.shiftType, ); } catch (_) { shiftTypeConfig = null; } } final isInvalidShiftForRole = role != null && shiftTypeConfig != null && shiftTypeConfig.allowedRoles.isNotEmpty && !shiftTypeConfig.allowedRoles.contains(role); final isHoliday = rotationConfig?.holidays.any( (h) => h.date.year == schedule.startTime.year && h.date.month == schedule.startTime.month && h.date.day == schedule.startTime.day, ) == true; 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( '${AppTime.formatTime(schedule.startTime)} - ${AppTime.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), ), ), if (isHoliday) ...[ const SizedBox(height: 4), Row( children: [ Icon( Icons.calendar_month, size: 16, color: Theme.of(context).colorScheme.primary, ), const SizedBox(width: 4), Text( 'Holiday', style: Theme.of(context).textTheme.bodySmall ?.copyWith( color: Theme.of( context, ).colorScheme.primary, ), ), ], ), ], if (isInvalidShiftForRole) ...[ const SizedBox(height: 4), Row( children: [ Icon( Icons.warning_amber, size: 16, color: Theme.of(context).colorScheme.error, ), const SizedBox(width: 4), Text( 'Shift not allowed for role', style: Theme.of(context).textTheme.bodySmall ?.copyWith( color: Theme.of(context).colorScheme.error, ), ), ], ), ], ], ), ), Column( crossAxisAlignment: CrossAxisAlignment.end, children: [ if (isAdmin) IconButton( tooltip: 'Edit schedule', onPressed: () => _editSchedule(context, ref), icon: const Icon(Icons.edit, size: 20), ), ], ), ], ), 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 _editSchedule(BuildContext context, WidgetRef ref) async { final profiles = ref.read(profilesProvider).valueOrNull ?? []; final staff = profiles .where( (p) => p.role == 'it_staff' || p.role == 'admin' || p.role == 'dispatcher', ) .toList() ..sort((a, b) => a.fullName.compareTo(b.fullName)); if (staff.isEmpty) return; var selectedUserId = schedule.userId; var selectedShift = schedule.shiftType; var selectedDate = DateTime( schedule.startTime.year, schedule.startTime.month, schedule.startTime.day, ); var startTime = TimeOfDay.fromDateTime(schedule.startTime); var endTime = TimeOfDay.fromDateTime(schedule.endTime); final rotationConfig = ref.read(rotationConfigProvider).valueOrNull; final shiftTypeConfigs = rotationConfig?.shiftTypes ?? RotationConfig().shiftTypes; final confirmed = await m3ShowDialog( context: context, builder: (dialogContext) { return StatefulBuilder( builder: (context, setState) { final selectedRole = () { try { return staff.firstWhere((p) => p.id == selectedUserId).role; } catch (_) { return null; } }(); final allowed = shiftTypeConfigs .where( (s) => selectedRole == null || s.allowedRoles.isEmpty || s.allowedRoles.contains(selectedRole), ) .toList(); // Ensure the currently selected shift is always available. if (!allowed.any((s) => s.id == selectedShift)) { final existing = shiftTypeConfigs.firstWhere( (s) => s.id == selectedShift, orElse: () => ShiftTypeConfig( id: selectedShift, label: selectedShift, startHour: 0, startMinute: 0, durationMinutes: 0, ), ); allowed.add(existing); } return AlertDialog( shape: AppSurfaces.of(context).dialogShape, title: const Text('Edit Schedule'), content: SingleChildScrollView( child: Column( mainAxisSize: MainAxisSize.min, children: [ DropdownButtonFormField( initialValue: selectedUserId, items: [ for (final p in staff) DropdownMenuItem( value: p.id, child: Text( p.fullName.isNotEmpty ? p.fullName : p.id, ), ), ], onChanged: (v) { if (v != null) setState(() => selectedUserId = v); }, decoration: const InputDecoration(labelText: 'Assignee'), ), const SizedBox(height: 12), DropdownButtonFormField( initialValue: selectedShift, items: [ for (final type in allowed) DropdownMenuItem( value: type.id, child: Text(type.label), ), ], onChanged: (v) { if (v != null) setState(() => selectedShift = v); }, decoration: const InputDecoration( labelText: 'Shift type', ), ), const SizedBox(height: 12), InkWell( onTap: () async { final picked = await showDatePicker( context: context, initialDate: selectedDate, firstDate: DateTime(2020), lastDate: DateTime(2100), ); if (picked != null) { setState(() => selectedDate = picked); } }, child: InputDecorator( decoration: const InputDecoration(labelText: 'Date'), child: Text(AppTime.formatDate(selectedDate)), ), ), const SizedBox(height: 12), InkWell( onTap: () async { final picked = await showTimePicker( context: context, initialTime: startTime, ); if (picked != null) { setState(() => startTime = picked); } }, child: InputDecorator( decoration: const InputDecoration( labelText: 'Start time', ), child: Text(startTime.format(context)), ), ), const SizedBox(height: 12), InkWell( onTap: () async { final picked = await showTimePicker( context: context, initialTime: endTime, ); if (picked != null) { setState(() => endTime = picked); } }, child: InputDecorator( decoration: const InputDecoration( labelText: 'End time', ), child: Text(endTime.format(context)), ), ), ], ), ), actions: [ TextButton( onPressed: () => Navigator.of(dialogContext).pop(false), child: const Text('Cancel'), ), FilledButton( onPressed: () => Navigator.of(dialogContext).pop(true), child: const Text('Save'), ), ], ); }, ); }, ); if (confirmed != true || !context.mounted) return; var 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)); } // ensure times are expressed in the app timezone (Asia/Manila) before // sending to the backend. previously these were raw local DateTimes which // caused off-by-offset errors when the device timezone differed. startDateTime = AppTime.toAppTime(startDateTime); endDateTime = AppTime.toAppTime(endDateTime); try { await ref .read(workforceControllerProvider) .updateSchedule( scheduleId: schedule.id, userId: selectedUserId, shiftType: selectedShift, startTime: startDateTime, endTime: endDateTime, ); ref.invalidate(dutySchedulesProvider); } catch (e) { if (!context.mounted) return; showErrorSnackBar(context, 'Update failed: $e'); } } 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) { final cs = Theme.of(context).colorScheme; switch (status) { case 'arrival': return cs.tertiary; case 'late': return cs.secondary; case 'absent': return cs.error; default: return cs.onSurfaceVariant; } } } class _DraftSchedule { _DraftSchedule({ required this.localId, required this.userId, required this.shiftType, required this.startTime, required this.endTime, this.isHoliday = false, List? relieverIds, }) : relieverIds = relieverIds ?? []; final int localId; String userId; String shiftType; DateTime startTime; DateTime endTime; List relieverIds; bool isHoliday; } 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(); } final isRamadan = ref.watch(isRamadanActiveProvider); return Card( child: Padding( padding: const EdgeInsets.all(16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Text( 'Schedule Generator', style: Theme.of(context).textTheme.titleMedium?.copyWith( fontWeight: FontWeight.w700, ), ), if (isRamadan) ...[ const SizedBox(width: 8), Chip( label: const Text('Ramadan'), avatar: const Icon(Icons.nights_stay, size: 16), backgroundColor: Theme.of( context, ).colorScheme.tertiaryContainer, labelStyle: TextStyle( color: Theme.of(context).colorScheme.onTertiaryContainer, ), ), ], const Spacer(), IconButton( tooltip: 'Rotation settings', icon: const Icon(Icons.settings_outlined), onPressed: () => showRotationSettings(context, ref), ), ], ), 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' : AppTime.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) { showWarningSnackBar(context, 'Select a date range.'); return; } if (end.isBefore(start)) { showWarningSnackBar(context, 'End date must be after start date.'); return; } setState(() => _isGenerating = true); try { final staff = _sortedStaff(); if (staff.isEmpty) { if (!mounted) return; showWarningSnackBar(context, 'No staff available for scheduling.'); return; } final rotationConfig = ref.read(rotationConfigProvider).valueOrNull; final schedules = ref.read(dutySchedulesProvider).valueOrNull ?? []; final shiftTypes = rotationConfig?.shiftTypes ?? RotationConfig().shiftTypes; final templates = _buildTemplates(schedules, shiftTypes); final generated = _generateDrafts( start, end, staff, schedules, templates, rotationConfig: rotationConfig, ); generated.sort((a, b) => a.startTime.compareTo(b.startTime)); final warnings = _buildWarnings(start, end, generated, rotationConfig); if (!mounted) return; setState(() { _draftSchedules = generated; _warnings = warnings; }); if (generated.isEmpty) { showInfoSnackBar(context, '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 ? '' : AppTime.formatDate(_startDate!); final end = _endDate == null ? '' : AppTime.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) { final rotationConfig = ref.watch(rotationConfigProvider).valueOrNull; 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, rotationConfig)} · $userLabel', style: Theme.of(context).textTheme.bodyMedium ?.copyWith(fontWeight: FontWeight.w600), ), const SizedBox(height: 4), Text( '${AppTime.formatDate(draft.startTime)} · ${AppTime.formatTime(draft.startTime)} - ${AppTime.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, ref.read(rotationConfigProvider).valueOrNull, ); }); } Future _openDraftEditor({_DraftSchedule? existing}) async { final staff = _sortedStaff(); if (staff.isEmpty) { showWarningSnackBar(context, '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 m3ShowDialog<_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: 'normal', child: Text('Normal 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: AppTime.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, ref.read(rotationConfigProvider).valueOrNull, ); }); } 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) { showWarningSnackBar(context, 'Select a date range.'); return; } final schedules = ref.read(dutySchedulesProvider).valueOrNull ?? []; final conflict = _findConflict(_draftSchedules, schedules); if (conflict != null) { showInfoSnackBar(context, 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; showSuccessSnackBar(context, 'Schedule committed.'); setState(() { _draftSchedules = []; _warnings = []; }); } catch (error) { if (!mounted) return; showErrorSnackBar(context, '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' || profile.role == 'admin' || profile.role == 'programmer' || profile.role == 'dispatcher', ) .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, List shiftTypes, ) { final templates = {}; // Add configured shift type templates first (source of truth). for (final shiftType in shiftTypes) { templates[shiftType.id] = _ShiftTemplate( startHour: shiftType.startHour, startMinute: shiftType.startMinute, duration: shiftType.duration, ); } // Fall back to observed schedule patterns for any missing shift types. 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), ); } return templates; } List<_DraftSchedule> _generateDrafts( DateTime start, DateTime end, List staff, List schedules, Map templates, { RotationConfig? rotationConfig, }) { final draft = <_DraftSchedule>[]; final normalizedStart = DateTime(start.year, start.month, start.day); final normalizedEnd = DateTime(end.year, end.month, end.day); final existing = schedules; final excludedIds = rotationConfig?.excludedStaffIds ?? []; final shiftTypes = rotationConfig?.shiftTypes ?? RotationConfig().shiftTypes; final roleWeeklyHours = rotationConfig?.roleWeeklyHours ?? {}; final holidayDates = (rotationConfig?.holidays ?? []) .map((h) => DateTime(h.date.year, h.date.month, h.date.day)) .toSet(); final userRoles = {for (final p in staff) p.id: p.role}; bool isShiftTypeAllowedForRole(String shiftTypeId, String role) { final config = shiftTypes.firstWhere( (s) => s.id == shiftTypeId, orElse: () => ShiftTypeConfig( id: shiftTypeId, label: shiftTypeId, startHour: 0, startMinute: 0, durationMinutes: 0, allowedRoles: const [], ), ); return config.allowedRoles.isEmpty || config.allowedRoles.contains(role); } int minutesInWeekForUser( String userId, DateTime weekStart, DateTime weekEnd, ) { int workMinutesForSchedule(dynamic schedule) { final duration = schedule.endTime.difference(schedule.startTime); final shiftType = shiftTypes.firstWhere( (s) => s.id == schedule.shiftType, orElse: () => ShiftTypeConfig( id: schedule.shiftType, label: schedule.shiftType, startHour: 0, startMinute: 0, durationMinutes: duration.inMinutes, ), ); final breakMinutes = shiftType.noonBreakMinutes; return (duration.inMinutes - breakMinutes) .clamp(0, duration.inMinutes) .toInt(); } var total = 0; for (final schedule in existing) { if (schedule.userId != userId) continue; if (schedule.startTime.isBefore(weekStart) || schedule.startTime.isAfter(weekEnd)) { continue; } total += workMinutesForSchedule(schedule); } for (final item in draft) { if (item.userId != userId) continue; if (item.startTime.isBefore(weekStart) || item.startTime.isAfter(weekEnd)) { continue; } total += workMinutesForSchedule(item); } return total; } bool canAddShift( String userId, String shiftTypeId, int shiftMinutes, DateTime weekStart, DateTime weekEnd, ) { final role = userRoles[userId]; if (role == null) return true; if (!isShiftTypeAllowedForRole(shiftTypeId, role)) { return false; } final capHours = roleWeeklyHours[role]; if (capHours == null || capHours <= 0) return true; final capMinutes = capHours * 60; final currentMinutes = minutesInWeekForUser(userId, weekStart, weekEnd); return currentMinutes + shiftMinutes <= capMinutes; } // Only IT Staff rotate through AM/PM/on_call shifts (minus excluded) final allItStaff = staff.where((p) => p.role == 'it_staff').toList(); final itStaff = allItStaff .where((p) => !excludedIds.contains(p.id)) .toList(); // Sort IT staff by configured rotation order if available if (rotationConfig != null && rotationConfig.rotationOrder.isNotEmpty) { final order = rotationConfig.rotationOrder; itStaff.sort((a, b) { final ai = order.indexOf(a.id); final bi = order.indexOf(b.id); final aIdx = ai == -1 ? order.length : ai; final bIdx = bi == -1 ? order.length : bi; return aIdx.compareTo(bIdx); }); } // Non-Islam IT staff for Friday AM rotation final fridayAmStaff = itStaff.where((p) => p.religion != 'islam').toList(); if (rotationConfig != null && rotationConfig.fridayAmOrder.isNotEmpty) { final order = rotationConfig.fridayAmOrder; fridayAmStaff.sort((a, b) { final ai = order.indexOf(a.id); final bi = order.indexOf(b.id); final aIdx = ai == -1 ? order.length : ai; final bIdx = bi == -1 ? order.length : bi; return aIdx.compareTo(bIdx); }); } // Admin/Dispatcher always get normal shift (no rotation) final nonRotating = staff.where((p) => p.role != 'it_staff').toList(); // Track Friday AM rotation index separately var fridayAmRotationIdx = 0; var isFirstWeek = true; 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, ), ), ]; // Rotation indices only for IT Staff int amBaseIndex; int pmBaseIndex; if (isFirstWeek && rotationConfig?.initialAmStaffId != null) { // Use configured initial AM final idx = itStaff.indexWhere( (p) => p.id == rotationConfig!.initialAmStaffId, ); amBaseIndex = idx != -1 ? idx : 0; } else { amBaseIndex = _nextIndexFromLastWeek( shiftType: 'am', staff: itStaff, lastWeek: lastWeek, defaultIndex: 0, ); } if (isFirstWeek && rotationConfig?.initialPmStaffId != null) { // Use configured initial PM final idx = itStaff.indexWhere( (p) => p.id == rotationConfig!.initialPmStaffId, ); pmBaseIndex = idx != -1 ? idx : (itStaff.length > 1 ? 2 : 0); } else { // PM duty is 2 positions after AM in the rotation to avoid conflicts final defaultPmIndex = itStaff.length > 2 ? (amBaseIndex + 2) % itStaff.length : (itStaff.length > 1 ? (amBaseIndex + 1) % itStaff.length : 0); pmBaseIndex = _nextIndexFromLastWeek( shiftType: 'pm', staff: itStaff, lastWeek: lastWeek, defaultIndex: defaultPmIndex, ); } isFirstWeek = false; final amUserId = itStaff.isEmpty ? null : itStaff[amBaseIndex % itStaff.length].id; final pmUserId = itStaff.isEmpty ? null : itStaff[pmBaseIndex % itStaff.length].id; final pmRelievers = _buildRelievers(pmBaseIndex, itStaff); void tryAddShift( String shiftType, String userId, DateTime day, List relieverIds, { String? displayShiftType, required bool isHoliday, }) { final template = templates[_normalizeShiftType(shiftType)]; if (template == null) return; final durationMinutes = template.duration.inMinutes; if (!canAddShift( userId, shiftType, durationMinutes, weekStart, weekEnd, )) { return; } _tryAddDraft( draft, existing, templates, shiftType, userId, day, relieverIds, displayShiftType: displayShiftType, isHoliday: isHoliday, ); } 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; final dayIsRamadan = isApproximateRamadan(day); final isHoliday = holidayDates.contains( DateTime(day.year, day.month, day.day), ); if (isWeekend) { final isSaturday = day.weekday == DateTime.saturday; if (isSaturday) { // Saturday: AM person gets normal shift, PM person gets weekend on_call if (amUserId != null) { tryAddShift( 'normal', amUserId, day, const [], isHoliday: isHoliday, ); } if (pmUserId != null) { tryAddShift( 'on_call_saturday', pmUserId, day, pmRelievers, isHoliday: isHoliday, ); } } else { // Sunday: PM person gets both normal and weekend on_call if (pmUserId != null) { tryAddShift( 'normal', pmUserId, day, const [], isHoliday: isHoliday, ); tryAddShift( 'on_call_sunday', pmUserId, day, pmRelievers, isHoliday: isHoliday, ); } } } else { // Weekday: IT Staff rotate AM/PM/on_call final isFriday = day.weekday == DateTime.friday; final profileMap = _profileById(); final amProfile = amUserId != null ? profileMap[amUserId] : null; // Friday AM: use separate non-Islam rotation String? effectiveAmUserId = amUserId; if (isFriday && (dayIsRamadan || true)) { // On Fridays, only non-Muslim IT Staff can take AM if (amProfile?.religion == 'islam') { // Use Friday AM rotation list if (fridayAmStaff.isNotEmpty) { effectiveAmUserId = fridayAmStaff[fridayAmRotationIdx % fridayAmStaff.length] .id; fridayAmRotationIdx++; } else { effectiveAmUserId = null; // No eligible staff } } } if (effectiveAmUserId != null) { tryAddShift( 'am', effectiveAmUserId, day, const [], isHoliday: isHoliday, ); } if (pmUserId != null) { tryAddShift('pm', pmUserId, day, pmRelievers, isHoliday: isHoliday); tryAddShift( 'on_call', pmUserId, day, pmRelievers, isHoliday: isHoliday, ); } // Remaining IT Staff (including excluded) get normal shift final assignedToday = [ effectiveAmUserId, pmUserId, ].whereType().toSet(); for (final profile in allItStaff) { if (assignedToday.contains(profile.id)) continue; final normalKey = dayIsRamadan && profile.religion == 'islam' ? 'normal_ramadan_islam' : dayIsRamadan ? 'normal_ramadan_other' : 'normal'; tryAddShift( normalKey, profile.id, day, const [], displayShiftType: 'normal', isHoliday: isHoliday, ); } // Admin/Dispatcher always get a role-specific shift (no rotation). String shiftTypeForRole(Profile profile) { // Exclude IT rotation shift types; those are handled above. const itRotationIds = { 'am', 'pm', 'on_call', 'on_call_saturday', 'on_call_sunday', }; final candidates = shiftTypes.where((s) { if (itRotationIds.contains(s.id)) return false; if (s.allowedRoles.isNotEmpty && !s.allowedRoles.contains(profile.role)) { return false; } return true; }).toList(); if (candidates.isEmpty) { // Fallback to the old "normal" behavior. if (dayIsRamadan) { return profile.religion == 'islam' ? 'normal_ramadan_islam' : 'normal_ramadan_other'; } return 'normal'; } if (dayIsRamadan) { final r = profile.religion == 'islam' ? 'ramadan_islam' : 'ramadan_other'; final match = candidates.firstWhere( (s) => s.id.contains(r), orElse: () => candidates.first, ); return match.id; } return candidates.first.id; } for (final profile in nonRotating) { final shiftKey = shiftTypeForRole(profile); tryAddShift( shiftKey, profile.id, day, const [], displayShiftType: shiftKey, isHoliday: isHoliday, ); } } } 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, { String? displayShiftType, bool isHoliday = false, }) { final template = templates[_normalizeShiftType(shiftType)]!; final start = template.buildStart(day); final end = template.buildEnd(start); final candidate = _DraftSchedule( localId: _draftCounter++, userId: userId, shiftType: displayShiftType ?? shiftType, startTime: start, endTime: end, isHoliday: isHoliday, 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 ${AppTime.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, RotationConfig? rotationConfig, ) { 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( '${AppTime.formatDate(day)} missing ${_shiftLabel(shift, rotationConfig)}', ); } } 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, RotationConfig? rotationConfig) { final configured = rotationConfig?.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; } } } 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 removedSwapIds = ref.watch(locallyRemovedSwapIdsProvider); final schedulesAsync = ref.watch(dutySchedulesProvider); final profilesAsync = ref.watch(profilesProvider); final rotationConfig = ref.watch(rotationConfigProvider).valueOrNull; 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: (allItems) { // Immediately exclude locally acted-on swap IDs while waiting for the // stream to catch up (avoids stale cards flashing back after invalidation). final items = allItems .where((s) => !removedSwapIds.contains(s.id)) .toList(); if (items.isEmpty) { return const Center(child: Text('No swap requests.')); } // If a swap references schedules that aren't in the current // `dutySchedulesProvider` (for example the requester owns the shift), // fetch those schedules by id so we can render shift details instead of // "Shift not found". final missingIds = items .expand( (s) => [ s.requesterScheduleId, if (s.targetScheduleId != null) s.targetScheduleId!, ], ) .where((id) => !scheduleById.containsKey(id)) .toSet() .toList(); final missingSchedules = ref.watch(dutySchedulesByIdsProvider(missingIds)).valueOrNull ?? []; for (final s in missingSchedules) { scheduleById[s.id] = s; } 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 requesterSchedule = scheduleById[item.requesterScheduleId]; final targetSchedule = item.targetScheduleId != null ? scheduleById[item.targetScheduleId!] : null; 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; String subtitle; if (requesterSchedule != null && targetSchedule != null) { subtitle = '${_shiftLabel(requesterSchedule.shiftType, rotationConfig)} · ${AppTime.formatDate(requesterSchedule.startTime)} · ${AppTime.formatTime(requesterSchedule.startTime)} → ${_shiftLabel(targetSchedule.shiftType, rotationConfig)} · ${AppTime.formatDate(targetSchedule.startTime)} · ${AppTime.formatTime(targetSchedule.startTime)}'; } else if (requesterSchedule != null) { subtitle = '${_shiftLabel(requesterSchedule.shiftType, rotationConfig)} · ${AppTime.formatDate(requesterSchedule.startTime)} · ${AppTime.formatTime(requesterSchedule.startTime)}'; } else if (item.shiftStartTime != null) { subtitle = '${_shiftLabel(item.shiftType ?? 'normal', rotationConfig)} · ${AppTime.formatDate(item.shiftStartTime!)} · ${AppTime.formatTime(item.shiftStartTime!)}'; } else { subtitle = 'Shift not found'; } final relieverLabels = requesterSchedule != null ? _relieverLabelsFromIds( requesterSchedule.relieverIds, profileById, ) : (item.relieverIds?.isNotEmpty == true ? item.relieverIds! .map((id) => profileById[id]?.fullName ?? id) .toList() : const []); final isPending = item.status == 'pending'; // Admins may act on regular pending swaps and also on escalated // swaps (status == 'admin_review'). Standard recipients can only // act when the swap is pending. final canRespond = (isPending && (isAdmin || item.recipientId == currentUserId)) || (isAdmin && item.status == 'admin_review'); 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 (isAdmin && item.status == 'admin_review') ...[ const SizedBox(width: 8), OutlinedButton( onPressed: () => _changeRecipient(context, ref, item), child: const Text('Change recipient'), ), ], if (canEscalate) ...[ const SizedBox(width: 8), OutlinedButton( onPressed: () => _respond(ref, item, 'admin_review'), child: const Text('Escalate'), ), ], ], ), if (item.chatThreadId != null) _SwapChatSection( threadId: item.chatThreadId!, currentUserId: currentUserId ?? '', profileById: profileById, ), ], ), ), ); }, ); }, 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.read(locallyRemovedSwapIdsProvider.notifier).update((s) => {...s, request.id}); ref.invalidate(swapRequestsProvider); ref.invalidate(dutySchedulesProvider); // Send push notifications with date context final notificationsController = ref.read(notificationsControllerProvider); final shiftDate = request.shiftStartTime != null ? AppTime.formatDate(request.shiftStartTime!) : 'the shift'; if (action == 'accepted') { await notificationsController.sendPush( userIds: [request.requesterId, request.recipientId], title: 'Swap approved', body: 'An admin approved the swap for $shiftDate.', data: {'type': 'swap_update', 'navigate_to': '/attendance'}, ); } else if (action == 'rejected') { await notificationsController.sendPush( userIds: [request.requesterId], title: 'Swap rejected by admin', body: 'An admin rejected your swap request for $shiftDate.', data: {'type': 'swap_update', 'navigate_to': '/attendance'}, ); } } Future _changeRecipient( BuildContext context, WidgetRef ref, SwapRequest request, ) async { final profiles = ref.watch(profilesProvider).valueOrNull ?? []; final eligible = profiles .where( (p) => p.id != request.requesterId && p.id != request.recipientId, ) .toList(); if (eligible.isEmpty) { // nothing to choose from return; } Profile? choice = eligible.first; final selected = await m3ShowDialog( context: context, builder: (context) { return AlertDialog( title: const Text('Change recipient'), content: StatefulBuilder( builder: (context, setState) => DropdownButtonFormField( initialValue: choice, items: eligible .map( (p) => DropdownMenuItem(value: p, child: Text(p.fullName)), ) .toList(), onChanged: (v) => setState(() => choice = v), ), ), actions: [ TextButton( onPressed: () => Navigator.of(context).pop(null), child: const Text('Cancel'), ), TextButton( onPressed: () => Navigator.of(context).pop(choice), child: const Text('Save'), ), ], ); }, ); if (selected == null) return; await ref .read(workforceControllerProvider) .reassignSwap(swapId: request.id, newRecipientId: selected.id); ref.invalidate(swapRequestsProvider); } String _shiftLabel(String value, RotationConfig? rotationConfig) { final configured = rotationConfig?.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; } } 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(); } } /// Expandable chat section within a swap request card. class _SwapChatSection extends ConsumerStatefulWidget { const _SwapChatSection({ required this.threadId, required this.currentUserId, required this.profileById, }); final String threadId; final String currentUserId; final Map profileById; @override ConsumerState<_SwapChatSection> createState() => _SwapChatSectionState(); } class _SwapChatSectionState extends ConsumerState<_SwapChatSection> { final _msgController = TextEditingController(); bool _expanded = false; @override void dispose() { _msgController.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return Column( children: [ InkWell( onTap: () => setState(() => _expanded = !_expanded), child: Padding( padding: const EdgeInsets.symmetric(vertical: 8), child: Row( children: [ const Icon(Icons.chat_bubble_outline, size: 18), const SizedBox(width: 8), const Text('Chat'), const Spacer(), Icon( _expanded ? Icons.expand_less : Icons.expand_more, size: 20, ), ], ), ), ), if (_expanded) _buildChatBody(context), ], ); } Widget _buildChatBody(BuildContext context) { final messagesAsync = ref.watch(chatMessagesProvider(widget.threadId)); return messagesAsync.when( data: (messages) { return Column( children: [ ConstrainedBox( constraints: const BoxConstraints(maxHeight: 200), child: messages.isEmpty ? const Padding( padding: EdgeInsets.all(12), child: Text('No messages yet.'), ) : ListView.builder( reverse: true, shrinkWrap: true, padding: const EdgeInsets.symmetric(vertical: 4), itemCount: messages.length, itemBuilder: (context, index) { final msg = messages[index]; final isMe = msg.senderId == widget.currentUserId; final sender = widget.profileById[msg.senderId]?.fullName ?? 'Unknown'; return Align( alignment: isMe ? Alignment.centerRight : Alignment.centerLeft, child: Container( margin: const EdgeInsets.symmetric( horizontal: 8, vertical: 2, ), padding: const EdgeInsets.symmetric( horizontal: 12, vertical: 8, ), decoration: BoxDecoration( color: isMe ? Theme.of( context, ).colorScheme.primaryContainer : Theme.of( context, ).colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(12), ), child: Column( crossAxisAlignment: isMe ? CrossAxisAlignment.end : CrossAxisAlignment.start, children: [ if (!isMe) Text( sender, style: Theme.of(context) .textTheme .labelSmall ?.copyWith(fontWeight: FontWeight.w600), ), Text(msg.body), ], ), ), ); }, ), ), const SizedBox(height: 4), Row( children: [ Expanded( child: TextField( controller: _msgController, decoration: const InputDecoration( hintText: 'Type a message...', isDense: true, contentPadding: EdgeInsets.symmetric( horizontal: 12, vertical: 8, ), ), onSubmitted: (_) => _send(), ), ), const SizedBox(width: 4), IconButton( onPressed: _send, icon: const Icon(Icons.send), iconSize: 20, ), ], ), ], ); }, loading: () => const Center(child: CircularProgressIndicator(strokeWidth: 2)), error: (e, _) => Text('Chat error: $e'), ); } Future _send() async { final body = _msgController.text.trim(); if (body.isEmpty) return; _msgController.clear(); await ref .read(chatControllerProvider) .sendMessage(threadId: widget.threadId, body: body); } }