import 'package:flutter/foundation.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:supabase_flutter/supabase_flutter.dart'; import 'package:uuid/uuid.dart'; import '../brick/cache_helpers.dart'; import '../models/pass_slip.dart'; import '../utils/app_time.dart'; import '../utils/snackbar.dart' show isOfflineSaveError; import 'connectivity_provider.dart'; import 'profile_provider.dart'; import 'supabase_provider.dart'; import 'stream_recovery.dart'; import 'realtime_controller.dart'; // --------------------------------------------------------------------------- // Offline-pending state // --------------------------------------------------------------------------- /// Pass slips filed offline that haven't been confirmed by Supabase yet. final offlinePendingPassSlipsProvider = StateProvider>((ref) => const []); /// Status edits (approve/reject/complete) made offline, keyed by slip id. final offlinePendingPassSlipUpdatesProvider = StateProvider>>((ref) => const {}); // --------------------------------------------------------------------------- // Read provider // --------------------------------------------------------------------------- /// All visible pass slips (own for staff, all for admin/dispatcher). final passSlipsProvider = 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 hasFullAccess = isAdmin || profile.role == 'programmer'; final pendingNew = ref.watch(offlinePendingPassSlipsProvider); final pendingUpdates = ref.watch(offlinePendingPassSlipUpdatesProvider); List applyPending(List rows) { final byId = {for (final r in rows) r.id: r}; pendingUpdates.forEach((id, fields) { final existing = byId[id]; if (existing == null) return; byId[id] = PassSlip( id: existing.id, userId: existing.userId, dutyScheduleId: existing.dutyScheduleId, reason: existing.reason, status: (fields['status'] as String?) ?? existing.status, requestedAt: existing.requestedAt, approvedBy: (fields['approved_by'] as String?) ?? existing.approvedBy, approvedAt: fields.containsKey('approved_at') ? (fields['approved_at'] is String ? AppTime.parse(fields['approved_at'] as String) : existing.approvedAt) : existing.approvedAt, slipStart: fields.containsKey('slip_start') ? (fields['slip_start'] is String ? AppTime.parse(fields['slip_start'] as String) : existing.slipStart) : existing.slipStart, slipEnd: fields.containsKey('slip_end') ? (fields['slip_end'] is String ? AppTime.parse(fields['slip_end'] as String) : existing.slipEnd) : existing.slipEnd, requestedStart: existing.requestedStart, ); }); for (final p in pendingNew) { if (hasFullAccess || p.userId == profile.id) { byId.putIfAbsent(p.id, () => p); } } final merged = byId.values.toList() ..sort((a, b) => b.requestedAt.compareTo(a.requestedAt)); return merged; } final wrapper = StreamRecoveryWrapper( stream: hasFullAccess ? client .from('pass_slips') .stream(primaryKey: ['id']) .order('requested_at', ascending: false) : client .from('pass_slips') .stream(primaryKey: ['id']) .eq('user_id', profile.id) .order('requested_at', ascending: false), onPollData: () async { final query = client.from('pass_slips').select(); final data = hasFullAccess ? await query.order('requested_at', ascending: false) : await query .eq('user_id', profile.id) .order('requested_at', ascending: false); return data.map(PassSlip.fromMap).toList(); }, fromMap: PassSlip.fromMap, channelName: 'pass_slips', onStatusChanged: ref.read(realtimeControllerProvider).handleChannelStatus, onOfflineData: () async { final all = await cachedListFromBrick(); final filtered = hasFullAccess ? all : all.where((s) => s.userId == profile.id).toList(); return applyPending(filtered); }, onCacheMirror: (rows) => mirrorBatchToBrick(rows, tag: 'pass_slips'), ); ref.onDispose(wrapper.dispose); // Reconnect-replay ref.listen(isOnlineProvider, (wasOnline, isNowOnline) async { if (wasOnline == true || !isNowOnline) return; final pending = List.from(ref.read(offlinePendingPassSlipsProvider)); if (pending.isNotEmpty) { debugPrint('[passSlipsProvider] reconnected — syncing ${pending.length} slip(s)'); final synced = []; for (final slip in pending) { try { await client.from('pass_slips').insert({ 'id': slip.id, 'user_id': slip.userId, 'duty_schedule_id': slip.dutyScheduleId, 'reason': slip.reason, 'status': slip.status, 'requested_at': slip.requestedAt.toIso8601String(), if (slip.requestedStart != null) 'requested_start': slip.requestedStart!.toIso8601String(), }); synced.add(slip.id); } catch (e) { final msg = e.toString(); if (msg.contains('23505') || msg.contains('duplicate key')) { synced.add(slip.id); } else { debugPrint('[passSlipsProvider] failed to sync slip id=${slip.id}: $e'); } } } if (synced.isNotEmpty) { final remaining = ref.read(offlinePendingPassSlipsProvider) .where((s) => !synced.contains(s.id)) .toList(); ref.read(offlinePendingPassSlipsProvider.notifier).state = List.unmodifiable(remaining); } } final pendingMap = Map>.from( ref.read(offlinePendingPassSlipUpdatesProvider), ); if (pendingMap.isNotEmpty) { debugPrint( '[passSlipsProvider] reconnected — syncing ${pendingMap.length} status edit(s)', ); final syncedIds = []; for (final entry in pendingMap.entries) { try { await client .from('pass_slips') .update(entry.value) .eq('id', entry.key); syncedIds.add(entry.key); } catch (e) { debugPrint('[passSlipsProvider] failed to sync edit id=${entry.key}: $e'); } } if (syncedIds.isNotEmpty) { final remaining = Map>.from( ref.read(offlinePendingPassSlipUpdatesProvider), ); for (final id in syncedIds) { remaining.remove(id); } ref.read(offlinePendingPassSlipUpdatesProvider.notifier).state = remaining; } } }); return wrapper.stream.map((result) => applyPending(result.data)); }); /// Currently active pass slip for the logged-in user (approved, not completed). final activePassSlipProvider = Provider((ref) { final slips = ref.watch(passSlipsProvider).valueOrNull ?? []; final userId = ref.watch(currentUserIdProvider); if (userId == null) return null; try { return slips.firstWhere((s) => s.userId == userId && s.isActive); } catch (_) { return null; } }); /// Active pass slips for all users (for dashboard IT Staff Pulse). final activePassSlipsProvider = Provider>((ref) { final slips = ref.watch(passSlipsProvider).valueOrNull ?? []; return slips.where((s) => s.isActive).toList(); }); // --------------------------------------------------------------------------- // Controller // --------------------------------------------------------------------------- final passSlipControllerProvider = Provider((ref) { final client = ref.watch(supabaseClientProvider); return PassSlipController(client, ref); }); class PassSlipController { PassSlipController(this._client, [this._ref]); final SupabaseClient _client; final Ref? _ref; void _queuePendingSlip(PassSlip slip) { final ref = _ref; if (ref == null) return; final current = List.from( ref.read(offlinePendingPassSlipsProvider), ); current.add(slip); ref.read(offlinePendingPassSlipsProvider.notifier).state = List.unmodifiable(current); mirrorBatchToBrick([slip], tag: 'offline_pass_slip'); } void _queueSlipStatusUpdate(String slipId, Map fields) { final ref = _ref; if (ref == null) return; final current = Map>.from( ref.read(offlinePendingPassSlipUpdatesProvider), ); current[slipId] = {...?current[slipId], ...fields}; ref.read(offlinePendingPassSlipUpdatesProvider.notifier).state = current; } Future requestSlip({ required String dutyScheduleId, required String reason, DateTime? requestedStart, }) async { final userId = _client.auth.currentUser?.id; if (userId == null) throw Exception('Not authenticated'); final id = const Uuid().v4(); final now = DateTime.now().toUtc(); final payload = { 'id': id, 'user_id': userId, 'duty_schedule_id': dutyScheduleId, 'reason': reason, 'status': 'pending', 'requested_at': now.toIso8601String(), if (requestedStart != null) 'requested_start': requestedStart.toUtc().toIso8601String(), }; Map? inserted; try { final insertedRaw = await _client .from('pass_slips') .insert(payload) .select() .maybeSingle(); inserted = insertedRaw is Map ? insertedRaw : null; } catch (e) { if (!isOfflineSaveError(e)) rethrow; _queuePendingSlip(PassSlip( id: id, userId: userId, dutyScheduleId: dutyScheduleId, reason: reason, status: 'pending', requestedAt: AppTime.toAppTime(now), requestedStart: requestedStart, )); return; } // Notify admins for approval (best-effort) try { final adminIds = await _fetchRoleUserIds( roles: const ['admin'], excludeUserId: userId, ); if (adminIds.isEmpty) return; String actorName = 'Someone'; try { final p = await _client .from('profiles') .select('full_name,display_name,name') .eq('id', userId) .maybeSingle(); if (p != null) { if (p['full_name'] != null) { actorName = p['full_name'].toString(); } else if (p['display_name'] != null) { actorName = p['display_name'].toString(); } else if (p['name'] != null) { actorName = p['name'].toString(); } } } catch (_) {} final slipId = (inserted ?? {})['id']?.toString() ?? id; final title = 'Pass Slip Filed for Approval'; final body = '$actorName filed a pass slip that requires approval.'; final notificationId = (inserted ?? {})['id']?.toString(); final dataPayload = { 'type': 'pass_slip_filed', 'pass_slip_id': slipId, ...?(notificationId != null ? {'notification_id': notificationId} : null), }; await _client.from('notifications').insert( adminIds .map((adminId) => { 'user_id': adminId, 'actor_id': userId, 'type': 'pass_slip_filed', 'pass_slip_id': slipId, }) .toList(), ); final res = await _client.functions.invoke( 'send_fcm', body: { 'user_ids': adminIds, 'title': title, 'body': body, 'data': dataPayload, }, ); debugPrint('pass slip send_fcm result: $res'); } catch (e) { debugPrint('pass slip send_fcm error: $e'); } } Future approveSlip(String slipId) async { final userId = _client.auth.currentUser?.id; if (userId == null) throw Exception('Not authenticated'); final nowUtc = DateTime.now().toUtc(); // Try to determine slip start time online; if offline use now. String slipStartIso = nowUtc.toIso8601String(); try { final row = await _client .from('pass_slips') .select('requested_start') .eq('id', slipId) .maybeSingle(); if (row != null && row['requested_start'] != null) { final requestedStart = DateTime.parse(row['requested_start'] as String); if (requestedStart.isAfter(nowUtc)) { slipStartIso = requestedStart.toIso8601String(); } } } catch (_) { // Stick with nowUtc. } final fields = { 'status': 'approved', 'approved_by': userId, 'approved_at': nowUtc.toIso8601String(), 'slip_start': slipStartIso, }; try { await _client.from('pass_slips').update(fields).eq('id', slipId); } catch (e) { if (!isOfflineSaveError(e)) rethrow; _queueSlipStatusUpdate(slipId, fields); return; } try { await _notifyRequester(slipId: slipId, actorId: userId, approved: true); } catch (_) {} } Future rejectSlip(String slipId) async { final userId = _client.auth.currentUser?.id; if (userId == null) throw Exception('Not authenticated'); final fields = { 'status': 'rejected', 'approved_by': userId, 'approved_at': DateTime.now().toUtc().toIso8601String(), }; try { await _client.from('pass_slips').update(fields).eq('id', slipId); } catch (e) { if (!isOfflineSaveError(e)) rethrow; _queueSlipStatusUpdate(slipId, fields); return; } try { await _notifyRequester(slipId: slipId, actorId: userId, approved: false); } catch (_) {} } Future _notifyRequester({ required String slipId, required String actorId, required bool approved, }) async { try { final row = await _client .from('pass_slips') .select('user_id') .eq('id', slipId) .maybeSingle(); // ignore: unnecessary_cast final rowMap = row as Map?; final userId = rowMap?['user_id']?.toString(); if (userId == null || userId.isEmpty) return; String actorName = 'Someone'; try { final p = await _client .from('profiles') .select('full_name,display_name,name') .eq('id', actorId) .maybeSingle(); if (p != null) { if (p['full_name'] != null) { actorName = p['full_name'].toString(); } else if (p['display_name'] != null) { actorName = p['display_name'].toString(); } else if (p['name'] != null) { actorName = p['name'].toString(); } } } catch (_) {} final title = approved ? 'Pass Slip Approved' : 'Pass Slip Rejected'; final body = approved ? '$actorName approved your pass slip.' : '$actorName rejected your pass slip.'; final dataPayload = { 'type': approved ? 'pass_slip_approved' : 'pass_slip_rejected', 'pass_slip_id': slipId, }; await _client.from('notifications').insert({ 'user_id': userId, 'actor_id': actorId, 'type': approved ? 'pass_slip_approved' : 'pass_slip_rejected', 'pass_slip_id': slipId, }); await _client.functions.invoke( 'send_fcm', body: { 'user_ids': [userId], 'title': title, 'body': body, 'data': dataPayload, }, ); } catch (_) { // non-fatal } } Future completeSlip(String slipId) async { final fields = { 'status': 'completed', 'slip_end': DateTime.now().toUtc().toIso8601String(), }; try { await _client.from('pass_slips').update(fields).eq('id', slipId); } catch (e) { if (!isOfflineSaveError(e)) rethrow; _queueSlipStatusUpdate(slipId, fields); } } Future> _fetchRoleUserIds({ required List roles, required String? excludeUserId, }) async { try { final data = await _client .from('profiles') .select('id, role') .inFilter('role', roles); final rows = data as List; final ids = rows .map((row) => row['id'] as String?) .whereType() .where((id) => id.isNotEmpty && id != excludeUserId) .toList(); return ids; } catch (_) { return []; } } }