diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index c0b90a2e..41c8d4e0 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,4 +1,6 @@ + + $(FLUTTER_BUILD_NUMBER) LSRequiresIPhoneOS + NSLocationWhenInUseUsageDescription + TasQ uses your location to verify on-site check-ins. UILaunchStoryboardName LaunchScreen UIMainStoryboardFile diff --git a/lib/models/app_settings.dart b/lib/models/app_settings.dart new file mode 100644 index 00000000..82839468 --- /dev/null +++ b/lib/models/app_settings.dart @@ -0,0 +1,33 @@ +class GeofenceConfig { + GeofenceConfig({ + required this.lat, + required this.lng, + required this.radiusMeters, + }); + + final double lat; + final double lng; + final double radiusMeters; + + factory GeofenceConfig.fromJson(Map json) { + return GeofenceConfig( + lat: (json['lat'] as num).toDouble(), + lng: (json['lng'] as num).toDouble(), + radiusMeters: (json['radius_m'] as num).toDouble(), + ); + } +} + +class AppSetting { + AppSetting({required this.key, required this.value}); + + final String key; + final Map value; + + factory AppSetting.fromMap(Map map) { + return AppSetting( + key: map['key'] as String, + value: Map.from(map['value'] as Map), + ); + } +} diff --git a/lib/models/duty_schedule.dart b/lib/models/duty_schedule.dart new file mode 100644 index 00000000..d3e780ea --- /dev/null +++ b/lib/models/duty_schedule.dart @@ -0,0 +1,39 @@ +class DutySchedule { + DutySchedule({ + required this.id, + required this.userId, + required this.shiftType, + required this.startTime, + required this.endTime, + required this.status, + required this.createdAt, + required this.checkInAt, + required this.checkInLocation, + }); + + final String id; + final String userId; + final String shiftType; + final DateTime startTime; + final DateTime endTime; + final String status; + final DateTime createdAt; + final DateTime? checkInAt; + final Object? checkInLocation; + + factory DutySchedule.fromMap(Map map) { + return DutySchedule( + id: map['id'] as String, + userId: map['user_id'] as String, + shiftType: map['shift_type'] as String? ?? 'normal', + startTime: DateTime.parse(map['start_time'] as String), + endTime: DateTime.parse(map['end_time'] as String), + status: map['status'] as String? ?? 'scheduled', + createdAt: DateTime.parse(map['created_at'] as String), + checkInAt: map['check_in_at'] == null + ? null + : DateTime.parse(map['check_in_at'] as String), + checkInLocation: map['check_in_location'], + ); + } +} diff --git a/lib/models/swap_request.dart b/lib/models/swap_request.dart new file mode 100644 index 00000000..50f5fbcc --- /dev/null +++ b/lib/models/swap_request.dart @@ -0,0 +1,36 @@ +class SwapRequest { + SwapRequest({ + required this.id, + required this.requesterId, + required this.recipientId, + required this.shiftId, + required this.status, + required this.createdAt, + required this.updatedAt, + required this.approvedBy, + }); + + final String id; + final String requesterId; + final String recipientId; + final String shiftId; + final String status; + final DateTime createdAt; + final DateTime? updatedAt; + final String? approvedBy; + + factory SwapRequest.fromMap(Map map) { + return SwapRequest( + id: map['id'] as String, + requesterId: map['requester_id'] as String, + recipientId: map['recipient_id'] as String, + shiftId: map['shift_id'] as String, + status: map['status'] as String? ?? 'pending', + createdAt: DateTime.parse(map['created_at'] as String), + updatedAt: map['updated_at'] == null + ? null + : DateTime.parse(map['updated_at'] as String), + approvedBy: map['approved_by'] as String?, + ); + } +} diff --git a/lib/providers/workforce_provider.dart b/lib/providers/workforce_provider.dart new file mode 100644 index 00000000..dbdccc55 --- /dev/null +++ b/lib/providers/workforce_provider.dart @@ -0,0 +1,139 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; + +import '../models/app_settings.dart'; +import '../models/duty_schedule.dart'; +import '../models/swap_request.dart'; +import 'profile_provider.dart'; +import 'supabase_provider.dart'; + +final geofenceProvider = FutureProvider((ref) async { + final client = ref.watch(supabaseClientProvider); + final data = await client + .from('app_settings') + .select() + .eq('key', 'geofence') + .maybeSingle(); + if (data == null) return null; + final setting = AppSetting.fromMap(data); + return GeofenceConfig.fromJson(setting.value); +}); + +final dutySchedulesProvider = StreamProvider>((ref) { + final client = ref.watch(supabaseClientProvider); + final profileAsync = ref.watch(currentProfileProvider); + final profile = profileAsync.valueOrNull; + if (profile == null) { + return Stream.value(const []); + } + + final isAdmin = profile.role == 'admin' || profile.role == 'dispatcher'; + final base = client.from('duty_schedules').stream(primaryKey: ['id']); + if (isAdmin) { + return base + .order('start_time') + .map((rows) => rows.map(DutySchedule.fromMap).toList()); + } + return base + .eq('user_id', profile.id) + .order('start_time') + .map((rows) => rows.map(DutySchedule.fromMap).toList()); +}); + +final swapRequestsProvider = StreamProvider>((ref) { + final client = ref.watch(supabaseClientProvider); + final profileAsync = ref.watch(currentProfileProvider); + final profile = profileAsync.valueOrNull; + if (profile == null) { + return Stream.value(const []); + } + + final isAdmin = profile.role == 'admin' || profile.role == 'dispatcher'; + final base = client.from('swap_requests').stream(primaryKey: ['id']); + if (isAdmin) { + return base + .order('created_at', ascending: false) + .map((rows) => rows.map(SwapRequest.fromMap).toList()); + } + return base + .order('created_at', ascending: false) + .map( + (rows) => rows + .where( + (row) => + row['requester_id'] == profile.id || + row['recipient_id'] == profile.id, + ) + .map(SwapRequest.fromMap) + .toList(), + ); +}); + +final workforceControllerProvider = Provider((ref) { + final client = ref.watch(supabaseClientProvider); + return WorkforceController(client); +}); + +class WorkforceController { + WorkforceController(this._client); + + final SupabaseClient _client; + + Future generateSchedule({ + required DateTime startDate, + required DateTime endDate, + }) async { + await _client.rpc( + 'generate_duty_schedule', + params: { + 'start_date': _formatDate(startDate), + 'end_date': _formatDate(endDate), + }, + ); + } + + Future insertSchedules(List> schedules) async { + if (schedules.isEmpty) return; + await _client.from('duty_schedules').insert(schedules); + } + + Future checkIn({ + required String dutyScheduleId, + required double lat, + required double lng, + }) async { + final data = await _client.rpc( + 'duty_check_in', + params: {'p_duty_id': dutyScheduleId, 'p_lat': lat, 'p_lng': lng}, + ); + return data as String?; + } + + Future requestSwap({ + required String shiftId, + required String recipientId, + }) async { + final data = await _client.rpc( + 'request_shift_swap', + params: {'p_shift_id': shiftId, 'p_recipient_id': recipientId}, + ); + return data as String?; + } + + Future respondSwap({ + required String swapId, + required String action, + }) async { + await _client.rpc( + 'respond_shift_swap', + params: {'p_swap_id': swapId, 'p_action': action}, + ); + } + + String _formatDate(DateTime value) { + final date = DateTime(value.year, value.month, value.day); + final month = date.month.toString().padLeft(2, '0'); + final day = date.day.toString().padLeft(2, '0'); + return '${date.year}-$month-$day'; + } +} diff --git a/lib/routing/app_router.dart b/lib/routing/app_router.dart index ea346904..89b655d6 100644 --- a/lib/routing/app_router.dart +++ b/lib/routing/app_router.dart @@ -15,6 +15,7 @@ import '../screens/tasks/task_detail_screen.dart'; import '../screens/tasks/tasks_list_screen.dart'; import '../screens/tickets/ticket_detail_screen.dart'; import '../screens/tickets/tickets_list_screen.dart'; +import '../screens/workforce/workforce_screen.dart'; import '../widgets/app_shell.dart'; final appRouterProvider = Provider((ref) { @@ -105,11 +106,7 @@ final appRouterProvider = Provider((ref) { ), GoRoute( path: '/workforce', - builder: (context, state) => const UnderDevelopmentScreen( - title: 'Workforce', - subtitle: 'Workforce management is in progress.', - icon: Icons.groups, - ), + builder: (context, state) => const WorkforceScreen(), ), GoRoute( path: '/reports', diff --git a/lib/screens/admin/user_management_screen.dart b/lib/screens/admin/user_management_screen.dart index 39b0f45e..e7c3dbba 100644 --- a/lib/screens/admin/user_management_screen.dart +++ b/lib/screens/admin/user_management_screen.dart @@ -177,8 +177,9 @@ class _UserManagementScreenState extends ConsumerState { columnSpacing: 24, horizontalMargin: 16, dividerThickness: 1, - headingRowColor: MaterialStateProperty.resolveWith( - (states) => Theme.of(context).colorScheme.surfaceVariant, + headingRowColor: WidgetStateProperty.resolveWith( + (states) => + Theme.of(context).colorScheme.surfaceContainerHighest, ), columns: const [ DataColumn(label: Text('User')), @@ -228,16 +229,16 @@ class _UserManagementScreenState extends ConsumerState { if (selected != true) return; _showUserDialog(context, profile, offices, assignments); }, - color: MaterialStateProperty.resolveWith((states) { - if (states.contains(MaterialState.selected)) { + color: WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.selected)) { return Theme.of( context, - ).colorScheme.surfaceTint.withOpacity(0.12); + ).colorScheme.surfaceTint.withValues(alpha: 0.12); } if (index.isEven) { return Theme.of( context, - ).colorScheme.surface.withOpacity(0.6); + ).colorScheme.surface.withValues(alpha: 0.6); } return Theme.of(context).colorScheme.surface; }), diff --git a/lib/screens/workforce/workforce_screen.dart b/lib/screens/workforce/workforce_screen.dart new file mode 100644 index 00000000..b1eb647f --- /dev/null +++ b/lib/screens/workforce/workforce_screen.dart @@ -0,0 +1,1706 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:geolocator/geolocator.dart'; + +import '../../models/duty_schedule.dart'; +import '../../models/profile.dart'; +import '../../models/swap_request.dart'; +import '../../providers/profile_provider.dart'; +import '../../providers/workforce_provider.dart'; +import '../../widgets/responsive_body.dart'; + +class WorkforceScreen extends ConsumerWidget { + const WorkforceScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final profileAsync = ref.watch(currentProfileProvider); + final role = profileAsync.valueOrNull?.role ?? 'standard'; + final isAdmin = role == 'admin' || role == 'dispatcher'; + + return ResponsiveBody( + child: LayoutBuilder( + builder: (context, constraints) { + final isWide = constraints.maxWidth >= 980; + final schedulePanel = _SchedulePanel(isAdmin: isAdmin); + final swapsPanel = _SwapRequestsPanel(isAdmin: isAdmin); + final generatorPanel = _ScheduleGeneratorPanel(enabled: isAdmin); + + if (isWide) { + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded(flex: 3, child: schedulePanel), + const SizedBox(width: 16), + Expanded( + flex: 2, + child: Column( + children: [ + if (isAdmin) generatorPanel, + if (isAdmin) const SizedBox(height: 16), + Expanded(child: swapsPanel), + ], + ), + ), + ], + ); + } + + return DefaultTabController( + length: isAdmin ? 3 : 2, + child: Column( + children: [ + const SizedBox(height: 8), + TabBar( + tabs: [ + const Tab(text: 'Schedule'), + const Tab(text: 'Swaps'), + if (isAdmin) const Tab(text: 'Generator'), + ], + ), + const SizedBox(height: 8), + Expanded( + child: TabBarView( + children: [ + schedulePanel, + swapsPanel, + if (isAdmin) generatorPanel, + ], + ), + ), + ], + ), + ); + }, + ), + ); + } +} + +class _SchedulePanel extends ConsumerWidget { + const _SchedulePanel({required this.isAdmin}); + + final bool isAdmin; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final schedulesAsync = ref.watch(dutySchedulesProvider); + final profilesAsync = ref.watch(profilesProvider); + final currentUserId = ref.watch(currentUserIdProvider); + + return schedulesAsync.when( + data: (schedules) { + if (schedules.isEmpty) { + return const Center(child: Text('No schedules yet.')); + } + + final Map profileById = { + for (final profile in profilesAsync.valueOrNull ?? []) + profile.id: profile, + }; + final grouped = >{}; + for (final schedule in schedules) { + final day = DateTime( + schedule.startTime.year, + schedule.startTime.month, + schedule.startTime.day, + ); + grouped.putIfAbsent(day, () => []).add(schedule); + } + + final days = grouped.keys.toList()..sort(); + + return ListView.builder( + padding: const EdgeInsets.only(bottom: 24), + itemCount: days.length, + itemBuilder: (context, index) { + final day = days[index]; + final items = grouped[day]! + ..sort((a, b) => a.startTime.compareTo(b.startTime)); + return Padding( + padding: const EdgeInsets.only(bottom: 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + _formatDay(day), + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 8), + ...items.map( + (schedule) => _ScheduleTile( + schedule: schedule, + displayName: _scheduleName( + profileById, + schedule, + isAdmin, + ), + isMine: schedule.userId == currentUserId, + ), + ), + ], + ), + ); + }, + ); + }, + loading: () => const Center(child: CircularProgressIndicator()), + error: (error, _) => + Center(child: Text('Failed to load schedules: $error')), + ); + } + + String _scheduleName( + Map profileById, + DutySchedule schedule, + bool isAdmin, + ) { + if (!isAdmin) { + return _shiftLabel(schedule.shiftType); + } + final profile = profileById[schedule.userId]; + final name = profile?.fullName.isNotEmpty == true + ? profile!.fullName + : schedule.userId; + return '${_shiftLabel(schedule.shiftType)} · $name'; + } + + String _shiftLabel(String value) { + switch (value) { + case 'am': + return 'AM Duty'; + case 'pm': + return 'PM Duty'; + case 'on_call': + return 'On Call'; + case 'normal': + return 'Normal'; + case 'weekend': + return 'Weekend'; + default: + return value; + } + } + + String _formatDay(DateTime value) { + return _formatFullDate(value); + } + + String _formatFullDate(DateTime value) { + const months = [ + 'Jan', + 'Feb', + 'Mar', + 'Apr', + 'May', + 'Jun', + 'Jul', + 'Aug', + 'Sep', + 'Oct', + 'Nov', + 'Dec', + ]; + const weekdays = [ + 'Monday', + 'Tuesday', + 'Wednesday', + 'Thursday', + 'Friday', + 'Saturday', + 'Sunday', + ]; + final month = months[value.month - 1]; + final day = value.day.toString().padLeft(2, '0'); + final weekday = weekdays[value.weekday - 1]; + return '$weekday, $month $day, ${value.year}'; + } +} + +class _ScheduleTile extends ConsumerWidget { + const _ScheduleTile({ + required this.schedule, + required this.displayName, + required this.isMine, + }); + + final DutySchedule schedule; + final String displayName; + final bool isMine; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final currentUserId = ref.watch(currentUserIdProvider); + final swaps = ref.watch(swapRequestsProvider).valueOrNull ?? []; + final now = DateTime.now(); + final isPast = schedule.startTime.isBefore(now); + final canCheckIn = + isMine && + schedule.checkInAt == null && + (schedule.status == 'scheduled' || schedule.status == 'late') && + now.isAfter(schedule.startTime.subtract(const Duration(minutes: 15))) && + now.isBefore(schedule.endTime); + final hasRequestedSwap = swaps.any( + (swap) => + swap.shiftId == schedule.id && + swap.requesterId == currentUserId && + swap.status == 'pending', + ); + final canRequestSwap = isMine && schedule.status != 'absent' && !isPast; + + return Card( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + displayName, + style: Theme.of(context).textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 4), + Text( + '${_formatTime(schedule.startTime)} - ${_formatTime(schedule.endTime)}', + style: Theme.of(context).textTheme.bodySmall, + ), + const SizedBox(height: 4), + Text( + _statusLabel(schedule.status), + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: _statusColor(context, schedule.status), + ), + ), + ], + ), + ), + Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + if (canCheckIn) + FilledButton.icon( + onPressed: () => _handleCheckIn(context, ref, schedule), + icon: const Icon(Icons.location_on), + label: const Text('Check in'), + ), + if (canRequestSwap) ...[ + if (canCheckIn) const SizedBox(height: 8), + OutlinedButton.icon( + onPressed: hasRequestedSwap + ? () => _openSwapsTab(context) + : () => _requestSwap(context, ref, schedule), + icon: const Icon(Icons.swap_horiz), + label: Text( + hasRequestedSwap ? 'Swap Requested' : 'Request swap', + ), + ), + ], + ], + ), + ], + ), + ), + ); + } + + Future _handleCheckIn( + BuildContext context, + WidgetRef ref, + DutySchedule schedule, + ) async { + final geofence = await ref.read(geofenceProvider.future); + if (geofence == null) { + if (!context.mounted) return; + _showMessage(context, 'Geofence is not configured.'); + return; + } + + final serviceEnabled = await Geolocator.isLocationServiceEnabled(); + if (!serviceEnabled) { + if (!context.mounted) return; + _showMessage(context, 'Location services are disabled.'); + return; + } + + var permission = await Geolocator.checkPermission(); + if (permission == LocationPermission.denied) { + permission = await Geolocator.requestPermission(); + } + if (permission == LocationPermission.denied || + permission == LocationPermission.deniedForever) { + if (!context.mounted) return; + _showMessage(context, 'Location permission denied.'); + return; + } + + final position = await Geolocator.getCurrentPosition( + locationSettings: const LocationSettings(accuracy: LocationAccuracy.high), + ); + + final distance = Geolocator.distanceBetween( + position.latitude, + position.longitude, + geofence.lat, + geofence.lng, + ); + + if (distance > geofence.radiusMeters) { + if (!context.mounted) return; + _showMessage(context, 'You are outside the geofence. Wala ka sa CRMC.'); + return; + } + + try { + final status = await ref + .read(workforceControllerProvider) + .checkIn( + dutyScheduleId: schedule.id, + lat: position.latitude, + lng: position.longitude, + ); + ref.invalidate(dutySchedulesProvider); + if (!context.mounted) return; + _showMessage(context, 'Checked in ($status).'); + } catch (error) { + if (!context.mounted) return; + _showMessage(context, 'Check-in failed: $error'); + } + } + + Future _requestSwap( + BuildContext context, + WidgetRef ref, + DutySchedule schedule, + ) async { + final profiles = ref.read(profilesProvider).valueOrNull ?? []; + final currentUserId = ref.read(currentUserIdProvider); + final staff = profiles + .where((profile) => profile.role == 'it_staff') + .where((profile) => profile.id != currentUserId) + .toList(); + if (staff.isEmpty) { + _showMessage(context, 'No IT staff available for swaps.'); + return; + } + + String? selectedId = staff.first.id; + + final confirmed = await showDialog( + context: context, + builder: (dialogContext) { + return AlertDialog( + title: const Text('Request swap'), + content: DropdownButtonFormField( + initialValue: selectedId, + items: [ + for (final profile in staff) + DropdownMenuItem( + value: profile.id, + child: Text( + profile.fullName.isNotEmpty ? profile.fullName : profile.id, + ), + ), + ], + onChanged: (value) => selectedId = value, + decoration: const InputDecoration(labelText: 'Recipient'), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(false), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () => Navigator.of(dialogContext).pop(true), + child: const Text('Send request'), + ), + ], + ); + }, + ); + + if (!context.mounted) return; + if (confirmed != true || selectedId == null) return; + + try { + await ref + .read(workforceControllerProvider) + .requestSwap(shiftId: schedule.id, recipientId: selectedId!); + ref.invalidate(swapRequestsProvider); + if (!context.mounted) return; + _showMessage(context, 'Swap request sent.'); + } catch (error) { + if (!context.mounted) return; + _showMessage(context, 'Swap request failed: $error'); + } + } + + void _showMessage(BuildContext context, String message) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(message))); + } + + void _openSwapsTab(BuildContext context) { + final controller = DefaultTabController.maybeOf(context); + if (controller != null) { + controller.animateTo(1); + return; + } + _showMessage(context, 'Swap request already sent. See Swaps panel.'); + } + + String _formatTime(DateTime value) { + final rawHour = value.hour; + final hour = (rawHour % 12 == 0 ? 12 : rawHour % 12).toString().padLeft( + 2, + '0', + ); + final minute = value.minute.toString().padLeft(2, '0'); + final suffix = rawHour >= 12 ? 'PM' : 'AM'; + return '$hour:$minute $suffix'; + } + + String _statusLabel(String status) { + switch (status) { + case 'arrival': + return 'Arrival'; + case 'late': + return 'Late'; + case 'absent': + return 'Absent'; + default: + return 'Scheduled'; + } + } + + Color _statusColor(BuildContext context, String status) { + switch (status) { + case 'arrival': + return Colors.green; + case 'late': + return Colors.orange; + case 'absent': + return Colors.red; + default: + return Theme.of(context).colorScheme.onSurfaceVariant; + } + } +} + +class _DraftSchedule { + _DraftSchedule({ + required this.localId, + required this.userId, + required this.shiftType, + required this.startTime, + required this.endTime, + }); + + final int localId; + String userId; + String shiftType; + DateTime startTime; + DateTime endTime; +} + +class _RotationEntry { + _RotationEntry({ + required this.userId, + required this.shiftType, + required this.startTime, + }); + + final String userId; + final String shiftType; + final DateTime startTime; +} + +class _ShiftTemplate { + _ShiftTemplate({ + required this.startHour, + required this.startMinute, + required this.duration, + }); + + final int startHour; + final int startMinute; + final Duration duration; + + DateTime buildStart(DateTime day) { + return DateTime(day.year, day.month, day.day, startHour, startMinute); + } + + DateTime buildEnd(DateTime start) { + return start.add(duration); + } +} + +class _ScheduleGeneratorPanel extends ConsumerStatefulWidget { + const _ScheduleGeneratorPanel({required this.enabled}); + + final bool enabled; + + @override + ConsumerState<_ScheduleGeneratorPanel> createState() => + _ScheduleGeneratorPanelState(); +} + +class _ScheduleGeneratorPanelState + extends ConsumerState<_ScheduleGeneratorPanel> { + DateTime? _startDate; + DateTime? _endDate; + bool _isGenerating = false; + bool _isSaving = false; + int _draftCounter = 0; + List<_DraftSchedule> _draftSchedules = []; + List _warnings = []; + + @override + Widget build(BuildContext context) { + if (!widget.enabled) { + return const SizedBox.shrink(); + } + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Schedule Generator', + style: Theme.of( + context, + ).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700), + ), + const SizedBox(height: 12), + _dateField( + context, + label: 'Start date', + value: _startDate, + onTap: () => _pickDate(isStart: true), + ), + const SizedBox(height: 8), + _dateField( + context, + label: 'End date', + value: _endDate, + onTap: () => _pickDate(isStart: false), + ), + const SizedBox(height: 16), + Row( + children: [ + Expanded( + child: FilledButton( + onPressed: _isGenerating ? null : _handleGenerate, + child: _isGenerating + ? const SizedBox( + height: 18, + width: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Text('Generate preview'), + ), + ), + const SizedBox(width: 12), + OutlinedButton( + onPressed: _draftSchedules.isEmpty ? null : _clearDraft, + child: const Text('Clear'), + ), + ], + ), + if (_warnings.isNotEmpty) ...[ + const SizedBox(height: 12), + _buildWarningPanel(context), + ], + if (_draftSchedules.isNotEmpty) ...[ + const SizedBox(height: 12), + _buildDraftHeader(context), + const SizedBox(height: 8), + Flexible(fit: FlexFit.loose, child: _buildDraftList(context)), + const SizedBox(height: 12), + Row( + children: [ + OutlinedButton.icon( + onPressed: _isSaving ? null : _addDraft, + icon: const Icon(Icons.add), + label: const Text('Add shift'), + ), + const Spacer(), + FilledButton.icon( + onPressed: _isSaving ? null : _commitDraft, + icon: const Icon(Icons.check_circle), + label: _isSaving + ? const SizedBox( + height: 18, + width: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Text('Commit schedule'), + ), + ], + ), + ], + ], + ), + ), + ); + } + + Widget _dateField( + BuildContext context, { + required String label, + required DateTime? value, + required VoidCallback onTap, + }) { + return InkWell( + onTap: onTap, + child: InputDecorator( + decoration: InputDecoration(labelText: label), + child: Text(value == null ? 'Select date' : _formatDate(value)), + ), + ); + } + + Future _pickDate({required bool isStart}) async { + final now = DateTime.now(); + final initial = isStart ? _startDate ?? now : _endDate ?? _startDate ?? now; + final picked = await showDatePicker( + context: context, + initialDate: initial, + firstDate: DateTime(now.year - 1), + lastDate: DateTime(now.year + 2), + ); + if (picked == null) return; + setState(() { + if (isStart) { + _startDate = picked; + } else { + _endDate = picked; + } + }); + } + + Future _handleGenerate() async { + final start = _startDate; + final end = _endDate; + if (start == null || end == null) { + _showMessage('Select a date range.'); + return; + } + if (end.isBefore(start)) { + _showMessage('End date must be after start date.'); + return; + } + setState(() => _isGenerating = true); + try { + final staff = _sortedStaff(); + if (staff.isEmpty) { + if (!mounted) return; + _showMessage('No IT staff available for scheduling.'); + return; + } + + final schedules = ref.read(dutySchedulesProvider).valueOrNull ?? []; + final templates = _buildTemplates(schedules); + final generated = _generateDrafts( + start, + end, + staff, + schedules, + templates, + ); + generated.sort((a, b) => a.startTime.compareTo(b.startTime)); + final warnings = _buildWarnings(start, end, generated); + + if (!mounted) return; + setState(() { + _draftSchedules = generated; + _warnings = warnings; + }); + + if (generated.isEmpty) { + _showMessage('No shifts could be generated.'); + } + } finally { + if (mounted) { + setState(() => _isGenerating = false); + } + } + } + + void _clearDraft() { + setState(() { + _draftSchedules = []; + _warnings = []; + }); + } + + Widget _buildWarningPanel(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: colorScheme.tertiaryContainer, + borderRadius: BorderRadius.circular(12), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(Icons.warning_amber, color: colorScheme.onTertiaryContainer), + const SizedBox(width: 8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Uncovered shifts', + style: Theme.of(context).textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w700, + color: colorScheme.onTertiaryContainer, + ), + ), + const SizedBox(height: 6), + for (final warning in _warnings) + Text( + warning, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.onTertiaryContainer, + ), + ), + ], + ), + ), + ], + ), + ); + } + + Widget _buildDraftHeader(BuildContext context) { + final start = _startDate == null ? '' : _formatDate(_startDate!); + final end = _endDate == null ? '' : _formatDate(_endDate!); + return Row( + children: [ + Text( + 'Preview (${_draftSchedules.length})', + style: Theme.of( + context, + ).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w700), + ), + const Spacer(), + if (start.isNotEmpty && end.isNotEmpty) + Text('$start → $end', style: Theme.of(context).textTheme.bodySmall), + ], + ); + } + + Widget _buildDraftList(BuildContext context) { + return ListView.separated( + primary: false, + itemCount: _draftSchedules.length, + separatorBuilder: (context, index) => const SizedBox(height: 8), + itemBuilder: (context, index) { + final draft = _draftSchedules[index]; + final profile = _profileById()[draft.userId]; + final userLabel = profile?.fullName.isNotEmpty == true + ? profile!.fullName + : draft.userId; + return Card( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '${_shiftLabel(draft.shiftType)} · $userLabel', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 4), + Text( + '${_formatDate(draft.startTime)} · ${_formatTime(draft.startTime)} - ${_formatTime(draft.endTime)}', + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ), + ), + IconButton( + tooltip: 'Edit', + onPressed: () => _editDraft(draft), + icon: const Icon(Icons.edit), + ), + IconButton( + tooltip: 'Delete', + onPressed: () => _deleteDraft(draft), + icon: const Icon(Icons.delete_outline), + ), + ], + ), + ), + ); + }, + ); + } + + void _addDraft() { + _openDraftEditor(); + } + + void _editDraft(_DraftSchedule draft) { + _openDraftEditor(existing: draft); + } + + void _deleteDraft(_DraftSchedule draft) { + setState(() { + _draftSchedules.removeWhere((item) => item.localId == draft.localId); + _warnings = _buildWarnings( + _startDate ?? DateTime.now(), + _endDate ?? DateTime.now(), + _draftSchedules, + ); + }); + } + + Future _openDraftEditor({_DraftSchedule? existing}) async { + final staff = _sortedStaff(); + if (staff.isEmpty) { + _showMessage('No IT staff available.'); + return; + } + + final start = existing?.startTime ?? _startDate ?? DateTime.now(); + var selectedDate = DateTime(start.year, start.month, start.day); + var selectedUserId = existing?.userId ?? staff.first.id; + var selectedShift = existing?.shiftType ?? 'am'; + var startTime = TimeOfDay.fromDateTime(existing?.startTime ?? start); + var endTime = TimeOfDay.fromDateTime( + existing?.endTime ?? start.add(const Duration(hours: 8)), + ); + + final result = await showDialog<_DraftSchedule>( + context: context, + builder: (dialogContext) { + return StatefulBuilder( + builder: (context, setDialogState) { + return AlertDialog( + title: Text(existing == null ? 'Add shift' : 'Edit shift'), + content: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + DropdownButtonFormField( + initialValue: selectedUserId, + items: [ + for (final profile in staff) + DropdownMenuItem( + value: profile.id, + child: Text( + profile.fullName.isNotEmpty + ? profile.fullName + : profile.id, + ), + ), + ], + onChanged: (value) { + if (value == null) return; + setDialogState(() => selectedUserId = value); + }, + decoration: const InputDecoration(labelText: 'Assignee'), + ), + const SizedBox(height: 12), + DropdownButtonFormField( + initialValue: selectedShift, + items: const [ + DropdownMenuItem(value: 'am', child: Text('AM Duty')), + DropdownMenuItem( + value: 'on_call', + child: Text('On Call'), + ), + DropdownMenuItem(value: 'pm', child: Text('PM Duty')), + ], + onChanged: (value) { + if (value == null) return; + setDialogState(() => selectedShift = value); + }, + decoration: const InputDecoration( + labelText: 'Shift type', + ), + ), + const SizedBox(height: 12), + _dialogDateField( + label: 'Date', + value: _formatDate(selectedDate), + onTap: () async { + final picked = await showDatePicker( + context: context, + initialDate: selectedDate, + firstDate: DateTime(2020), + lastDate: DateTime(2100), + ); + if (picked == null) return; + setDialogState(() { + selectedDate = picked; + }); + }, + ), + const SizedBox(height: 12), + _dialogTimeField( + label: 'Start time', + value: startTime, + onTap: () async { + final picked = await showTimePicker( + context: context, + initialTime: startTime, + ); + if (picked == null) return; + setDialogState(() => startTime = picked); + }, + ), + const SizedBox(height: 12), + _dialogTimeField( + label: 'End time', + value: endTime, + onTap: () async { + final picked = await showTimePicker( + context: context, + initialTime: endTime, + ); + if (picked == null) return; + setDialogState(() => endTime = picked); + }, + ), + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () { + final startDateTime = DateTime( + selectedDate.year, + selectedDate.month, + selectedDate.day, + startTime.hour, + startTime.minute, + ); + var endDateTime = DateTime( + selectedDate.year, + selectedDate.month, + selectedDate.day, + endTime.hour, + endTime.minute, + ); + if (!endDateTime.isAfter(startDateTime)) { + endDateTime = endDateTime.add(const Duration(days: 1)); + } + + final draft = + existing ?? + _DraftSchedule( + localId: _draftCounter++, + userId: selectedUserId, + shiftType: selectedShift, + startTime: startDateTime, + endTime: endDateTime, + ); + + draft + ..userId = selectedUserId + ..shiftType = selectedShift + ..startTime = startDateTime + ..endTime = endDateTime; + + Navigator.of(dialogContext).pop(draft); + }, + child: const Text('Save'), + ), + ], + ); + }, + ); + }, + ); + + if (!mounted || result == null) return; + setState(() { + if (existing == null) { + _draftSchedules.add(result); + } + _draftSchedules.sort((a, b) => a.startTime.compareTo(b.startTime)); + _warnings = _buildWarnings( + _startDate ?? result.startTime, + _endDate ?? result.endTime, + _draftSchedules, + ); + }); + } + + Widget _dialogDateField({ + required String label, + required String value, + required VoidCallback onTap, + }) { + return InkWell( + onTap: onTap, + child: InputDecorator( + decoration: InputDecoration(labelText: label), + child: Text(value), + ), + ); + } + + Widget _dialogTimeField({ + required String label, + required TimeOfDay value, + required VoidCallback onTap, + }) { + return InkWell( + onTap: onTap, + child: InputDecorator( + decoration: InputDecoration(labelText: label), + child: Text(value.format(context)), + ), + ); + } + + Future _commitDraft() async { + final start = _startDate; + final end = _endDate; + if (start == null || end == null) { + _showMessage('Select a date range.'); + return; + } + + final schedules = ref.read(dutySchedulesProvider).valueOrNull ?? []; + final conflict = _findConflict(_draftSchedules, schedules); + if (conflict != null) { + _showMessage(conflict); + return; + } + + final payload = _draftSchedules + .map( + (draft) => { + 'user_id': draft.userId, + 'shift_type': draft.shiftType, + 'start_time': draft.startTime.toIso8601String(), + 'end_time': draft.endTime.toIso8601String(), + 'status': 'scheduled', + }, + ) + .toList(); + + setState(() => _isSaving = true); + try { + await ref.read(workforceControllerProvider).insertSchedules(payload); + ref.invalidate(dutySchedulesProvider); + if (!mounted) return; + _showMessage('Schedule committed.'); + setState(() { + _draftSchedules = []; + _warnings = []; + }); + } catch (error) { + if (!mounted) return; + _showMessage('Commit failed: $error'); + } finally { + if (mounted) { + setState(() => _isSaving = false); + } + } + } + + List _sortedStaff() { + final profiles = ref.read(profilesProvider).valueOrNull ?? []; + final staff = profiles + .where((profile) => profile.role == 'it_staff') + .toList(); + staff.sort((a, b) { + final nameA = a.fullName.isNotEmpty ? a.fullName : a.id; + final nameB = b.fullName.isNotEmpty ? b.fullName : b.id; + final result = nameA.compareTo(nameB); + if (result != 0) return result; + return a.id.compareTo(b.id); + }); + return staff; + } + + Map _profileById() { + final profiles = ref.read(profilesProvider).valueOrNull ?? []; + return {for (final profile in profiles) profile.id: profile}; + } + + Map _buildTemplates(List schedules) { + final templates = {}; + for (final schedule in schedules) { + final key = _normalizeShiftType(schedule.shiftType); + if (templates.containsKey(key)) continue; + final start = schedule.startTime; + var end = schedule.endTime; + if (!end.isAfter(start)) { + end = end.add(const Duration(days: 1)); + } + templates[key] = _ShiftTemplate( + startHour: start.hour, + startMinute: start.minute, + duration: end.difference(start), + ); + } + templates['am'] = _ShiftTemplate( + startHour: 7, + startMinute: 0, + duration: const Duration(hours: 8), + ); + templates['pm'] = _ShiftTemplate( + startHour: 15, + startMinute: 0, + duration: const Duration(hours: 8), + ); + templates['on_call'] = _ShiftTemplate( + startHour: 23, + startMinute: 0, + duration: const Duration(hours: 8), + ); + templates['normal'] = _ShiftTemplate( + startHour: 8, + startMinute: 0, + duration: const Duration(hours: 9), + ); + return templates; + } + + List<_DraftSchedule> _generateDrafts( + DateTime start, + DateTime end, + List staff, + List schedules, + Map templates, + ) { + final draft = <_DraftSchedule>[]; + final normalizedStart = DateTime(start.year, start.month, start.day); + final normalizedEnd = DateTime(end.year, end.month, end.day); + final existing = schedules; + var weekStart = _startOfWeek(normalizedStart); + + while (!weekStart.isAfter(normalizedEnd)) { + final weekEnd = weekStart.add(const Duration(days: 6)); + final prevWeekStart = weekStart.subtract(const Duration(days: 7)); + final prevWeekEnd = weekStart.subtract(const Duration(days: 1)); + final lastWeek = <_RotationEntry>[ + ...existing + .where( + (schedule) => + !schedule.startTime.isBefore(prevWeekStart) && + !schedule.startTime.isAfter(prevWeekEnd), + ) + .map( + (schedule) => _RotationEntry( + userId: schedule.userId, + shiftType: schedule.shiftType, + startTime: schedule.startTime, + ), + ), + ...draft + .where( + (item) => + !item.startTime.isBefore(prevWeekStart) && + !item.startTime.isAfter(prevWeekEnd), + ) + .map( + (item) => _RotationEntry( + userId: item.userId, + shiftType: item.shiftType, + startTime: item.startTime, + ), + ), + ]; + + final amBaseIndex = _nextIndexFromLastWeek( + shiftType: 'am', + staff: staff, + lastWeek: lastWeek, + defaultIndex: 0, + ); + final pmBaseIndex = _nextIndexFromLastWeek( + shiftType: 'pm', + staff: staff, + lastWeek: lastWeek, + defaultIndex: staff.length > 1 ? 1 : 0, + ); + final amUserId = staff.isEmpty + ? null + : staff[amBaseIndex % staff.length].id; + final pmUserId = staff.isEmpty + ? null + : staff[pmBaseIndex % staff.length].id; + final nextWeekPmUserId = staff.isEmpty + ? null + : staff[(pmBaseIndex + 1) % staff.length].id; + var weekendNormalOffset = 0; + + for ( + var day = weekStart; + !day.isAfter(weekEnd); + day = day.add(const Duration(days: 1)) + ) { + if (day.isBefore(normalizedStart) || day.isAfter(normalizedEnd)) { + continue; + } + final isWeekend = + day.weekday == DateTime.saturday || day.weekday == DateTime.sunday; + if (isWeekend) { + if (staff.isNotEmpty) { + final normalIndex = + (amBaseIndex + pmBaseIndex + weekendNormalOffset) % + staff.length; + _tryAddDraft( + draft, + existing, + templates, + 'normal', + staff[normalIndex].id, + day, + ); + weekendNormalOffset += 1; + } + if (nextWeekPmUserId != null) { + _tryAddDraft( + draft, + existing, + templates, + 'on_call', + nextWeekPmUserId, + day, + ); + } + } else { + if (amUserId != null) { + _tryAddDraft(draft, existing, templates, 'am', amUserId, day); + } + if (pmUserId != null) { + _tryAddDraft(draft, existing, templates, 'pm', pmUserId, day); + _tryAddDraft(draft, existing, templates, 'on_call', pmUserId, day); + } + + final assignedToday = [ + amUserId, + pmUserId, + ].whereType().toSet(); + for (final profile in staff) { + if (assignedToday.contains(profile.id)) continue; + _tryAddDraft(draft, existing, templates, 'normal', profile.id, day); + } + } + } + + weekStart = weekStart.add(const Duration(days: 7)); + } + + return draft; + } + + void _tryAddDraft( + List<_DraftSchedule> draft, + List existing, + Map templates, + String shiftType, + String userId, + DateTime day, + ) { + final template = templates[_normalizeShiftType(shiftType)]!; + final start = template.buildStart(day); + final end = template.buildEnd(start); + final candidate = _DraftSchedule( + localId: _draftCounter++, + userId: userId, + shiftType: shiftType, + startTime: start, + endTime: end, + ); + + if (_hasConflict(candidate, draft, existing)) { + return; + } + draft.add(candidate); + } + + int _nextIndexFromLastWeek({ + required String shiftType, + required List staff, + required Iterable<_RotationEntry> lastWeek, + required int defaultIndex, + }) { + if (staff.isEmpty) return 0; + final normalized = _normalizeShiftType(shiftType); + final lastAssignment = + lastWeek + .where( + (entry) => _normalizeShiftType(entry.shiftType) == normalized, + ) + .toList() + ..sort((a, b) => a.startTime.compareTo(b.startTime)); + + if (lastAssignment.isNotEmpty) { + final lastUserId = lastAssignment.last.userId; + final index = staff.indexWhere((profile) => profile.id == lastUserId); + if (index != -1) { + return (index + 1) % staff.length; + } + } + + return defaultIndex % staff.length; + } + + bool _hasConflict( + _DraftSchedule candidate, + List<_DraftSchedule> drafts, + List existing, + ) { + for (final draft in drafts) { + if (draft.userId != candidate.userId) continue; + if (_overlaps( + candidate.startTime, + candidate.endTime, + draft.startTime, + draft.endTime, + )) { + return true; + } + } + for (final schedule in existing) { + if (schedule.userId != candidate.userId) continue; + if (_overlaps( + candidate.startTime, + candidate.endTime, + schedule.startTime, + schedule.endTime, + )) { + return true; + } + } + return false; + } + + String? _findConflict( + List<_DraftSchedule> drafts, + List existing, + ) { + for (final draft in drafts) { + if (_hasConflict( + draft, + drafts.where((d) => d.localId != draft.localId).toList(), + existing, + )) { + return 'Conflict found for ${_formatDate(draft.startTime)}.'; + } + } + return null; + } + + bool _overlaps( + DateTime startA, + DateTime endA, + DateTime startB, + DateTime endB, + ) { + return startA.isBefore(endB) && endA.isAfter(startB); + } + + List _buildWarnings( + DateTime start, + DateTime end, + List<_DraftSchedule> drafts, + ) { + final warnings = []; + var day = DateTime(start.year, start.month, start.day); + final lastDay = DateTime(end.year, end.month, end.day); + + while (!day.isAfter(lastDay)) { + final isWeekend = + day.weekday == DateTime.saturday || day.weekday == DateTime.sunday; + final required = {'on_call'}; + if (isWeekend) { + required.add('normal'); + } else { + required.addAll({'am', 'pm'}); + } + + final available = drafts + .where( + (draft) => + draft.startTime.year == day.year && + draft.startTime.month == day.month && + draft.startTime.day == day.day, + ) + .map((draft) => _normalizeShiftType(draft.shiftType)) + .toSet(); + + for (final shift in required) { + if (!available.contains(shift)) { + warnings.add('${_formatDate(day)} missing ${_shiftLabel(shift)}'); + } + } + + day = day.add(const Duration(days: 1)); + } + + return warnings; + } + + DateTime _startOfWeek(DateTime day) { + return day.subtract(Duration(days: day.weekday - 1)); + } + + String _normalizeShiftType(String value) { + return value; + } + + String _shiftLabel(String value) { + switch (value) { + case 'am': + return 'AM Duty'; + case 'pm': + return 'PM Duty'; + case 'on_call': + return 'On Call'; + case 'normal': + return 'Normal'; + case 'weekend': + return 'Weekend'; + default: + return value; + } + } + + void _showMessage(String message) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(message))); + } + + String _formatTime(DateTime value) { + final rawHour = value.hour; + final hour = (rawHour % 12 == 0 ? 12 : rawHour % 12).toString().padLeft( + 2, + '0', + ); + final minute = value.minute.toString().padLeft(2, '0'); + final suffix = rawHour >= 12 ? 'PM' : 'AM'; + return '$hour:$minute $suffix'; + } + + String _formatDate(DateTime value) { + const months = [ + 'Jan', + 'Feb', + 'Mar', + 'Apr', + 'May', + 'Jun', + 'Jul', + 'Aug', + 'Sep', + 'Oct', + 'Nov', + 'Dec', + ]; + const weekdays = [ + 'Monday', + 'Tuesday', + 'Wednesday', + 'Thursday', + 'Friday', + 'Saturday', + 'Sunday', + ]; + final month = months[value.month - 1]; + final day = value.day.toString().padLeft(2, '0'); + final weekday = weekdays[value.weekday - 1]; + return '$weekday, $month $day, ${value.year}'; + } +} + +class _SwapRequestsPanel extends ConsumerWidget { + const _SwapRequestsPanel({required this.isAdmin}); + + final bool isAdmin; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final swapsAsync = ref.watch(swapRequestsProvider); + final schedulesAsync = ref.watch(dutySchedulesProvider); + final profilesAsync = ref.watch(profilesProvider); + final currentUserId = ref.watch(currentUserIdProvider); + + final scheduleById = { + for (final schedule in schedulesAsync.valueOrNull ?? []) + schedule.id: schedule, + }; + final profileById = { + for (final profile in profilesAsync.valueOrNull ?? []) + profile.id: profile, + }; + + return swapsAsync.when( + data: (items) { + if (items.isEmpty) { + return const Center(child: Text('No swap requests.')); + } + return ListView.separated( + padding: const EdgeInsets.only(bottom: 24), + itemCount: items.length, + separatorBuilder: (context, index) => const SizedBox(height: 12), + itemBuilder: (context, index) { + final item = items[index]; + final schedule = scheduleById[item.shiftId]; + final requesterProfile = profileById[item.requesterId]; + final recipientProfile = profileById[item.recipientId]; + final requester = requesterProfile?.fullName.isNotEmpty == true + ? requesterProfile!.fullName + : item.requesterId; + final recipient = recipientProfile?.fullName.isNotEmpty == true + ? recipientProfile!.fullName + : item.recipientId; + final subtitle = schedule == null + ? 'Shift not found' + : '${_shiftLabel(schedule.shiftType)} · ${_formatDate(schedule.startTime)} · ${_formatTime(schedule.startTime)}'; + + final isPending = item.status == 'pending'; + final canRespond = + (isAdmin || item.recipientId == currentUserId) && isPending; + final canEscalate = item.requesterId == currentUserId && isPending; + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '$requester → $recipient', + style: Theme.of(context).textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 6), + Text(subtitle), + const SizedBox(height: 6), + Text('Status: ${item.status}'), + const SizedBox(height: 12), + Row( + children: [ + if (canRespond) ...[ + OutlinedButton( + onPressed: () => _respond(ref, item, 'accepted'), + child: const Text('Accept'), + ), + const SizedBox(width: 8), + OutlinedButton( + onPressed: () => _respond(ref, item, 'rejected'), + child: const Text('Reject'), + ), + ], + if (canEscalate) ...[ + const SizedBox(width: 8), + OutlinedButton( + onPressed: () => + _respond(ref, item, 'admin_review'), + child: const Text('Escalate'), + ), + ], + ], + ), + ], + ), + ), + ); + }, + ); + }, + loading: () => const Center(child: CircularProgressIndicator()), + error: (error, _) => Center(child: Text('Failed to load swaps: $error')), + ); + } + + Future _respond( + WidgetRef ref, + SwapRequest request, + String action, + ) async { + await ref + .read(workforceControllerProvider) + .respondSwap(swapId: request.id, action: action); + ref.invalidate(swapRequestsProvider); + } + + String _shiftLabel(String value) { + switch (value) { + case 'am': + return 'AM Duty'; + case 'pm': + return 'PM Duty'; + case 'on_call': + return 'On Call'; + case 'normal': + return 'Normal'; + case 'weekend': + return 'Weekend'; + default: + return value; + } + } + + String _formatTime(DateTime value) { + final rawHour = value.hour; + final hour = (rawHour % 12 == 0 ? 12 : rawHour % 12).toString().padLeft( + 2, + '0', + ); + final minute = value.minute.toString().padLeft(2, '0'); + final suffix = rawHour >= 12 ? 'PM' : 'AM'; + return '$hour:$minute $suffix'; + } + + String _formatDate(DateTime value) { + const months = [ + 'Jan', + 'Feb', + 'Mar', + 'Apr', + 'May', + 'Jun', + 'Jul', + 'Aug', + 'Sep', + 'Oct', + 'Nov', + 'Dec', + ]; + const weekdays = [ + 'Monday', + 'Tuesday', + 'Wednesday', + 'Thursday', + 'Friday', + 'Saturday', + 'Sunday', + ]; + final month = months[value.month - 1]; + final day = value.day.toString().padLeft(2, '0'); + final weekday = weekdays[value.weekday - 1]; + return '$weekday, $month $day, ${value.year}'; + } +} diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index d282e3e5..c7d6decd 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -7,12 +7,14 @@ import Foundation import app_links import audioplayers_darwin +import geolocator_apple import shared_preferences_foundation import url_launcher_macos func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { AppLinksMacosPlugin.register(with: registry.registrar(forPlugin: "AppLinksMacosPlugin")) AudioplayersDarwinPlugin.register(with: registry.registrar(forPlugin: "AudioplayersDarwinPlugin")) + GeolocatorPlugin.register(with: registry.registrar(forPlugin: "GeolocatorPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) } diff --git a/pubspec.lock b/pubspec.lock index aa4fa3e7..aad4e5c8 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -304,6 +304,54 @@ packages: url: "https://pub.dev" source: hosted version: "2.5.0" + geolocator: + dependency: "direct main" + description: + name: geolocator + sha256: f62bcd90459e63210bbf9c35deb6a51c521f992a78de19a1fe5c11704f9530e2 + url: "https://pub.dev" + source: hosted + version: "13.0.4" + geolocator_android: + dependency: transitive + description: + name: geolocator_android + sha256: fcb1760a50d7500deca37c9a666785c047139b5f9ee15aa5469fae7dbbe3170d + url: "https://pub.dev" + source: hosted + version: "4.6.2" + geolocator_apple: + dependency: transitive + description: + name: geolocator_apple + sha256: dbdd8789d5aaf14cf69f74d4925ad1336b4433a6efdf2fce91e8955dc921bf22 + url: "https://pub.dev" + source: hosted + version: "2.3.13" + geolocator_platform_interface: + dependency: transitive + description: + name: geolocator_platform_interface + sha256: "30cb64f0b9adcc0fb36f628b4ebf4f731a2961a0ebd849f4b56200205056fe67" + url: "https://pub.dev" + source: hosted + version: "4.2.6" + geolocator_web: + dependency: transitive + description: + name: geolocator_web + sha256: b1ae9bdfd90f861fde8fd4f209c37b953d65e92823cb73c7dee1fa021b06f172 + url: "https://pub.dev" + source: hosted + version: "4.1.3" + geolocator_windows: + dependency: transitive + description: + name: geolocator_windows + sha256: "175435404d20278ffd220de83c2ca293b73db95eafbdc8131fe8609be1421eb6" + url: "https://pub.dev" + source: hosted + version: "0.2.5" glob: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index f96df653..df936ddf 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -16,6 +16,7 @@ dependencies: font_awesome_flutter: ^10.7.0 google_fonts: ^6.2.1 audioplayers: ^6.1.0 + geolocator: ^13.0.1 dev_dependencies: flutter_test: diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index 0e1b3832..d82fa4dc 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -8,6 +8,7 @@ #include #include +#include #include void RegisterPlugins(flutter::PluginRegistry* registry) { @@ -15,6 +16,8 @@ void RegisterPlugins(flutter::PluginRegistry* registry) { registry->GetRegistrarForPlugin("AppLinksPluginCApi")); AudioplayersWindowsPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("AudioplayersWindowsPlugin")); + GeolocatorWindowsRegisterWithRegistrar( + registry->GetRegistrarForPlugin("GeolocatorWindows")); UrlLauncherWindowsRegisterWithRegistrar( registry->GetRegistrarForPlugin("UrlLauncherWindows")); } diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index 99c6ac61..210d2642 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -5,6 +5,7 @@ list(APPEND FLUTTER_PLUGIN_LIST app_links audioplayers_windows + geolocator_windows url_launcher_windows )