Offline Support
This commit is contained in:
@@ -1,20 +1,42 @@
|
||||
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<List<LeaveOfAbsence>>((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<Map<String, Map<String, dynamic>>>((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. This prevents rejected or
|
||||
/// pending applications from inadvertently influencing schedules or status
|
||||
/// computations.
|
||||
/// verify the leave overlaps the current time.
|
||||
final leavesProvider = StreamProvider<List<LeaveOfAbsence>>((ref) {
|
||||
final client = ref.watch(supabaseClientProvider);
|
||||
final profileAsync = ref.watch(currentProfileProvider);
|
||||
@@ -27,6 +49,40 @@ final leavesProvider = StreamProvider<List<LeaveOfAbsence>>((ref) {
|
||||
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<LeaveOfAbsence> applyPending(List<LeaveOfAbsence> 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<LeaveOfAbsence>(
|
||||
stream: hasFullAccess
|
||||
? client
|
||||
@@ -50,24 +106,113 @@ final leavesProvider = StreamProvider<List<LeaveOfAbsence>>((ref) {
|
||||
fromMap: LeaveOfAbsence.fromMap,
|
||||
channelName: 'leave_of_absence',
|
||||
onStatusChanged: ref.read(realtimeControllerProvider).handleChannelStatus,
|
||||
onOfflineData: () async {
|
||||
final all = await cachedListFromBrick<LeaveOfAbsence>();
|
||||
final filtered = hasFullAccess
|
||||
? all
|
||||
: all.where((l) => l.userId == profile.id).toList();
|
||||
return applyPending(filtered);
|
||||
},
|
||||
onCacheMirror: (rows) =>
|
||||
mirrorBatchToBrick<LeaveOfAbsence>(rows, tag: 'leave_of_absence'),
|
||||
);
|
||||
|
||||
ref.onDispose(wrapper.dispose);
|
||||
return wrapper.stream.map((result) => result.data);
|
||||
|
||||
// Reconnect-replay: directly POST queued items, bypassing Brick's queue
|
||||
// which can block on stale entries (504, deadlock, etc.).
|
||||
ref.listen<bool>(isOnlineProvider, (wasOnline, isNowOnline) async {
|
||||
if (wasOnline == true || !isNowOnline) return;
|
||||
|
||||
// 1. Replay offline-created leaves
|
||||
final pending = List<LeaveOfAbsence>.from(ref.read(offlinePendingLeavesProvider));
|
||||
if (pending.isNotEmpty) {
|
||||
debugPrint('[leavesProvider] reconnected — syncing ${pending.length} leave(s)');
|
||||
final synced = <String>[];
|
||||
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<String, Map<String, dynamic>>.from(
|
||||
ref.read(offlinePendingLeaveUpdatesProvider),
|
||||
);
|
||||
if (pendingMap.isNotEmpty) {
|
||||
debugPrint(
|
||||
'[leavesProvider] reconnected — syncing ${pendingMap.length} status edit(s)',
|
||||
);
|
||||
final syncedIds = <String>[];
|
||||
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<String, Map<String, dynamic>>.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<LeaveController>((ref) {
|
||||
final client = ref.watch(supabaseClientProvider);
|
||||
return LeaveController(client);
|
||||
return LeaveController(client, ref);
|
||||
});
|
||||
|
||||
class LeaveController {
|
||||
LeaveController(this._client);
|
||||
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<void> fileLeave({
|
||||
required String leaveType,
|
||||
required String justification,
|
||||
@@ -76,43 +221,90 @@ class LeaveController {
|
||||
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': autoApprove ? 'approved' : 'pending',
|
||||
'status': status,
|
||||
'filed_by': uid,
|
||||
};
|
||||
|
||||
final insertedRaw = await _client
|
||||
.from('leave_of_absence')
|
||||
.insert(payload)
|
||||
.select()
|
||||
.maybeSingle();
|
||||
final Map<String, dynamic>? inserted = insertedRaw is Map<String, dynamic>
|
||||
? insertedRaw
|
||||
: null;
|
||||
Map<String, dynamic>? inserted;
|
||||
try {
|
||||
final insertedRaw = await _client
|
||||
.from('leave_of_absence')
|
||||
.insert(payload)
|
||||
.select()
|
||||
.maybeSingle();
|
||||
inserted = insertedRaw is Map<String, dynamic> ? 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
|
||||
final status = payload['status'] as String;
|
||||
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<LeaveOfAbsence>.from(
|
||||
ref.read(offlinePendingLeavesProvider),
|
||||
);
|
||||
current.add(leave);
|
||||
ref.read(offlinePendingLeavesProvider.notifier).state =
|
||||
List.unmodifiable(current);
|
||||
mirrorBatchToBrick<LeaveOfAbsence>([leave], tag: 'offline_leave');
|
||||
}
|
||||
|
||||
void _queueLeaveStatusUpdate(String leaveId, Map<String, dynamic> fields) {
|
||||
final ref = _ref;
|
||||
if (ref == null) return;
|
||||
final current = Map<String, Map<String, dynamic>>.from(
|
||||
ref.read(offlinePendingLeaveUpdatesProvider),
|
||||
);
|
||||
current[leaveId] = {...?current[leaveId], ...fields};
|
||||
ref.read(offlinePendingLeaveUpdatesProvider.notifier).state = current;
|
||||
}
|
||||
|
||||
Future<void> _notifyAdminsOfFiling({
|
||||
required Map<String, dynamic>? inserted,
|
||||
required String leaveId,
|
||||
required String actorId,
|
||||
}) async {
|
||||
try {
|
||||
final adminIds = await _fetchRoleUserIds(
|
||||
roles: const ['admin'],
|
||||
excludeUserId: uid,
|
||||
excludeUserId: actorId,
|
||||
);
|
||||
if (adminIds.isEmpty) return;
|
||||
|
||||
// Resolve actor display name for nicer push text
|
||||
String actorName = 'Someone';
|
||||
try {
|
||||
final p = await _client
|
||||
.from('profiles')
|
||||
.select('full_name,display_name,name')
|
||||
.eq('id', uid)
|
||||
.eq('id', actorId)
|
||||
.maybeSingle();
|
||||
if (p != null) {
|
||||
if (p['full_name'] != null) {
|
||||
@@ -125,32 +317,24 @@ class LeaveController {
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
final leaveId = (inserted ?? <String, dynamic>{})['id']?.toString() ?? '';
|
||||
final title = 'Leave Filed for Approval';
|
||||
final body = '$actorName filed a leave request that requires approval.';
|
||||
final notificationId = (inserted ?? <String, dynamic>{})['id']
|
||||
?.toString();
|
||||
final notificationId = (inserted ?? <String, dynamic>{})['id']?.toString();
|
||||
|
||||
final dataPayload = <String, dynamic>{
|
||||
'type': 'leave_filed',
|
||||
'leave_id': leaveId,
|
||||
...?(notificationId != null
|
||||
? {'notification_id': notificationId}
|
||||
: null),
|
||||
...?(notificationId != null ? {'notification_id': notificationId} : null),
|
||||
};
|
||||
|
||||
await _client
|
||||
.from('notifications')
|
||||
.insert(
|
||||
await _client.from('notifications').insert(
|
||||
adminIds
|
||||
.map(
|
||||
(userId) => {
|
||||
'user_id': userId,
|
||||
'actor_id': uid,
|
||||
'type': 'leave_filed',
|
||||
'leave_id': leaveId,
|
||||
},
|
||||
)
|
||||
.map((userId) => {
|
||||
'user_id': userId,
|
||||
'actor_id': actorId,
|
||||
'type': 'leave_filed',
|
||||
'leave_id': leaveId,
|
||||
})
|
||||
.toList(),
|
||||
);
|
||||
|
||||
@@ -175,14 +359,21 @@ class LeaveController {
|
||||
final userId = _client.auth.currentUser?.id;
|
||||
if (userId == null) throw Exception('Not authenticated');
|
||||
|
||||
// Update status first; then notify the requester.
|
||||
await _client
|
||||
.from('leave_of_absence')
|
||||
.update({'status': 'approved'})
|
||||
.eq('id', leaveId);
|
||||
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
|
||||
await _notifyRequester(leaveId: leaveId, actorId: userId, approved: true);
|
||||
// Notify requestor (best-effort)
|
||||
try {
|
||||
await _notifyRequester(leaveId: leaveId, actorId: userId, approved: true);
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
/// Reject a leave request.
|
||||
@@ -190,13 +381,20 @@ class LeaveController {
|
||||
final userId = _client.auth.currentUser?.id;
|
||||
if (userId == null) throw Exception('Not authenticated');
|
||||
|
||||
await _client
|
||||
.from('leave_of_absence')
|
||||
.update({'status': 'rejected'})
|
||||
.eq('id', leaveId);
|
||||
try {
|
||||
await _client
|
||||
.from('leave_of_absence')
|
||||
.update({'status': 'rejected'})
|
||||
.eq('id', leaveId);
|
||||
} catch (e) {
|
||||
if (!isOfflineSaveError(e)) rethrow;
|
||||
_queueLeaveStatusUpdate(leaveId, {'status': 'rejected'});
|
||||
return;
|
||||
}
|
||||
|
||||
// Notify requestor
|
||||
await _notifyRequester(leaveId: leaveId, actorId: userId, approved: false);
|
||||
try {
|
||||
await _notifyRequester(leaveId: leaveId, actorId: userId, approved: false);
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
Future<void> _notifyRequester({
|
||||
@@ -266,10 +464,15 @@ class LeaveController {
|
||||
|
||||
/// Cancel an approved leave.
|
||||
Future<void> cancelLeave(String leaveId) async {
|
||||
await _client
|
||||
.from('leave_of_absence')
|
||||
.update({'status': 'cancelled'})
|
||||
.eq('id', leaveId);
|
||||
try {
|
||||
await _client
|
||||
.from('leave_of_absence')
|
||||
.update({'status': 'cancelled'})
|
||||
.eq('id', leaveId);
|
||||
} catch (e) {
|
||||
if (!isOfflineSaveError(e)) rethrow;
|
||||
_queueLeaveStatusUpdate(leaveId, {'status': 'cancelled'});
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<String>> _fetchRoleUserIds({
|
||||
|
||||
Reference in New Issue
Block a user