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/swap_request.dart'; import '../../providers/profile_provider.dart'; import '../../providers/workforce_provider.dart'; import '../../providers/chat_provider.dart'; import '../../providers/ramadan_provider.dart'; import '../../widgets/responsive_body.dart'; import '../../theme/app_surfaces.dart'; import '../../utils/snackbar.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); 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); final schedules = showPast ? allSchedules : allSchedules .where((s) => !s.endTime.isBefore(today)) .toList(); 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( '${_dayOfWeek(day)}, ${AppTime.formatDate(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, isAdmin: isAdmin, ), ), ], ), ); }, ); }, loading: () => const Center(child: CircularProgressIndicator()), error: (error, _) => Center(child: Text('Failed to load schedules: $error')), ), ), ], ); } String _scheduleName( Map profileById, DutySchedule schedule, bool isAdmin, ) { final profile = profileById[schedule.userId]; final name = profile?.fullName.isNotEmpty == true ? profile!.fullName : schedule.userId; return '${_shiftLabel(schedule.shiftType)} · $name'; } static String _dayOfWeek(DateTime day) { const days = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']; return days[day.weekday - 1]; } 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; } } 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, }); final DutySchedule schedule; final String displayName; final List relieverLabels; final bool isMine; final bool isAdmin; @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 hasRequestedSwap = swaps.any( (swap) => swap.requesterScheduleId == 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( '${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), ), ), ], ), ), Column( crossAxisAlignment: CrossAxisAlignment.end, children: [ if (isAdmin) IconButton( tooltip: 'Edit schedule', onPressed: () => _editSchedule(context, ref), icon: const Icon(Icons.edit, size: 20), ), if (canRequestSwap) 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 _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 confirmed = await m3ShowDialog( context: context, builder: (dialogContext) { return StatefulBuilder( builder: (context, setState) { 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: const [ DropdownMenuItem(value: 'am', child: Text('AM Duty')), DropdownMenuItem(value: 'pm', child: Text('PM Duty')), DropdownMenuItem( value: 'on_call', child: Text('On Call'), ), DropdownMenuItem( value: 'normal', child: Text('Normal'), ), ], 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; 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)); } 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'); } } 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.', type: SnackType.warning, ); return; } String? selectedId = staff.first.id; List recipientShifts = []; String? selectedTargetShiftId; final confirmed = await m3ShowDialog( context: context, builder: (dialogContext) { return StatefulBuilder( builder: (context, setState) { // initial load for the first recipient shown — only upcoming shifts if (recipientShifts.isEmpty && selectedId != null) { ref .read(dutySchedulesForUserProvider(selectedId!).future) .then((shifts) { final now = AppTime.now(); final upcoming = shifts.where((s) => !s.startTime.isBefore(now)).toList() ..sort((a, b) => a.startTime.compareTo(b.startTime)); setState(() { recipientShifts = upcoming; selectedTargetShiftId = upcoming.isNotEmpty ? upcoming.first.id : null; }); }) .catchError((_) {}); } return AlertDialog( shape: AppSurfaces.of(context).dialogShape, title: const Text('Request swap'), content: Column( mainAxisSize: MainAxisSize.min, children: [ DropdownButtonFormField( initialValue: selectedId, items: [ for (final profile in staff) DropdownMenuItem( value: profile.id, child: Text( profile.fullName.isNotEmpty ? profile.fullName : profile.id, ), ), ], onChanged: (value) async { if (value == null) return; setState(() => selectedId = value); // load recipient shifts (only show upcoming) final shifts = await ref .read(dutySchedulesForUserProvider(value).future) .catchError((_) => []); final now = AppTime.now(); final upcoming = shifts .where((s) => !s.startTime.isBefore(now)) .toList() ..sort( (a, b) => a.startTime.compareTo(b.startTime), ); setState(() { recipientShifts = upcoming; selectedTargetShiftId = upcoming.isNotEmpty ? upcoming.first.id : null; }); }, decoration: const InputDecoration(labelText: 'Recipient'), ), const SizedBox(height: 12), DropdownButtonFormField( initialValue: selectedTargetShiftId, items: [ for (final s in recipientShifts) DropdownMenuItem( value: s.id, child: Text( '${s.shiftType == 'am' ? 'AM Duty' : s.shiftType == 'pm' ? 'PM Duty' : s.shiftType} · ${AppTime.formatDate(s.startTime)} · ${AppTime.formatTime(s.startTime)}', ), ), ], onChanged: (value) => setState(() => selectedTargetShiftId = value), decoration: const InputDecoration( labelText: 'Recipient shift', ), ), ], ), 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 || selectedTargetShiftId == null) { return; } try { await ref .read(workforceControllerProvider) .requestSwap( requesterScheduleId: schedule.id, targetScheduleId: selectedTargetShiftId!, recipientId: selectedId!, ); ref.invalidate(swapRequestsProvider); if (!context.mounted) return; _showMessage(context, 'Swap request sent.', type: SnackType.success); } catch (error) { if (!context.mounted) return; _showMessage( context, 'Swap request failed: $error', type: SnackType.error, ); } } void _showMessage( BuildContext context, String message, { SnackType type = SnackType.warning, }) { switch (type) { case SnackType.success: showSuccessSnackBar(context, message); break; case SnackType.error: showErrorSnackBar(context, message); break; case SnackType.info: showInfoSnackBar(context, message); break; case SnackType.warning: showWarningSnackBar(context, message); break; } } 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.', type: SnackType.info, ); } 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(); } 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 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 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) { 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) { 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( '${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, ); }); } 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, ); }); } 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 == '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) { 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), ); // Default normal shift (8am-5pm = 9 hours) templates['normal'] = _ShiftTemplate( startHour: 8, startMinute: 0, duration: const Duration(hours: 9), ); // Islam Ramadan normal shift (8am-4pm = 8 hours) templates['normal_ramadan_islam'] = _ShiftTemplate( startHour: 8, startMinute: 0, duration: const Duration(hours: 8), ); // Non-Islam Ramadan normal shift (8am-5pm = 9 hours, same as default) templates['normal_ramadan_other'] = _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; // Only IT Staff rotate through AM/PM/on_call shifts final itStaff = staff.where((p) => p.role == 'it_staff').toList(); // Admin/Dispatcher always get normal shift (no rotation) final nonRotating = staff.where((p) => p.role != 'it_staff').toList(); 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 final amBaseIndex = _nextIndexFromLastWeek( shiftType: 'am', staff: itStaff, lastWeek: lastWeek, defaultIndex: 0, ); final pmBaseIndex = _nextIndexFromLastWeek( shiftType: 'pm', staff: itStaff, lastWeek: lastWeek, defaultIndex: itStaff.length > 1 ? 1 : 0, ); final amUserId = itStaff.isEmpty ? null : itStaff[amBaseIndex % itStaff.length].id; final pmUserId = itStaff.isEmpty ? null : itStaff[pmBaseIndex % itStaff.length].id; final nextWeekPmUserId = itStaff.isEmpty ? null : itStaff[(pmBaseIndex + 1) % itStaff.length].id; final pmRelievers = _buildRelievers(pmBaseIndex, itStaff); final nextWeekRelievers = itStaff.isEmpty ? [] : _buildRelievers((pmBaseIndex + 1) % itStaff.length, itStaff); 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; final dayIsRamadan = isApproximateRamadan(day); if (isWeekend) { // Weekend: only IT Staff get normal + on_call (rotating) if (itStaff.isNotEmpty) { final normalIndex = (amBaseIndex + pmBaseIndex + weekendNormalOffset) % itStaff.length; _tryAddDraft( draft, existing, templates, 'normal', itStaff[normalIndex].id, day, const [], ); weekendNormalOffset += 1; } if (nextWeekPmUserId != null) { _tryAddDraft( draft, existing, templates, 'on_call', nextWeekPmUserId, day, nextWeekRelievers, ); } } 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; final skipAmForRamadan = dayIsRamadan && isFriday && amProfile?.religion == 'islam'; if (amUserId != null && !skipAmForRamadan) { _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, ); } // Remaining IT Staff get normal shift final assignedToday = [ amUserId, pmUserId, ].whereType().toSet(); for (final profile in itStaff) { if (assignedToday.contains(profile.id)) continue; final normalKey = dayIsRamadan && profile.religion == 'islam' ? 'normal_ramadan_islam' : dayIsRamadan ? 'normal_ramadan_other' : 'normal'; _tryAddDraft( draft, existing, templates, normalKey, profile.id, day, const [], displayShiftType: 'normal', ); } // Admin/Dispatcher always get normal shift (no rotation) for (final profile in nonRotating) { final normalKey = dayIsRamadan && profile.religion == 'islam' ? 'normal_ramadan_islam' : dayIsRamadan ? 'normal_ramadan_other' : 'normal'; _tryAddDraft( draft, existing, templates, normalKey, profile.id, day, const [], displayShiftType: 'normal', ); } } } 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, }) { 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, 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, ) { 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)}', ); } } 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; } } } 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.')); } // 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)} · ${AppTime.formatDate(requesterSchedule.startTime)} · ${AppTime.formatTime(requesterSchedule.startTime)} → ${_shiftLabel(targetSchedule.shiftType)} · ${AppTime.formatDate(targetSchedule.startTime)} · ${AppTime.formatTime(targetSchedule.startTime)}'; } else if (requesterSchedule != null) { subtitle = '${_shiftLabel(requesterSchedule.shiftType)} · ${AppTime.formatDate(requesterSchedule.startTime)} · ${AppTime.formatTime(requesterSchedule.startTime)}'; } else if (item.shiftStartTime != null) { subtitle = '${_shiftLabel(item.shiftType ?? 'normal')} · ${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.invalidate(swapRequestsProvider); } 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) { 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); } }