import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../models/profile.dart'; import '../../models/rotation_config.dart'; import '../../providers/profile_provider.dart'; import '../../providers/rotation_config_provider.dart'; import '../../theme/app_surfaces.dart'; import '../../theme/m3_motion.dart'; import '../../utils/snackbar.dart'; /// Opens rotation settings as a dialog (desktop/tablet) or bottom sheet /// (mobile) based on screen width. Future showRotationSettings(BuildContext context, WidgetRef ref) async { final isWide = MediaQuery.of(context).size.width >= 600; if (isWide) { await m3ShowDialog( context: context, builder: (_) => const _RotationSettingsDialog(), ); } else { await m3ShowBottomSheet( context: context, isScrollControlled: true, builder: (_) => DraggableScrollableSheet( expand: false, initialChildSize: 0.85, maxChildSize: 0.95, minChildSize: 0.5, builder: (context, controller) => _RotationSettingsSheet(scrollController: controller), ), ); } } // ═══════════════════════════════════════════════════════════════ // Dialog variant (desktop / tablet) // ═══════════════════════════════════════════════════════════════ class _RotationSettingsDialog extends ConsumerStatefulWidget { const _RotationSettingsDialog(); @override ConsumerState<_RotationSettingsDialog> createState() => _RotationSettingsDialogState(); } class _RotationSettingsDialogState extends ConsumerState<_RotationSettingsDialog> with SingleTickerProviderStateMixin { late TabController _tabCtrl; @override void initState() { super.initState(); _tabCtrl = TabController(length: 4, vsync: this); } @override void dispose() { _tabCtrl.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final surfaces = AppSurfaces.of(context); return Dialog( shape: surfaces.dialogShape, clipBehavior: Clip.antiAlias, child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 560, maxHeight: 620), child: Column( mainAxisSize: MainAxisSize.min, children: [ _buildHeader(context), TabBar( controller: _tabCtrl, isScrollable: true, tabAlignment: TabAlignment.start, tabs: const [ Tab(text: 'Rotation Order'), Tab(text: 'Friday AM'), Tab(text: 'Excluded'), Tab(text: 'Initial Duty'), ], ), Flexible( child: TabBarView( controller: _tabCtrl, children: const [ _RotationOrderTab(), _FridayAmTab(), _ExcludedTab(), _InitialDutyTab(), ], ), ), ], ), ), ); } Widget _buildHeader(BuildContext context) { return Padding( padding: const EdgeInsets.fromLTRB(24, 20, 8, 4), child: Row( children: [ Icon( Icons.settings_outlined, color: Theme.of(context).colorScheme.primary, ), const SizedBox(width: 12), Expanded( child: Text( 'Rotation Settings', style: Theme.of(context).textTheme.titleLarge, ), ), IconButton( onPressed: () => Navigator.of(context).pop(), icon: const Icon(Icons.close), ), ], ), ); } } // ═══════════════════════════════════════════════════════════════ // Bottom Sheet variant (mobile) // ═══════════════════════════════════════════════════════════════ class _RotationSettingsSheet extends ConsumerStatefulWidget { const _RotationSettingsSheet({required this.scrollController}); final ScrollController scrollController; @override ConsumerState<_RotationSettingsSheet> createState() => _RotationSettingsSheetState(); } class _RotationSettingsSheetState extends ConsumerState<_RotationSettingsSheet> with SingleTickerProviderStateMixin { late TabController _tabCtrl; @override void initState() { super.initState(); _tabCtrl = TabController(length: 4, vsync: this); } @override void dispose() { _tabCtrl.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return Column( children: [ Padding( padding: const EdgeInsets.fromLTRB(24, 8, 8, 0), child: Row( children: [ Icon( Icons.settings_outlined, color: Theme.of(context).colorScheme.primary, ), const SizedBox(width: 12), Expanded( child: Text( 'Rotation Settings', style: Theme.of(context).textTheme.titleLarge, ), ), IconButton( onPressed: () => Navigator.of(context).pop(), icon: const Icon(Icons.close), ), ], ), ), TabBar( controller: _tabCtrl, isScrollable: true, tabAlignment: TabAlignment.start, tabs: const [ Tab(text: 'Rotation Order'), Tab(text: 'Friday AM'), Tab(text: 'Excluded'), Tab(text: 'Initial Duty'), ], ), Expanded( child: TabBarView( controller: _tabCtrl, children: const [ _RotationOrderTab(), _FridayAmTab(), _ExcludedTab(), _InitialDutyTab(), ], ), ), ], ); } } // ═══════════════════════════════════════════════════════════════ // Tab 1: IT Staff rotation order // ═══════════════════════════════════════════════════════════════ class _RotationOrderTab extends ConsumerStatefulWidget { const _RotationOrderTab(); @override ConsumerState<_RotationOrderTab> createState() => _RotationOrderTabState(); } class _RotationOrderTabState extends ConsumerState<_RotationOrderTab> { List? _order; bool _saving = false; List _itStaff() { return (ref.read(profilesProvider).valueOrNull ?? []) .where((p) => p.role == 'it_staff') .toList(); } void _ensureOrder() { if (_order != null) return; final config = ref.read(rotationConfigProvider).valueOrNull; final allIt = _itStaff(); if (config != null && config.rotationOrder.isNotEmpty) { // Start with saved order, append any new staff not yet in order final order = [...config.rotationOrder]; for (final s in allIt) { if (!order.contains(s.id)) order.add(s.id); } // Remove staff who no longer exist order.removeWhere((id) => !allIt.any((p) => p.id == id)); _order = order; } else { _order = allIt.map((p) => p.id).toList(); } } @override Widget build(BuildContext context) { final configAsync = ref.watch(rotationConfigProvider); final profilesAsync = ref.watch(profilesProvider); if (configAsync.isLoading || profilesAsync.isLoading) { return const Center(child: CircularProgressIndicator()); } _ensureOrder(); final allIt = _itStaff(); final profileMap = {for (final p in allIt) p.id: p}; final order = _order!; return Column( children: [ Padding( padding: const EdgeInsets.fromLTRB(16, 12, 16, 4), child: Text( 'Drag to reorder the IT Staff AM/PM duty rotation sequence.', style: Theme.of(context).textTheme.bodySmall?.copyWith( color: Theme.of(context).colorScheme.onSurfaceVariant, ), ), ), Expanded( child: ReorderableListView.builder( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), itemCount: order.length, onReorder: (oldIndex, newIndex) { setState(() { if (newIndex > oldIndex) newIndex--; final item = order.removeAt(oldIndex); order.insert(newIndex, item); }); }, itemBuilder: (context, index) { final id = order[index]; final profile = profileMap[id]; final name = profile?.fullName ?? id; return M3FadeSlideIn( key: ValueKey(id), delay: Duration(milliseconds: 40 * index), child: ListTile( leading: CircleAvatar( backgroundColor: Theme.of( context, ).colorScheme.primaryContainer, child: Text( '${index + 1}', style: TextStyle( color: Theme.of(context).colorScheme.onPrimaryContainer, fontWeight: FontWeight.w600, ), ), ), title: Text(name), trailing: ReorderableDragStartListener( index: index, child: const Icon(Icons.drag_handle), ), ), ); }, ), ), _SaveButton(saving: _saving, onSave: () => _save(context)), ], ); } Future _save(BuildContext context) async { setState(() => _saving = true); try { final current = ref.read(rotationConfigProvider).valueOrNull ?? RotationConfig(); await ref .read(rotationConfigControllerProvider) .save(current.copyWith(rotationOrder: _order)); if (mounted) showSuccessSnackBar(this.context, 'Rotation order saved.'); } catch (e) { if (mounted) showErrorSnackBar(this.context, 'Save failed: $e'); } finally { if (mounted) setState(() => _saving = false); } } } // ═══════════════════════════════════════════════════════════════ // Tab 2: Friday AM rotation (non-Islam staff) // ═══════════════════════════════════════════════════════════════ class _FridayAmTab extends ConsumerStatefulWidget { const _FridayAmTab(); @override ConsumerState<_FridayAmTab> createState() => _FridayAmTabState(); } class _FridayAmTabState extends ConsumerState<_FridayAmTab> { List? _order; bool _saving = false; List _nonIslamIt() { return (ref.read(profilesProvider).valueOrNull ?? []) .where((p) => p.role == 'it_staff' && p.religion != 'islam') .toList(); } void _ensureOrder() { if (_order != null) return; final config = ref.read(rotationConfigProvider).valueOrNull; final eligible = _nonIslamIt(); if (config != null && config.fridayAmOrder.isNotEmpty) { final order = [...config.fridayAmOrder]; for (final s in eligible) { if (!order.contains(s.id)) order.add(s.id); } order.removeWhere((id) => !eligible.any((p) => p.id == id)); _order = order; } else { _order = eligible.map((p) => p.id).toList(); } } @override Widget build(BuildContext context) { final configAsync = ref.watch(rotationConfigProvider); final profilesAsync = ref.watch(profilesProvider); if (configAsync.isLoading || profilesAsync.isLoading) { return const Center(child: CircularProgressIndicator()); } _ensureOrder(); final eligible = _nonIslamIt(); final profileMap = {for (final p in eligible) p.id: p}; final order = _order!; return Column( children: [ Padding( padding: const EdgeInsets.fromLTRB(16, 12, 16, 4), child: Text( 'On Fridays, only non-Muslim IT Staff can take AM duty. ' 'Drag to set the rotation order.', style: Theme.of(context).textTheme.bodySmall?.copyWith( color: Theme.of(context).colorScheme.onSurfaceVariant, ), ), ), if (order.isEmpty) Expanded( child: Center( child: Text( 'No eligible staff found.', style: Theme.of(context).textTheme.bodyMedium, ), ), ) else Expanded( child: ReorderableListView.builder( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), itemCount: order.length, onReorder: (oldIndex, newIndex) { setState(() { if (newIndex > oldIndex) newIndex--; final item = order.removeAt(oldIndex); order.insert(newIndex, item); }); }, itemBuilder: (context, index) { final id = order[index]; final profile = profileMap[id]; final name = profile?.fullName ?? id; return M3FadeSlideIn( key: ValueKey(id), delay: Duration(milliseconds: 40 * index), child: ListTile( leading: CircleAvatar( backgroundColor: Theme.of( context, ).colorScheme.secondaryContainer, child: Text( '${index + 1}', style: TextStyle( color: Theme.of( context, ).colorScheme.onSecondaryContainer, fontWeight: FontWeight.w600, ), ), ), title: Text(name), trailing: ReorderableDragStartListener( index: index, child: const Icon(Icons.drag_handle), ), ), ); }, ), ), _SaveButton(saving: _saving, onSave: () => _save(context)), ], ); } Future _save(BuildContext context) async { setState(() => _saving = true); try { final current = ref.read(rotationConfigProvider).valueOrNull ?? RotationConfig(); await ref .read(rotationConfigControllerProvider) .save(current.copyWith(fridayAmOrder: _order)); if (mounted) showSuccessSnackBar(this.context, 'Friday AM order saved.'); } catch (e) { if (mounted) showErrorSnackBar(this.context, 'Save failed: $e'); } finally { if (mounted) setState(() => _saving = false); } } } // ═══════════════════════════════════════════════════════════════ // Tab 3: Excluded Staff // ═══════════════════════════════════════════════════════════════ class _ExcludedTab extends ConsumerStatefulWidget { const _ExcludedTab(); @override ConsumerState<_ExcludedTab> createState() => _ExcludedTabState(); } class _ExcludedTabState extends ConsumerState<_ExcludedTab> { Set? _excluded; bool _saving = false; List _itStaff() { return (ref.read(profilesProvider).valueOrNull ?? []) .where((p) => p.role == 'it_staff') .toList(); } void _ensureExcluded() { if (_excluded != null) return; final config = ref.read(rotationConfigProvider).valueOrNull; _excluded = config != null ? Set.from(config.excludedStaffIds) : {}; } @override Widget build(BuildContext context) { final configAsync = ref.watch(rotationConfigProvider); final profilesAsync = ref.watch(profilesProvider); if (configAsync.isLoading || profilesAsync.isLoading) { return const Center(child: CircularProgressIndicator()); } _ensureExcluded(); final allIt = _itStaff(); final excluded = _excluded!; return Column( children: [ Padding( padding: const EdgeInsets.fromLTRB(16, 12, 16, 4), child: Text( 'Toggle to exclude IT Staff from duty rotation. ' 'Excluded staff will not be assigned AM/PM/On-Call shifts.', style: Theme.of(context).textTheme.bodySmall?.copyWith( color: Theme.of(context).colorScheme.onSurfaceVariant, ), ), ), Expanded( child: ListView.builder( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), itemCount: allIt.length, itemBuilder: (context, index) { final profile = allIt[index]; final isExcluded = excluded.contains(profile.id); return M3FadeSlideIn( key: ValueKey(profile.id), delay: Duration(milliseconds: 40 * index), child: SwitchListTile( title: Text( profile.fullName.isNotEmpty ? profile.fullName : profile.id, ), subtitle: Text( isExcluded ? 'Excluded' : 'Included in rotation', style: TextStyle( color: isExcluded ? Theme.of(context).colorScheme.error : Theme.of(context).colorScheme.onSurfaceVariant, ), ), value: isExcluded, onChanged: (val) { setState(() { if (val) { excluded.add(profile.id); } else { excluded.remove(profile.id); } }); }, ), ); }, ), ), _SaveButton(saving: _saving, onSave: () => _save(context)), ], ); } Future _save(BuildContext context) async { setState(() => _saving = true); try { final current = ref.read(rotationConfigProvider).valueOrNull ?? RotationConfig(); await ref .read(rotationConfigControllerProvider) .save(current.copyWith(excludedStaffIds: _excluded!.toList())); if (mounted) showSuccessSnackBar(this.context, 'Exclusions saved.'); } catch (e) { if (mounted) showErrorSnackBar(this.context, 'Save failed: $e'); } finally { if (mounted) setState(() => _saving = false); } } } // ═══════════════════════════════════════════════════════════════ // Tab 4: Initial AM/PM duty // ═══════════════════════════════════════════════════════════════ class _InitialDutyTab extends ConsumerStatefulWidget { const _InitialDutyTab(); @override ConsumerState<_InitialDutyTab> createState() => _InitialDutyTabState(); } class _InitialDutyTabState extends ConsumerState<_InitialDutyTab> { String? _amId; String? _pmId; bool _initialised = false; bool _saving = false; List _itStaff() { final config = ref.read(rotationConfigProvider).valueOrNull; final excluded = config?.excludedStaffIds ?? []; return (ref.read(profilesProvider).valueOrNull ?? []) .where((p) => p.role == 'it_staff' && !excluded.contains(p.id)) .toList(); } void _ensureInit() { if (_initialised) return; final config = ref.read(rotationConfigProvider).valueOrNull; _amId = config?.initialAmStaffId; _pmId = config?.initialPmStaffId; _initialised = true; } @override Widget build(BuildContext context) { final configAsync = ref.watch(rotationConfigProvider); final profilesAsync = ref.watch(profilesProvider); if (configAsync.isLoading || profilesAsync.isLoading) { return const Center(child: CircularProgressIndicator()); } _ensureInit(); final staff = _itStaff(); final colorScheme = Theme.of(context).colorScheme; // Compute implied PM hint based on rotation order String? impliedPmHint; if (_amId != null) { final config = ref.read(rotationConfigProvider).valueOrNull; final order = config?.rotationOrder ?? staff.map((p) => p.id).toList(); if (order.length >= 3) { final amIdx = order.indexOf(_amId!); if (amIdx != -1) { // PM is 2 positions after AM in the rotation order final pmIdx = (amIdx + 2) % order.length; final pmProfile = staff .where((p) => p.id == order[pmIdx]) .firstOrNull; if (pmProfile != null) { impliedPmHint = 'Based on rotation: PM would be ${pmProfile.fullName}'; } } } } return SingleChildScrollView( padding: const EdgeInsets.all(16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( 'Set the starting AM and PM duty assignment for the next ' 'generated schedule. The rotation will continue from these ' 'positions.', style: Theme.of(context).textTheme.bodySmall?.copyWith( color: colorScheme.onSurfaceVariant, ), ), const SizedBox(height: 8), Container( padding: const EdgeInsets.all(12), decoration: BoxDecoration( color: colorScheme.tertiaryContainer, borderRadius: BorderRadius.circular(12), ), child: Row( children: [ Icon( Icons.info_outline, size: 18, color: colorScheme.onTertiaryContainer, ), const SizedBox(width: 8), Expanded( child: Text( 'AM and PM duty rotate with a gap of 2 positions. ' 'e.g. If Staff A (pos 1) takes AM, Staff C (pos 3) takes PM.', style: Theme.of(context).textTheme.bodySmall?.copyWith( color: colorScheme.onTertiaryContainer, ), ), ), ], ), ), const SizedBox(height: 20), Text( 'Initial AM Duty', style: Theme.of(context).textTheme.titleSmall, ), const SizedBox(height: 8), DropdownButtonFormField( initialValue: _amId, decoration: const InputDecoration( labelText: 'AM Staff', border: OutlineInputBorder(), ), items: [ const DropdownMenuItem( value: null, child: Text('Auto (continue from last week)'), ), for (final s in staff) DropdownMenuItem( value: s.id, child: Text(s.fullName.isNotEmpty ? s.fullName : s.id), ), ], onChanged: (v) => setState(() => _amId = v), ), const SizedBox(height: 20), Text( 'Initial PM Duty', style: Theme.of(context).textTheme.titleSmall, ), const SizedBox(height: 8), DropdownButtonFormField( initialValue: _pmId, decoration: const InputDecoration( labelText: 'PM Staff', border: OutlineInputBorder(), ), items: [ const DropdownMenuItem( value: null, child: Text('Auto (continue from last week)'), ), for (final s in staff) DropdownMenuItem( value: s.id, child: Text(s.fullName.isNotEmpty ? s.fullName : s.id), ), ], onChanged: (v) => setState(() => _pmId = v), ), if (impliedPmHint != null) ...[ const SizedBox(height: 8), Text( impliedPmHint, style: Theme.of(context).textTheme.bodySmall?.copyWith( color: colorScheme.primary, fontStyle: FontStyle.italic, ), ), ], const SizedBox(height: 24), _SaveButton(saving: _saving, onSave: () => _save(context)), ], ), ); } Future _save(BuildContext context) async { setState(() => _saving = true); try { final current = ref.read(rotationConfigProvider).valueOrNull ?? RotationConfig(); await ref .read(rotationConfigControllerProvider) .save( current.copyWith( initialAmStaffId: _amId, initialPmStaffId: _pmId, clearInitialAm: _amId == null, clearInitialPm: _pmId == null, ), ); if (mounted) showSuccessSnackBar(this.context, 'Initial duty saved.'); } catch (e) { if (mounted) showErrorSnackBar(this.context, 'Save failed: $e'); } finally { if (mounted) setState(() => _saving = false); } } } // ═══════════════════════════════════════════════════════════════ // Shared Save Button // ═══════════════════════════════════════════════════════════════ class _SaveButton extends StatelessWidget { const _SaveButton({required this.saving, required this.onSave}); final bool saving; final VoidCallback onSave; @override Widget build(BuildContext context) { return Padding( padding: const EdgeInsets.fromLTRB(16, 8, 16, 16), child: SizedBox( width: double.infinity, child: FilledButton.icon( onPressed: saving ? null : onSave, icon: saving ? const SizedBox( height: 18, width: 18, child: CircularProgressIndicator(strokeWidth: 2), ) : const Icon(Icons.save_outlined), label: const Text('Save'), ), ), ); } }