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/leave_of_absence.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 // --------------------------------------------------------------------------- /// Leaves filed offline that haven't been confirmed by Supabase yet. Merged /// into [leavesProvider] so the UI shows them immediately. Cleared as the /// realtime stream confirms each id. final offlinePendingLeavesProvider = StateProvider>((ref) => const []); /// Status changes (approve/reject/cancel) made offline, keyed by leave id. /// On reconnect the listener PATCHes each entry and clears the map. final offlinePendingLeaveUpdatesProvider = StateProvider>>((ref) => const {}); // --------------------------------------------------------------------------- // Read provider // --------------------------------------------------------------------------- /// All visible leaves (own for standard, all for admin/dispatcher/it_staff). /// /// Consumers should **not** treat every record as an active absence; the UI /// layers (dashboard, logbook) explicitly filter to `status == 'approved'` and /// verify the leave overlaps the current time. final leavesProvider = StreamProvider>((ref) { final client = ref.watch(supabaseClientProvider); final profileAsync = ref.watch(currentProfileProvider); final profile = profileAsync.valueOrNull; if (profile == null) return Stream.value(const []); final hasFullAccess = profile.role == 'admin' || profile.role == 'programmer' || profile.role == 'dispatcher' || profile.role == 'it_staff'; // Watch pending state — provider rebuilds when offline writes arrive so the // closed-over `pending*` values feed into `onOfflineData` synchronously. final pendingNew = ref.watch(offlinePendingLeavesProvider); final pendingUpdates = ref.watch(offlinePendingLeaveUpdatesProvider); List applyPending(List rows) { final byId = {for (final r in rows) r.id: r}; // Merge offline status updates onto existing leaves pendingUpdates.forEach((id, fields) { final existing = byId[id]; if (existing == null) return; byId[id] = LeaveOfAbsence( id: existing.id, userId: existing.userId, leaveType: existing.leaveType, justification: existing.justification, startTime: existing.startTime, endTime: existing.endTime, status: (fields['status'] as String?) ?? existing.status, filedBy: existing.filedBy, createdAt: existing.createdAt, ); }); // Append offline-created leaves not yet on the server 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.startTime.compareTo(a.startTime)); return merged; } final wrapper = StreamRecoveryWrapper( stream: hasFullAccess ? client .from('leave_of_absence') .stream(primaryKey: ['id']) .order('start_time', ascending: false) : client .from('leave_of_absence') .stream(primaryKey: ['id']) .eq('user_id', profile.id) .order('start_time', ascending: false), onPollData: () async { final query = client.from('leave_of_absence').select(); final data = hasFullAccess ? await query.order('start_time', ascending: false) : await query .eq('user_id', profile.id) .order('start_time', ascending: false); return data.map(LeaveOfAbsence.fromMap).toList(); }, fromMap: LeaveOfAbsence.fromMap, channelName: 'leave_of_absence', onStatusChanged: ref.read(realtimeControllerProvider).handleChannelStatus, onOfflineData: () async { final all = await cachedListFromBrick(); final filtered = hasFullAccess ? all : all.where((l) => l.userId == profile.id).toList(); return applyPending(filtered); }, onCacheMirror: (rows) => mirrorBatchToBrick(rows, tag: 'leave_of_absence'), ); ref.onDispose(wrapper.dispose); // Reconnect-replay: directly POST queued items, bypassing Brick's queue // which can block on stale entries (504, deadlock, etc.). ref.listen(isOnlineProvider, (wasOnline, isNowOnline) async { if (wasOnline == true || !isNowOnline) return; // 1. Replay offline-created leaves final pending = List.from(ref.read(offlinePendingLeavesProvider)); if (pending.isNotEmpty) { debugPrint('[leavesProvider] reconnected — syncing ${pending.length} leave(s)'); final synced = []; for (final leave in pending) { try { await client.from('leave_of_absence').insert({ 'id': leave.id, 'user_id': leave.userId, 'leave_type': leave.leaveType, 'justification': leave.justification, 'start_time': leave.startTime.toIso8601String(), 'end_time': leave.endTime.toIso8601String(), 'status': leave.status, 'filed_by': leave.filedBy, }); synced.add(leave.id); } catch (e) { final msg = e.toString(); if (msg.contains('23505') || msg.contains('duplicate key')) { synced.add(leave.id); // already in DB — drop pending entry } else { debugPrint('[leavesProvider] failed to sync leave id=${leave.id}: $e'); } } } if (synced.isNotEmpty) { final remaining = ref.read(offlinePendingLeavesProvider) .where((l) => !synced.contains(l.id)) .toList(); ref.read(offlinePendingLeavesProvider.notifier).state = List.unmodifiable(remaining); } } // 2. Replay offline status updates final pendingMap = Map>.from( ref.read(offlinePendingLeaveUpdatesProvider), ); if (pendingMap.isNotEmpty) { debugPrint( '[leavesProvider] reconnected — syncing ${pendingMap.length} status edit(s)', ); final syncedIds = []; for (final entry in pendingMap.entries) { try { await client .from('leave_of_absence') .update(entry.value) .eq('id', entry.key); syncedIds.add(entry.key); } catch (e) { debugPrint('[leavesProvider] failed to sync edit id=${entry.key}: $e'); } } if (syncedIds.isNotEmpty) { final remaining = Map>.from( ref.read(offlinePendingLeaveUpdatesProvider), ); for (final id in syncedIds) { remaining.remove(id); } ref.read(offlinePendingLeaveUpdatesProvider.notifier).state = remaining; } } }); return wrapper.stream.map((result) => applyPending(result.data)); }); // --------------------------------------------------------------------------- // Controller // --------------------------------------------------------------------------- final leaveControllerProvider = Provider((ref) { final client = ref.watch(supabaseClientProvider); return LeaveController(client, ref); }); class LeaveController { LeaveController(this._client, [this._ref]); final SupabaseClient _client; final Ref? _ref; /// File a leave of absence for the current user. /// Caller controls auto-approval based on role policy. /// Offline: queues a local row + raw payload, returns silently. Future fileLeave({ required String leaveType, required String justification, required DateTime startTime, required DateTime endTime, required bool autoApprove, }) async { final uid = _client.auth.currentUser!.id; final id = const Uuid().v4(); final status = autoApprove ? 'approved' : 'pending'; final payload = { 'id': id, 'user_id': uid, 'leave_type': leaveType, 'justification': justification, 'start_time': startTime.toIso8601String(), 'end_time': endTime.toIso8601String(), 'status': status, 'filed_by': uid, }; Map? inserted; try { final insertedRaw = await _client .from('leave_of_absence') .insert(payload) .select() .maybeSingle(); inserted = insertedRaw is Map ? insertedRaw : null; } catch (e) { if (!isOfflineSaveError(e)) rethrow; _queuePendingLeave( LeaveOfAbsence( id: id, userId: uid, leaveType: leaveType, justification: justification, startTime: startTime, endTime: endTime, status: status, filedBy: uid, createdAt: AppTime.now(), ), ); return; // Don't try to notify admins — we're offline. } // If this was filed as pending, notify admins for approval if (status != 'pending') return; await _notifyAdminsOfFiling(inserted: inserted, leaveId: id, actorId: uid); } void _queuePendingLeave(LeaveOfAbsence leave) { final ref = _ref; if (ref == null) return; final current = List.from( ref.read(offlinePendingLeavesProvider), ); current.add(leave); ref.read(offlinePendingLeavesProvider.notifier).state = List.unmodifiable(current); mirrorBatchToBrick([leave], tag: 'offline_leave'); } void _queueLeaveStatusUpdate(String leaveId, Map fields) { final ref = _ref; if (ref == null) return; final current = Map>.from( ref.read(offlinePendingLeaveUpdatesProvider), ); current[leaveId] = {...?current[leaveId], ...fields}; ref.read(offlinePendingLeaveUpdatesProvider.notifier).state = current; } Future _notifyAdminsOfFiling({ required Map? inserted, required String leaveId, required String actorId, }) async { try { final adminIds = await _fetchRoleUserIds( roles: const ['admin'], excludeUserId: actorId, ); if (adminIds.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 = 'Leave Filed for Approval'; final body = '$actorName filed a leave request that requires approval.'; final notificationId = (inserted ?? {})['id']?.toString(); final dataPayload = { 'type': 'leave_filed', 'leave_id': leaveId, ...?(notificationId != null ? {'notification_id': notificationId} : null), }; await _client.from('notifications').insert( adminIds .map((userId) => { 'user_id': userId, 'actor_id': actorId, 'type': 'leave_filed', 'leave_id': leaveId, }) .toList(), ); final res = await _client.functions.invoke( 'send_fcm', body: { 'user_ids': adminIds, 'title': title, 'body': body, 'data': dataPayload, }, ); debugPrint('leave filing send_fcm result: $res'); } catch (e) { debugPrint('leave filing send_fcm error: $e'); // Non-fatal: keep leave filing working even if send_fcm fails } } /// Approve a leave request. Future approveLeave(String leaveId) async { final userId = _client.auth.currentUser?.id; if (userId == null) throw Exception('Not authenticated'); try { await _client .from('leave_of_absence') .update({'status': 'approved'}) .eq('id', leaveId); } catch (e) { if (!isOfflineSaveError(e)) rethrow; _queueLeaveStatusUpdate(leaveId, {'status': 'approved'}); return; } // Notify requestor (best-effort) try { await _notifyRequester(leaveId: leaveId, actorId: userId, approved: true); } catch (_) {} } /// Reject a leave request. Future rejectLeave(String leaveId) async { final userId = _client.auth.currentUser?.id; if (userId == null) throw Exception('Not authenticated'); try { await _client .from('leave_of_absence') .update({'status': 'rejected'}) .eq('id', leaveId); } catch (e) { if (!isOfflineSaveError(e)) rethrow; _queueLeaveStatusUpdate(leaveId, {'status': 'rejected'}); return; } try { await _notifyRequester(leaveId: leaveId, actorId: userId, approved: false); } catch (_) {} } Future _notifyRequester({ required String leaveId, required String actorId, required bool approved, }) async { try { final row = await _client .from('leave_of_absence') .select('user_id') .eq('id', leaveId) .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 ? 'Leave Approved' : 'Leave Rejected'; final body = approved ? '$actorName approved your leave request.' : '$actorName rejected your leave request.'; final dataPayload = { 'type': approved ? 'leave_approved' : 'leave_rejected', 'leave_id': leaveId, }; await _client.from('notifications').insert({ 'user_id': userId, 'actor_id': actorId, 'type': approved ? 'leave_approved' : 'leave_rejected', 'leave_id': leaveId, }); await _client.functions.invoke( 'send_fcm', body: { 'user_ids': [userId], 'title': title, 'body': body, 'data': dataPayload, }, ); } catch (_) { // non-fatal } } /// Cancel an approved leave. Future cancelLeave(String leaveId) async { try { await _client .from('leave_of_absence') .update({'status': 'cancelled'}) .eq('id', leaveId); } catch (e) { if (!isOfflineSaveError(e)) rethrow; _queueLeaveStatusUpdate(leaveId, {'status': 'cancelled'}); } } 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 []; } } }