diff --git a/lib/main.dart b/lib/main.dart index 73928678..eb9ac748 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -14,6 +14,7 @@ import 'providers/notifications_provider.dart'; import 'providers/notification_navigation_provider.dart'; import 'utils/app_time.dart'; import 'utils/notification_permission.dart'; +import 'utils/location_permission.dart'; import 'services/notification_service.dart'; import 'services/notification_bridge.dart'; import 'services/background_location_service.dart'; @@ -314,10 +315,15 @@ Future main() async { final granted = await ensureNotificationPermission(); if (!granted) { // we don’t block startup, but it’s worth logging so developers notice. - // A real app might show a dialog pointing the user to settings. // debugPrint('notification permission not granted'); } + // Request location permission at launch (same pattern as notification) + final locationGranted = await ensureLocationPermission(); + if (!locationGranted) { + // debugPrint('location permission not granted'); + } + // request FCM permission (iOS/Android13+) and handle foreground messages await FirebaseMessaging.instance.requestPermission(); } diff --git a/lib/models/rotation_config.dart b/lib/models/rotation_config.dart new file mode 100644 index 00000000..ecfe27fa --- /dev/null +++ b/lib/models/rotation_config.dart @@ -0,0 +1,80 @@ +/// Rotation configuration for the duty schedule generator. +/// +/// Stored in `app_settings` with key `rotation_config`. Contains: +/// - Ordered list of IT Staff IDs for general rotation +/// - Ordered list of non-Islam IT Staff IDs for Friday AM rotation +/// - Set of excluded IT Staff IDs +/// - Initial AM and PM duty assignments for the next generated schedule +class RotationConfig { + RotationConfig({ + List? rotationOrder, + List? fridayAmOrder, + List? excludedStaffIds, + this.initialAmStaffId, + this.initialPmStaffId, + }) : rotationOrder = rotationOrder ?? [], + fridayAmOrder = fridayAmOrder ?? [], + excludedStaffIds = excludedStaffIds ?? []; + + /// Ordered IDs for standard AM/PM rotation. + final List rotationOrder; + + /// Ordered IDs for Friday AM duty (non-Islam staff only). + final List fridayAmOrder; + + /// Staff IDs excluded from all rotation. + final List excludedStaffIds; + + /// The staff member that should take AM duty on the first day of the next + /// generated schedule. `null` means "continue from last week". + final String? initialAmStaffId; + + /// The staff member that should take PM duty on the first day of the next + /// generated schedule. `null` means "continue from last week". + final String? initialPmStaffId; + + factory RotationConfig.fromJson(Map json) { + return RotationConfig( + rotationOrder: _toStringList(json['rotation_order']), + fridayAmOrder: _toStringList(json['friday_am_order']), + excludedStaffIds: _toStringList(json['excluded_staff_ids']), + initialAmStaffId: json['initial_am_staff_id'] as String?, + initialPmStaffId: json['initial_pm_staff_id'] as String?, + ); + } + + Map toJson() => { + 'rotation_order': rotationOrder, + 'friday_am_order': fridayAmOrder, + 'excluded_staff_ids': excludedStaffIds, + 'initial_am_staff_id': initialAmStaffId, + 'initial_pm_staff_id': initialPmStaffId, + }; + + RotationConfig copyWith({ + List? rotationOrder, + List? fridayAmOrder, + List? excludedStaffIds, + String? initialAmStaffId, + String? initialPmStaffId, + bool clearInitialAm = false, + bool clearInitialPm = false, + }) { + return RotationConfig( + rotationOrder: rotationOrder ?? this.rotationOrder, + fridayAmOrder: fridayAmOrder ?? this.fridayAmOrder, + excludedStaffIds: excludedStaffIds ?? this.excludedStaffIds, + initialAmStaffId: clearInitialAm + ? null + : (initialAmStaffId ?? this.initialAmStaffId), + initialPmStaffId: clearInitialPm + ? null + : (initialPmStaffId ?? this.initialPmStaffId), + ); + } + + static List _toStringList(dynamic value) { + if (value is List) return value.map((e) => e.toString()).toList(); + return []; + } +} diff --git a/lib/providers/rotation_config_provider.dart b/lib/providers/rotation_config_provider.dart new file mode 100644 index 00000000..7b12142c --- /dev/null +++ b/lib/providers/rotation_config_provider.dart @@ -0,0 +1,51 @@ +import 'dart:convert'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../models/rotation_config.dart'; +import 'supabase_provider.dart'; + +/// Key used to store the rotation configuration in `app_settings`. +const _settingsKey = 'rotation_config'; + +/// Provides the current [RotationConfig] from `app_settings`. +final rotationConfigProvider = FutureProvider((ref) async { + final client = ref.watch(supabaseClientProvider); + final row = await client + .from('app_settings') + .select() + .eq('key', _settingsKey) + .maybeSingle(); + if (row == null) return RotationConfig(); + final value = row['value']; + if (value is Map) { + return RotationConfig.fromJson(value); + } + if (value is String) { + return RotationConfig.fromJson(jsonDecode(value) as Map); + } + return RotationConfig(); +}); + +/// Controller for persisting [RotationConfig] changes. +final rotationConfigControllerProvider = Provider(( + ref, +) { + final client = ref.watch(supabaseClientProvider); + return RotationConfigController(client, ref); +}); + +class RotationConfigController { + RotationConfigController(this._client, this._ref); + + final dynamic _client; + final Ref _ref; + + Future save(RotationConfig config) async { + await _client.from('app_settings').upsert({ + 'key': _settingsKey, + 'value': config.toJson(), + }); + _ref.invalidate(rotationConfigProvider); + } +} diff --git a/lib/providers/whereabouts_provider.dart b/lib/providers/whereabouts_provider.dart index c68f32f2..2ee29418 100644 --- a/lib/providers/whereabouts_provider.dart +++ b/lib/providers/whereabouts_provider.dart @@ -6,6 +6,7 @@ import 'package:supabase_flutter/supabase_flutter.dart'; import '../models/live_position.dart'; import '../services/background_location_service.dart'; +import '../utils/location_permission.dart'; import 'profile_provider.dart'; import 'supabase_provider.dart'; import 'stream_recovery.dart'; @@ -49,10 +50,27 @@ class WhereaboutsController { return data as bool? ?? false; } + /// Public convenience: fetch device position and upsert to live_positions. + /// Call after check-in / check-out to ensure immediate data freshness. + Future updatePositionNow() => _updatePositionNow(); + /// Toggle allow_tracking preference. + /// + /// When enabling, requests location permission and immediately saves the + /// current position to `live_positions`. When disabling, updates position + /// one last time (so the final location is recorded) then removes the entry. Future setTracking(bool allow) async { final userId = _client.auth.currentUser?.id; if (userId == null) throw Exception('Not authenticated'); + + if (allow) { + // Ensure location permission before enabling + final granted = await ensureLocationPermission(); + if (!granted) { + throw Exception('Location permission is required for tracking'); + } + } + await _client .from('profiles') .update({'allow_tracking': allow}) @@ -61,12 +79,43 @@ class WhereaboutsController { // Start or stop background location updates if (allow) { await startBackgroundLocationUpdates(); + // Immediately save current position + await _updatePositionNow(); } else { + // Record final position before removing + await _updatePositionNow(); await stopBackgroundLocationUpdates(); // Remove the live position entry await _client.from('live_positions').delete().eq('user_id', userId); } } + + /// Immediately fetches current position and upserts into live_positions. + Future _updatePositionNow() async { + try { + final serviceEnabled = await Geolocator.isLocationServiceEnabled(); + if (!serviceEnabled) return; + + var permission = await Geolocator.checkPermission(); + if (permission == LocationPermission.denied || + permission == LocationPermission.deniedForever) { + return; + } + + final position = await Geolocator.getCurrentPosition( + locationSettings: const LocationSettings( + accuracy: LocationAccuracy.high, + ), + ); + + await _client.rpc( + 'update_live_position', + params: {'p_lat': position.latitude, 'p_lng': position.longitude}, + ); + } catch (_) { + // Best-effort; don't block the toggle action + } + } } /// Background location reporting service. diff --git a/lib/screens/attendance/attendance_screen.dart b/lib/screens/attendance/attendance_screen.dart index 8ea0aca6..5c1d5866 100644 --- a/lib/screens/attendance/attendance_screen.dart +++ b/lib/screens/attendance/attendance_screen.dart @@ -23,6 +23,7 @@ import '../../providers/whereabouts_provider.dart'; import '../../providers/workforce_provider.dart'; import '../../theme/m3_motion.dart'; import '../../utils/app_time.dart'; +import '../../utils/location_permission.dart'; import '../../widgets/face_verification_overlay.dart'; import '../../utils/snackbar.dart'; import '../../widgets/gemini_animated_text_field.dart'; @@ -409,8 +410,21 @@ class _CheckInTabState extends ConsumerState<_CheckInTab> { ), Switch( value: allowTracking, - onChanged: (v) => - ref.read(whereaboutsControllerProvider).setTracking(v), + onChanged: (v) async { + if (v) { + final granted = await ensureLocationPermission(); + if (!granted) { + if (context.mounted) { + showWarningSnackBar( + context, + 'Location permission is required.', + ); + } + return; + } + } + ref.read(whereaboutsControllerProvider).setTracking(v); + }, ), ], ), @@ -810,6 +824,17 @@ class _CheckInTabState extends ConsumerState<_CheckInTab> { Future _handleCheckIn(DutySchedule schedule) async { setState(() => _loading = true); try { + // Ensure location permission before check-in + final locGranted = await ensureLocationPermission(); + if (!locGranted) { + if (mounted) { + showWarningSnackBar( + context, + 'Location permission is required for check-in.', + ); + } + return; + } final geoCfg = await ref.read(geofenceProvider.future); final position = await Geolocator.getCurrentPosition( locationSettings: const LocationSettings( @@ -849,6 +874,8 @@ class _CheckInTabState extends ConsumerState<_CheckInTab> { lat: position.latitude, lng: position.longitude, ); + // Update live position immediately on check-in + ref.read(whereaboutsControllerProvider).updatePositionNow(); if (mounted) { setState(() { _justCheckedIn.add(schedule.id); @@ -869,6 +896,17 @@ class _CheckInTabState extends ConsumerState<_CheckInTab> { Future _handleCheckOut(AttendanceLog log, {String? scheduleId}) async { setState(() => _loading = true); try { + // Ensure location permission before check-out + final locGranted = await ensureLocationPermission(); + if (!locGranted) { + if (mounted) { + showWarningSnackBar( + context, + 'Location permission is required for check-out.', + ); + } + return; + } final position = await Geolocator.getCurrentPosition( locationSettings: const LocationSettings( accuracy: LocationAccuracy.high, @@ -881,6 +919,8 @@ class _CheckInTabState extends ConsumerState<_CheckInTab> { lat: position.latitude, lng: position.longitude, ); + // Update live position immediately on check-out + ref.read(whereaboutsControllerProvider).updatePositionNow(); if (mounted) { setState(() { if (scheduleId != null) { @@ -904,6 +944,17 @@ class _CheckInTabState extends ConsumerState<_CheckInTab> { Future _handleCheckOutById(String logId, {String? scheduleId}) async { setState(() => _loading = true); try { + // Ensure location permission before check-out + final locGranted = await ensureLocationPermission(); + if (!locGranted) { + if (mounted) { + showWarningSnackBar( + context, + 'Location permission is required for check-out.', + ); + } + return; + } final position = await Geolocator.getCurrentPosition( locationSettings: const LocationSettings( accuracy: LocationAccuracy.high, @@ -916,6 +967,8 @@ class _CheckInTabState extends ConsumerState<_CheckInTab> { lat: position.latitude, lng: position.longitude, ); + // Update live position immediately on check-out + ref.read(whereaboutsControllerProvider).updatePositionNow(); if (mounted) { setState(() { if (scheduleId != null) { diff --git a/lib/screens/workforce/rotation_settings_dialog.dart b/lib/screens/workforce/rotation_settings_dialog.dart new file mode 100644 index 00000000..5d108718 --- /dev/null +++ b/lib/screens/workforce/rotation_settings_dialog.dart @@ -0,0 +1,814 @@ +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'), + ), + ), + ); + } +} diff --git a/lib/screens/workforce/workforce_screen.dart b/lib/screens/workforce/workforce_screen.dart index 7e37bd91..5e9d2f80 100644 --- a/lib/screens/workforce/workforce_screen.dart +++ b/lib/screens/workforce/workforce_screen.dart @@ -6,15 +6,18 @@ 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 '../../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}); @@ -879,6 +882,12 @@ class _ScheduleGeneratorPanelState ), ), ], + const Spacer(), + IconButton( + tooltip: 'Rotation settings', + icon: const Icon(Icons.settings_outlined), + onPressed: () => showRotationSettings(context, ref), + ), ], ), const SizedBox(height: 12), @@ -1009,6 +1018,7 @@ class _ScheduleGeneratorPanelState return; } + final rotationConfig = ref.read(rotationConfigProvider).valueOrNull; final schedules = ref.read(dutySchedulesProvider).valueOrNull ?? []; final templates = _buildTemplates(schedules); final generated = _generateDrafts( @@ -1017,6 +1027,7 @@ class _ScheduleGeneratorPanelState staff, schedules, templates, + rotationConfig: rotationConfig, ); generated.sort((a, b) => a.startTime.compareTo(b.startTime)); final warnings = _buildWarnings(start, end, generated); @@ -1544,18 +1555,54 @@ class _ScheduleGeneratorPanelState DateTime end, List staff, List schedules, - Map templates, - ) { + 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; - // Only IT Staff rotate through AM/PM/on_call shifts - final itStaff = staff.where((p) => p.role == 'it_staff').toList(); + final excludedIds = rotationConfig?.excludedStaffIds ?? []; + + // 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)) { @@ -1592,18 +1639,45 @@ class _ScheduleGeneratorPanelState ]; // 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, - ); + 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; @@ -1664,16 +1738,31 @@ class _ScheduleGeneratorPanelState 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) { + // 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) { _tryAddDraft( draft, existing, templates, 'am', - amUserId, + effectiveAmUserId, day, const [], ); @@ -1699,12 +1788,12 @@ class _ScheduleGeneratorPanelState ); } - // Remaining IT Staff get normal shift + // Remaining IT Staff (including excluded) get normal shift final assignedToday = [ - amUserId, + effectiveAmUserId, pmUserId, ].whereType().toSet(); - for (final profile in itStaff) { + for (final profile in allItStaff) { if (assignedToday.contains(profile.id)) continue; final normalKey = dayIsRamadan && profile.religion == 'islam' ? 'normal_ramadan_islam' diff --git a/lib/utils/location_permission.dart b/lib/utils/location_permission.dart new file mode 100644 index 00000000..3df8dfb2 --- /dev/null +++ b/lib/utils/location_permission.dart @@ -0,0 +1,32 @@ +import 'package:flutter/foundation.dart' show kIsWeb; +import 'package:permission_handler/permission_handler.dart'; + +import '../services/permission_service.dart'; + +/// Helpers for requesting and checking the platform location permission. +/// +/// Mirrors the pattern in [notification_permission.dart] so that location +/// can be requested at app launch and before location-dependent actions. + +Future requestLocationPermission() async { + if (kIsWeb) { + return PermissionStatus.granted; + } + return requestPermission(Permission.location); +} + +/// Ensures location permission is granted. Returns `true` if granted. +/// +/// Call on app launch and before check-in, check-out, or toggling location +/// tracking so the user always has the opportunity to grant permission. +Future ensureLocationPermission() async { + if (kIsWeb) return true; + final status = await Permission.location.status; + if (status.isGranted) return true; + if (status.isDenied || status.isRestricted || status.isLimited) { + final newStatus = await requestLocationPermission(); + return newStatus.isGranted; + } + // permanently denied – user must open settings + return false; +}