import 'dart:async'; 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'; import 'stream_recovery.dart'; import 'realtime_controller.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); }); /// Toggle to show/hide past schedules. Defaults to false (hide past). final showPastSchedulesProvider = StateProvider((ref) => false); final dutySchedulesProvider = StreamProvider>((ref) { final client = ref.watch(supabaseClientProvider); // Only recreate stream when user id changes (not on other profile edits). final profileId = ref.watch( currentProfileProvider.select((p) => p.valueOrNull?.id), ); if (profileId == null) { return Stream.value(const []); } // All roles now see all schedules (RLS updated in migration) final wrapper = StreamRecoveryWrapper( stream: client .from('duty_schedules') .stream(primaryKey: ['id']) .order('start_time'), onPollData: () async { final data = await client .from('duty_schedules') .select() .order('start_time'); return data.map(DutySchedule.fromMap).toList(); }, fromMap: DutySchedule.fromMap, channelName: 'duty_schedules', onStatusChanged: ref.read(realtimeControllerProvider).handleChannelStatus, ); ref.onDispose(wrapper.dispose); // Immediate poll so any changes that happened while this provider was // not alive (e.g. a swap was accepted on another device) are reflected // right away — before the 3-second periodic timer fires. wrapper.pollNow(); // Periodic safety-net: keep polling every 3 s so that ownership changes // (swap accepted → user_id updated on duty_schedules) are always picked // up even if Supabase Realtime misses the event. final dutyRefreshTimer = Timer.periodic(const Duration(seconds: 3), (_) { wrapper.pollNow(); }); ref.onDispose(dutyRefreshTimer.cancel); return wrapper.stream.map((result) => result.data); }); /// Fetch duty schedules by a list of IDs (used by UI when swap requests reference /// schedules that are not included in the current user's `dutySchedulesProvider`). final dutySchedulesByIdsProvider = FutureProvider.family, List>((ref, ids) async { if (ids.isEmpty) return const []; final client = ref.watch(supabaseClientProvider); final quoted = ids.map((id) => '"$id"').join(','); final inList = '($quoted)'; final rows = await client .from('duty_schedules') .select() .filter('id', 'in', inList) as List; return rows .map((r) => DutySchedule.fromMap(r as Map)) .toList(); }); /// Fetch upcoming duty schedules for a specific user (used by swap UI to /// let the requester pick a concrete target shift owned by the recipient). final dutySchedulesForUserProvider = FutureProvider.family, String>((ref, userId) async { final client = ref.watch(supabaseClientProvider); final nowIso = DateTime.now().toUtc().toIso8601String(); final rows = await client .from('duty_schedules') .select() .eq('user_id', userId) /* exclude past schedules by ensuring the shift has not ended */ .gte('end_time', nowIso) .order('start_time') as List; return rows .map((r) => DutySchedule.fromMap(r as Map)) .toList(); }); final swapRequestsProvider = StreamProvider>((ref) { final client = ref.watch(supabaseClientProvider); // Only recreate stream when user id or role changes. final profileId = ref.watch( currentProfileProvider.select((p) => p.valueOrNull?.id), ); final profileRole = ref.watch( currentProfileProvider.select((p) => p.valueOrNull?.role), ); if (profileId == null) { return Stream.value(const []); } final isAdmin = profileRole == 'admin' || profileRole == 'programmer' || profileRole == 'dispatcher'; final wrapper = StreamRecoveryWrapper( stream: isAdmin ? client .from('swap_requests') .stream(primaryKey: ['id']) .order('created_at', ascending: false) : client .from('swap_requests') .stream(primaryKey: ['id']) .order('created_at', ascending: false), onPollData: () async { final data = await client .from('swap_requests') .select() .inFilter('status', ['pending', 'admin_review']) .order('created_at', ascending: false); return data.map(SwapRequest.fromMap).toList(); }, fromMap: SwapRequest.fromMap, channelName: 'swap_requests', onStatusChanged: ref.read(realtimeControllerProvider).handleChannelStatus, ); ref.onDispose(wrapper.dispose); // Immediate poll: fetch fresh data right away so any status changes that // happened while this provider was not alive are reflected instantly, // before the 3-second periodic timer fires for the first time. wrapper.pollNow(); // Periodic safety-net: keep polling every 3 s to catch any status changes // that Supabase Realtime may have missed (e.g. when the swap_requests table // is not yet in the supabase_realtime publication). final refreshTimer = Timer.periodic(const Duration(seconds: 3), (_) { wrapper.pollNow(); }); ref.onDispose(refreshTimer.cancel); return wrapper.stream.map((result) { // only return requests that are still actionable; once a swap has been // accepted or rejected we no longer need to bubble it up to the UI for // either party. admins still see "admin_review" rows so they can act on // escalated cases. return result.data.where((row) { // admins see all swaps; standard users only see swaps they're in if (!isAdmin && !(row.requesterId == profileId || row.recipientId == profileId)) { return false; } // only keep pending and admin_review statuses return row.status == 'pending' || row.status == 'admin_review'; }).toList(); }); }); /// IDs of swap requests that were acted on locally (accepted, rejected, etc.). /// Kept as a global provider so the set survives tab switches — widget state /// is disposed when the user navigates away from My Schedule. final locallyRemovedSwapIdsProvider = StateProvider>((ref) => {}); /// IDs of duty_schedules owned by the current user that were created by an accepted swap. final swappedScheduleIdsProvider = Provider>((ref) { final schedules = ref.watch(dutySchedulesProvider).valueOrNull ?? []; return { for (final s in schedules) if (s.swapRequestId != null) s.id, }; }); 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 requesterScheduleId, required String targetScheduleId, required String recipientId, }) async { final data = await _client.rpc( 'request_shift_swap', params: { 'p_shift_id': requesterScheduleId, 'p_target_shift_id': targetScheduleId, '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}, ); } /// Reassign the recipient of a swap request. Only admins/dispatchers are /// expected to call this; the DB RLS and RPCs will additionally enforce rules. Future reassignSwap({ required String swapId, required String newRecipientId, }) async { // Prefer using an RPC for server-side validation, but update directly here await _client .from('swap_requests') .update({ 'recipient_id': newRecipientId, 'status': 'pending', 'updated_at': DateTime.now().toUtc().toIso8601String(), }) .eq('id', swapId); } Future updateSchedule({ required String scheduleId, required String userId, required String shiftType, required DateTime startTime, required DateTime endTime, }) async { await _client .from('duty_schedules') .update({ 'user_id': userId, 'shift_type': shiftType, 'start_time': startTime.toUtc().toIso8601String(), 'end_time': endTime.toUtc().toIso8601String(), }) .eq('id', scheduleId); } 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'; } }