Workforce rotation settings and location permission handling
This commit is contained in:
parent
ce82a88e04
commit
8bf0dc13d7
|
|
@ -14,6 +14,7 @@ import 'providers/notifications_provider.dart';
|
||||||
import 'providers/notification_navigation_provider.dart';
|
import 'providers/notification_navigation_provider.dart';
|
||||||
import 'utils/app_time.dart';
|
import 'utils/app_time.dart';
|
||||||
import 'utils/notification_permission.dart';
|
import 'utils/notification_permission.dart';
|
||||||
|
import 'utils/location_permission.dart';
|
||||||
import 'services/notification_service.dart';
|
import 'services/notification_service.dart';
|
||||||
import 'services/notification_bridge.dart';
|
import 'services/notification_bridge.dart';
|
||||||
import 'services/background_location_service.dart';
|
import 'services/background_location_service.dart';
|
||||||
|
|
@ -314,10 +315,15 @@ Future<void> main() async {
|
||||||
final granted = await ensureNotificationPermission();
|
final granted = await ensureNotificationPermission();
|
||||||
if (!granted) {
|
if (!granted) {
|
||||||
// we don’t block startup, but it’s worth logging so developers notice.
|
// 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');
|
// 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
|
// request FCM permission (iOS/Android13+) and handle foreground messages
|
||||||
await FirebaseMessaging.instance.requestPermission();
|
await FirebaseMessaging.instance.requestPermission();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
80
lib/models/rotation_config.dart
Normal file
80
lib/models/rotation_config.dart
Normal file
|
|
@ -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<String>? rotationOrder,
|
||||||
|
List<String>? fridayAmOrder,
|
||||||
|
List<String>? excludedStaffIds,
|
||||||
|
this.initialAmStaffId,
|
||||||
|
this.initialPmStaffId,
|
||||||
|
}) : rotationOrder = rotationOrder ?? [],
|
||||||
|
fridayAmOrder = fridayAmOrder ?? [],
|
||||||
|
excludedStaffIds = excludedStaffIds ?? [];
|
||||||
|
|
||||||
|
/// Ordered IDs for standard AM/PM rotation.
|
||||||
|
final List<String> rotationOrder;
|
||||||
|
|
||||||
|
/// Ordered IDs for Friday AM duty (non-Islam staff only).
|
||||||
|
final List<String> fridayAmOrder;
|
||||||
|
|
||||||
|
/// Staff IDs excluded from all rotation.
|
||||||
|
final List<String> 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<String, dynamic> 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<String, dynamic> 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<String>? rotationOrder,
|
||||||
|
List<String>? fridayAmOrder,
|
||||||
|
List<String>? 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<String> _toStringList(dynamic value) {
|
||||||
|
if (value is List) return value.map((e) => e.toString()).toList();
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
51
lib/providers/rotation_config_provider.dart
Normal file
51
lib/providers/rotation_config_provider.dart
Normal file
|
|
@ -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<RotationConfig>((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<String, dynamic>) {
|
||||||
|
return RotationConfig.fromJson(value);
|
||||||
|
}
|
||||||
|
if (value is String) {
|
||||||
|
return RotationConfig.fromJson(jsonDecode(value) as Map<String, dynamic>);
|
||||||
|
}
|
||||||
|
return RotationConfig();
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Controller for persisting [RotationConfig] changes.
|
||||||
|
final rotationConfigControllerProvider = Provider<RotationConfigController>((
|
||||||
|
ref,
|
||||||
|
) {
|
||||||
|
final client = ref.watch(supabaseClientProvider);
|
||||||
|
return RotationConfigController(client, ref);
|
||||||
|
});
|
||||||
|
|
||||||
|
class RotationConfigController {
|
||||||
|
RotationConfigController(this._client, this._ref);
|
||||||
|
|
||||||
|
final dynamic _client;
|
||||||
|
final Ref _ref;
|
||||||
|
|
||||||
|
Future<void> save(RotationConfig config) async {
|
||||||
|
await _client.from('app_settings').upsert({
|
||||||
|
'key': _settingsKey,
|
||||||
|
'value': config.toJson(),
|
||||||
|
});
|
||||||
|
_ref.invalidate(rotationConfigProvider);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -6,6 +6,7 @@ import 'package:supabase_flutter/supabase_flutter.dart';
|
||||||
|
|
||||||
import '../models/live_position.dart';
|
import '../models/live_position.dart';
|
||||||
import '../services/background_location_service.dart';
|
import '../services/background_location_service.dart';
|
||||||
|
import '../utils/location_permission.dart';
|
||||||
import 'profile_provider.dart';
|
import 'profile_provider.dart';
|
||||||
import 'supabase_provider.dart';
|
import 'supabase_provider.dart';
|
||||||
import 'stream_recovery.dart';
|
import 'stream_recovery.dart';
|
||||||
|
|
@ -49,10 +50,27 @@ class WhereaboutsController {
|
||||||
return data as bool? ?? false;
|
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<void> updatePositionNow() => _updatePositionNow();
|
||||||
|
|
||||||
/// Toggle allow_tracking preference.
|
/// 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<void> setTracking(bool allow) async {
|
Future<void> setTracking(bool allow) async {
|
||||||
final userId = _client.auth.currentUser?.id;
|
final userId = _client.auth.currentUser?.id;
|
||||||
if (userId == null) throw Exception('Not authenticated');
|
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
|
await _client
|
||||||
.from('profiles')
|
.from('profiles')
|
||||||
.update({'allow_tracking': allow})
|
.update({'allow_tracking': allow})
|
||||||
|
|
@ -61,12 +79,43 @@ class WhereaboutsController {
|
||||||
// Start or stop background location updates
|
// Start or stop background location updates
|
||||||
if (allow) {
|
if (allow) {
|
||||||
await startBackgroundLocationUpdates();
|
await startBackgroundLocationUpdates();
|
||||||
|
// Immediately save current position
|
||||||
|
await _updatePositionNow();
|
||||||
} else {
|
} else {
|
||||||
|
// Record final position before removing
|
||||||
|
await _updatePositionNow();
|
||||||
await stopBackgroundLocationUpdates();
|
await stopBackgroundLocationUpdates();
|
||||||
// Remove the live position entry
|
// Remove the live position entry
|
||||||
await _client.from('live_positions').delete().eq('user_id', userId);
|
await _client.from('live_positions').delete().eq('user_id', userId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Immediately fetches current position and upserts into live_positions.
|
||||||
|
Future<void> _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.
|
/// Background location reporting service.
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ import '../../providers/whereabouts_provider.dart';
|
||||||
import '../../providers/workforce_provider.dart';
|
import '../../providers/workforce_provider.dart';
|
||||||
import '../../theme/m3_motion.dart';
|
import '../../theme/m3_motion.dart';
|
||||||
import '../../utils/app_time.dart';
|
import '../../utils/app_time.dart';
|
||||||
|
import '../../utils/location_permission.dart';
|
||||||
import '../../widgets/face_verification_overlay.dart';
|
import '../../widgets/face_verification_overlay.dart';
|
||||||
import '../../utils/snackbar.dart';
|
import '../../utils/snackbar.dart';
|
||||||
import '../../widgets/gemini_animated_text_field.dart';
|
import '../../widgets/gemini_animated_text_field.dart';
|
||||||
|
|
@ -409,8 +410,21 @@ class _CheckInTabState extends ConsumerState<_CheckInTab> {
|
||||||
),
|
),
|
||||||
Switch(
|
Switch(
|
||||||
value: allowTracking,
|
value: allowTracking,
|
||||||
onChanged: (v) =>
|
onChanged: (v) async {
|
||||||
ref.read(whereaboutsControllerProvider).setTracking(v),
|
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<void> _handleCheckIn(DutySchedule schedule) async {
|
Future<void> _handleCheckIn(DutySchedule schedule) async {
|
||||||
setState(() => _loading = true);
|
setState(() => _loading = true);
|
||||||
try {
|
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 geoCfg = await ref.read(geofenceProvider.future);
|
||||||
final position = await Geolocator.getCurrentPosition(
|
final position = await Geolocator.getCurrentPosition(
|
||||||
locationSettings: const LocationSettings(
|
locationSettings: const LocationSettings(
|
||||||
|
|
@ -849,6 +874,8 @@ class _CheckInTabState extends ConsumerState<_CheckInTab> {
|
||||||
lat: position.latitude,
|
lat: position.latitude,
|
||||||
lng: position.longitude,
|
lng: position.longitude,
|
||||||
);
|
);
|
||||||
|
// Update live position immediately on check-in
|
||||||
|
ref.read(whereaboutsControllerProvider).updatePositionNow();
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_justCheckedIn.add(schedule.id);
|
_justCheckedIn.add(schedule.id);
|
||||||
|
|
@ -869,6 +896,17 @@ class _CheckInTabState extends ConsumerState<_CheckInTab> {
|
||||||
Future<void> _handleCheckOut(AttendanceLog log, {String? scheduleId}) async {
|
Future<void> _handleCheckOut(AttendanceLog log, {String? scheduleId}) async {
|
||||||
setState(() => _loading = true);
|
setState(() => _loading = true);
|
||||||
try {
|
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(
|
final position = await Geolocator.getCurrentPosition(
|
||||||
locationSettings: const LocationSettings(
|
locationSettings: const LocationSettings(
|
||||||
accuracy: LocationAccuracy.high,
|
accuracy: LocationAccuracy.high,
|
||||||
|
|
@ -881,6 +919,8 @@ class _CheckInTabState extends ConsumerState<_CheckInTab> {
|
||||||
lat: position.latitude,
|
lat: position.latitude,
|
||||||
lng: position.longitude,
|
lng: position.longitude,
|
||||||
);
|
);
|
||||||
|
// Update live position immediately on check-out
|
||||||
|
ref.read(whereaboutsControllerProvider).updatePositionNow();
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() {
|
setState(() {
|
||||||
if (scheduleId != null) {
|
if (scheduleId != null) {
|
||||||
|
|
@ -904,6 +944,17 @@ class _CheckInTabState extends ConsumerState<_CheckInTab> {
|
||||||
Future<void> _handleCheckOutById(String logId, {String? scheduleId}) async {
|
Future<void> _handleCheckOutById(String logId, {String? scheduleId}) async {
|
||||||
setState(() => _loading = true);
|
setState(() => _loading = true);
|
||||||
try {
|
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(
|
final position = await Geolocator.getCurrentPosition(
|
||||||
locationSettings: const LocationSettings(
|
locationSettings: const LocationSettings(
|
||||||
accuracy: LocationAccuracy.high,
|
accuracy: LocationAccuracy.high,
|
||||||
|
|
@ -916,6 +967,8 @@ class _CheckInTabState extends ConsumerState<_CheckInTab> {
|
||||||
lat: position.latitude,
|
lat: position.latitude,
|
||||||
lng: position.longitude,
|
lng: position.longitude,
|
||||||
);
|
);
|
||||||
|
// Update live position immediately on check-out
|
||||||
|
ref.read(whereaboutsControllerProvider).updatePositionNow();
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() {
|
setState(() {
|
||||||
if (scheduleId != null) {
|
if (scheduleId != null) {
|
||||||
|
|
|
||||||
814
lib/screens/workforce/rotation_settings_dialog.dart
Normal file
814
lib/screens/workforce/rotation_settings_dialog.dart
Normal file
|
|
@ -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<void> 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<String>? _order;
|
||||||
|
bool _saving = false;
|
||||||
|
|
||||||
|
List<Profile> _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 = <String>[...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<void> _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<String>? _order;
|
||||||
|
bool _saving = false;
|
||||||
|
|
||||||
|
List<Profile> _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 = <String>[...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<void> _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<String>? _excluded;
|
||||||
|
bool _saving = false;
|
||||||
|
|
||||||
|
List<Profile> _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<String>.from(config.excludedStaffIds)
|
||||||
|
: <String>{};
|
||||||
|
}
|
||||||
|
|
||||||
|
@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<void> _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<Profile> _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<String?>(
|
||||||
|
initialValue: _amId,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'AM Staff',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
items: [
|
||||||
|
const DropdownMenuItem<String?>(
|
||||||
|
value: null,
|
||||||
|
child: Text('Auto (continue from last week)'),
|
||||||
|
),
|
||||||
|
for (final s in staff)
|
||||||
|
DropdownMenuItem<String?>(
|
||||||
|
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<String?>(
|
||||||
|
initialValue: _pmId,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'PM Staff',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
items: [
|
||||||
|
const DropdownMenuItem<String?>(
|
||||||
|
value: null,
|
||||||
|
child: Text('Auto (continue from last week)'),
|
||||||
|
),
|
||||||
|
for (final s in staff)
|
||||||
|
DropdownMenuItem<String?>(
|
||||||
|
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<void> _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'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -6,15 +6,18 @@ import 'package:timezone/timezone.dart' as tz;
|
||||||
|
|
||||||
import '../../models/duty_schedule.dart';
|
import '../../models/duty_schedule.dart';
|
||||||
import '../../models/profile.dart';
|
import '../../models/profile.dart';
|
||||||
|
import '../../models/rotation_config.dart';
|
||||||
import '../../models/swap_request.dart';
|
import '../../models/swap_request.dart';
|
||||||
|
|
||||||
import '../../providers/profile_provider.dart';
|
import '../../providers/profile_provider.dart';
|
||||||
|
import '../../providers/rotation_config_provider.dart';
|
||||||
import '../../providers/workforce_provider.dart';
|
import '../../providers/workforce_provider.dart';
|
||||||
import '../../providers/chat_provider.dart';
|
import '../../providers/chat_provider.dart';
|
||||||
import '../../providers/ramadan_provider.dart';
|
import '../../providers/ramadan_provider.dart';
|
||||||
import '../../widgets/responsive_body.dart';
|
import '../../widgets/responsive_body.dart';
|
||||||
import '../../theme/app_surfaces.dart';
|
import '../../theme/app_surfaces.dart';
|
||||||
import '../../utils/snackbar.dart';
|
import '../../utils/snackbar.dart';
|
||||||
|
import 'rotation_settings_dialog.dart';
|
||||||
|
|
||||||
class WorkforceScreen extends ConsumerWidget {
|
class WorkforceScreen extends ConsumerWidget {
|
||||||
const WorkforceScreen({super.key});
|
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),
|
const SizedBox(height: 12),
|
||||||
|
|
@ -1009,6 +1018,7 @@ class _ScheduleGeneratorPanelState
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final rotationConfig = ref.read(rotationConfigProvider).valueOrNull;
|
||||||
final schedules = ref.read(dutySchedulesProvider).valueOrNull ?? [];
|
final schedules = ref.read(dutySchedulesProvider).valueOrNull ?? [];
|
||||||
final templates = _buildTemplates(schedules);
|
final templates = _buildTemplates(schedules);
|
||||||
final generated = _generateDrafts(
|
final generated = _generateDrafts(
|
||||||
|
|
@ -1017,6 +1027,7 @@ class _ScheduleGeneratorPanelState
|
||||||
staff,
|
staff,
|
||||||
schedules,
|
schedules,
|
||||||
templates,
|
templates,
|
||||||
|
rotationConfig: rotationConfig,
|
||||||
);
|
);
|
||||||
generated.sort((a, b) => a.startTime.compareTo(b.startTime));
|
generated.sort((a, b) => a.startTime.compareTo(b.startTime));
|
||||||
final warnings = _buildWarnings(start, end, generated);
|
final warnings = _buildWarnings(start, end, generated);
|
||||||
|
|
@ -1544,18 +1555,54 @@ class _ScheduleGeneratorPanelState
|
||||||
DateTime end,
|
DateTime end,
|
||||||
List<Profile> staff,
|
List<Profile> staff,
|
||||||
List<DutySchedule> schedules,
|
List<DutySchedule> schedules,
|
||||||
Map<String, _ShiftTemplate> templates,
|
Map<String, _ShiftTemplate> templates, {
|
||||||
) {
|
RotationConfig? rotationConfig,
|
||||||
|
}) {
|
||||||
final draft = <_DraftSchedule>[];
|
final draft = <_DraftSchedule>[];
|
||||||
final normalizedStart = DateTime(start.year, start.month, start.day);
|
final normalizedStart = DateTime(start.year, start.month, start.day);
|
||||||
final normalizedEnd = DateTime(end.year, end.month, end.day);
|
final normalizedEnd = DateTime(end.year, end.month, end.day);
|
||||||
final existing = schedules;
|
final existing = schedules;
|
||||||
|
|
||||||
// Only IT Staff rotate through AM/PM/on_call shifts
|
final excludedIds = rotationConfig?.excludedStaffIds ?? [];
|
||||||
final itStaff = staff.where((p) => p.role == 'it_staff').toList();
|
|
||||||
|
// 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)
|
// Admin/Dispatcher always get normal shift (no rotation)
|
||||||
final nonRotating = staff.where((p) => p.role != 'it_staff').toList();
|
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);
|
var weekStart = _startOfWeek(normalizedStart);
|
||||||
|
|
||||||
while (!weekStart.isAfter(normalizedEnd)) {
|
while (!weekStart.isAfter(normalizedEnd)) {
|
||||||
|
|
@ -1592,18 +1639,45 @@ class _ScheduleGeneratorPanelState
|
||||||
];
|
];
|
||||||
|
|
||||||
// Rotation indices only for IT Staff
|
// Rotation indices only for IT Staff
|
||||||
final amBaseIndex = _nextIndexFromLastWeek(
|
int amBaseIndex;
|
||||||
shiftType: 'am',
|
int pmBaseIndex;
|
||||||
staff: itStaff,
|
|
||||||
lastWeek: lastWeek,
|
if (isFirstWeek && rotationConfig?.initialAmStaffId != null) {
|
||||||
defaultIndex: 0,
|
// Use configured initial AM
|
||||||
);
|
final idx = itStaff.indexWhere(
|
||||||
final pmBaseIndex = _nextIndexFromLastWeek(
|
(p) => p.id == rotationConfig!.initialAmStaffId,
|
||||||
shiftType: 'pm',
|
);
|
||||||
staff: itStaff,
|
amBaseIndex = idx != -1 ? idx : 0;
|
||||||
lastWeek: lastWeek,
|
} else {
|
||||||
defaultIndex: itStaff.length > 1 ? 1 : 0,
|
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
|
final amUserId = itStaff.isEmpty
|
||||||
? null
|
? null
|
||||||
: itStaff[amBaseIndex % itStaff.length].id;
|
: itStaff[amBaseIndex % itStaff.length].id;
|
||||||
|
|
@ -1664,16 +1738,31 @@ class _ScheduleGeneratorPanelState
|
||||||
final isFriday = day.weekday == DateTime.friday;
|
final isFriday = day.weekday == DateTime.friday;
|
||||||
final profileMap = _profileById();
|
final profileMap = _profileById();
|
||||||
final amProfile = amUserId != null ? profileMap[amUserId] : null;
|
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(
|
_tryAddDraft(
|
||||||
draft,
|
draft,
|
||||||
existing,
|
existing,
|
||||||
templates,
|
templates,
|
||||||
'am',
|
'am',
|
||||||
amUserId,
|
effectiveAmUserId,
|
||||||
day,
|
day,
|
||||||
const [],
|
const [],
|
||||||
);
|
);
|
||||||
|
|
@ -1699,12 +1788,12 @@ class _ScheduleGeneratorPanelState
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remaining IT Staff get normal shift
|
// Remaining IT Staff (including excluded) get normal shift
|
||||||
final assignedToday = <String?>[
|
final assignedToday = <String?>[
|
||||||
amUserId,
|
effectiveAmUserId,
|
||||||
pmUserId,
|
pmUserId,
|
||||||
].whereType<String>().toSet();
|
].whereType<String>().toSet();
|
||||||
for (final profile in itStaff) {
|
for (final profile in allItStaff) {
|
||||||
if (assignedToday.contains(profile.id)) continue;
|
if (assignedToday.contains(profile.id)) continue;
|
||||||
final normalKey = dayIsRamadan && profile.religion == 'islam'
|
final normalKey = dayIsRamadan && profile.religion == 'islam'
|
||||||
? 'normal_ramadan_islam'
|
? 'normal_ramadan_islam'
|
||||||
|
|
|
||||||
32
lib/utils/location_permission.dart
Normal file
32
lib/utils/location_permission.dart
Normal file
|
|
@ -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<PermissionStatus> 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<bool> 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;
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user