Offline Support
This commit is contained in:
@@ -1,13 +1,34 @@
|
||||
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<List<PassSlip>>((ref) => const []);
|
||||
|
||||
/// Status edits (approve/reject/complete) made offline, keyed by slip id.
|
||||
final offlinePendingPassSlipUpdatesProvider =
|
||||
StateProvider<Map<String, Map<String, dynamic>>>((ref) => const {});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Read provider
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// All visible pass slips (own for staff, all for admin/dispatcher).
|
||||
final passSlipsProvider = StreamProvider<List<PassSlip>>((ref) {
|
||||
final client = ref.watch(supabaseClientProvider);
|
||||
@@ -18,6 +39,50 @@ final passSlipsProvider = StreamProvider<List<PassSlip>>((ref) {
|
||||
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<PassSlip> applyPending(List<PassSlip> 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<PassSlip>(
|
||||
stream: hasFullAccess
|
||||
? client
|
||||
@@ -41,10 +106,90 @@ final passSlipsProvider = StreamProvider<List<PassSlip>>((ref) {
|
||||
fromMap: PassSlip.fromMap,
|
||||
channelName: 'pass_slips',
|
||||
onStatusChanged: ref.read(realtimeControllerProvider).handleChannelStatus,
|
||||
onOfflineData: () async {
|
||||
final all = await cachedListFromBrick<PassSlip>();
|
||||
final filtered = hasFullAccess
|
||||
? all
|
||||
: all.where((s) => s.userId == profile.id).toList();
|
||||
return applyPending(filtered);
|
||||
},
|
||||
onCacheMirror: (rows) =>
|
||||
mirrorBatchToBrick<PassSlip>(rows, tag: 'pass_slips'),
|
||||
);
|
||||
|
||||
ref.onDispose(wrapper.dispose);
|
||||
return wrapper.stream.map((result) => result.data);
|
||||
|
||||
// Reconnect-replay
|
||||
ref.listen<bool>(isOnlineProvider, (wasOnline, isNowOnline) async {
|
||||
if (wasOnline == true || !isNowOnline) return;
|
||||
|
||||
final pending = List<PassSlip>.from(ref.read(offlinePendingPassSlipsProvider));
|
||||
if (pending.isNotEmpty) {
|
||||
debugPrint('[passSlipsProvider] reconnected — syncing ${pending.length} slip(s)');
|
||||
final synced = <String>[];
|
||||
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<String, Map<String, dynamic>>.from(
|
||||
ref.read(offlinePendingPassSlipUpdatesProvider),
|
||||
);
|
||||
if (pendingMap.isNotEmpty) {
|
||||
debugPrint(
|
||||
'[passSlipsProvider] reconnected — syncing ${pendingMap.length} status edit(s)',
|
||||
);
|
||||
final syncedIds = <String>[];
|
||||
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<String, Map<String, dynamic>>.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).
|
||||
@@ -65,15 +210,42 @@ final activePassSlipsProvider = Provider<List<PassSlip>>((ref) {
|
||||
return slips.where((s) => s.isActive).toList();
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Controller
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
final passSlipControllerProvider = Provider<PassSlipController>((ref) {
|
||||
final client = ref.watch(supabaseClientProvider);
|
||||
return PassSlipController(client);
|
||||
return PassSlipController(client, ref);
|
||||
});
|
||||
|
||||
class PassSlipController {
|
||||
PassSlipController(this._client);
|
||||
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<PassSlip>.from(
|
||||
ref.read(offlinePendingPassSlipsProvider),
|
||||
);
|
||||
current.add(slip);
|
||||
ref.read(offlinePendingPassSlipsProvider.notifier).state =
|
||||
List.unmodifiable(current);
|
||||
mirrorBatchToBrick<PassSlip>([slip], tag: 'offline_pass_slip');
|
||||
}
|
||||
|
||||
void _queueSlipStatusUpdate(String slipId, Map<String, dynamic> fields) {
|
||||
final ref = _ref;
|
||||
if (ref == null) return;
|
||||
final current = Map<String, Map<String, dynamic>>.from(
|
||||
ref.read(offlinePendingPassSlipUpdatesProvider),
|
||||
);
|
||||
current[slipId] = {...?current[slipId], ...fields};
|
||||
ref.read(offlinePendingPassSlipUpdatesProvider.notifier).state = current;
|
||||
}
|
||||
|
||||
Future<void> requestSlip({
|
||||
required String dutyScheduleId,
|
||||
@@ -82,26 +254,42 @@ class PassSlipController {
|
||||
}) 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': DateTime.now().toUtc().toIso8601String(),
|
||||
'requested_at': now.toIso8601String(),
|
||||
if (requestedStart != null)
|
||||
'requested_start': requestedStart.toUtc().toIso8601String(),
|
||||
};
|
||||
|
||||
final insertedRaw = await _client
|
||||
.from('pass_slips')
|
||||
.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('pass_slips')
|
||||
.insert(payload)
|
||||
.select()
|
||||
.maybeSingle();
|
||||
inserted = insertedRaw is Map<String, dynamic> ? 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
|
||||
// Notify admins for approval (best-effort)
|
||||
try {
|
||||
final adminIds = await _fetchRoleUserIds(
|
||||
roles: const ['admin'],
|
||||
@@ -109,7 +297,6 @@ class PassSlipController {
|
||||
);
|
||||
if (adminIds.isEmpty) return;
|
||||
|
||||
// Resolve actor display name for nice push text
|
||||
String actorName = 'Someone';
|
||||
try {
|
||||
final p = await _client
|
||||
@@ -128,32 +315,25 @@ class PassSlipController {
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
final slipId = (inserted ?? <String, dynamic>{})['id']?.toString() ?? '';
|
||||
final slipId = (inserted ?? <String, dynamic>{})['id']?.toString() ?? id;
|
||||
final title = 'Pass Slip Filed for Approval';
|
||||
final body = '$actorName filed a pass slip that requires approval.';
|
||||
final notificationId = (inserted ?? <String, dynamic>{})['id']
|
||||
?.toString();
|
||||
final notificationId = (inserted ?? <String, dynamic>{})['id']?.toString();
|
||||
|
||||
final dataPayload = <String, dynamic>{
|
||||
'type': 'pass_slip_filed',
|
||||
'pass_slip_id': slipId,
|
||||
...?(notificationId != null
|
||||
? {'notification_id': notificationId}
|
||||
: null),
|
||||
...?(notificationId != null ? {'notification_id': notificationId} : null),
|
||||
};
|
||||
|
||||
await _client
|
||||
.from('notifications')
|
||||
.insert(
|
||||
await _client.from('notifications').insert(
|
||||
adminIds
|
||||
.map(
|
||||
(adminId) => {
|
||||
'user_id': adminId,
|
||||
'actor_id': userId,
|
||||
'type': 'pass_slip_filed',
|
||||
'pass_slip_id': slipId,
|
||||
},
|
||||
)
|
||||
.map((adminId) => {
|
||||
'user_id': adminId,
|
||||
'actor_id': userId,
|
||||
'type': 'pass_slip_filed',
|
||||
'pass_slip_id': slipId,
|
||||
})
|
||||
.toList(),
|
||||
);
|
||||
|
||||
@@ -169,7 +349,6 @@ class PassSlipController {
|
||||
debugPrint('pass slip send_fcm result: $res');
|
||||
} catch (e) {
|
||||
debugPrint('pass slip send_fcm error: $e');
|
||||
// Non-fatal: keep slip request working even if send_fcm fails
|
||||
}
|
||||
}
|
||||
|
||||
@@ -177,49 +356,67 @@ class PassSlipController {
|
||||
final userId = _client.auth.currentUser?.id;
|
||||
if (userId == null) throw Exception('Not authenticated');
|
||||
|
||||
// Determine slip start time based on requested_start
|
||||
final nowUtc = DateTime.now().toUtc();
|
||||
String slipStartIso = nowUtc.toIso8601String();
|
||||
|
||||
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();
|
||||
// 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.
|
||||
}
|
||||
|
||||
await _client
|
||||
.from('pass_slips')
|
||||
.update({
|
||||
'status': 'approved',
|
||||
'approved_by': userId,
|
||||
'approved_at': nowUtc.toIso8601String(),
|
||||
'slip_start': slipStartIso,
|
||||
})
|
||||
.eq('id', slipId);
|
||||
final fields = {
|
||||
'status': 'approved',
|
||||
'approved_by': userId,
|
||||
'approved_at': nowUtc.toIso8601String(),
|
||||
'slip_start': slipStartIso,
|
||||
};
|
||||
|
||||
await _notifyRequester(slipId: slipId, actorId: userId, approved: true);
|
||||
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<void> rejectSlip(String slipId) async {
|
||||
final userId = _client.auth.currentUser?.id;
|
||||
if (userId == null) throw Exception('Not authenticated');
|
||||
|
||||
await _client
|
||||
.from('pass_slips')
|
||||
.update({
|
||||
'status': 'rejected',
|
||||
'approved_by': userId,
|
||||
'approved_at': DateTime.now().toUtc().toIso8601String(),
|
||||
})
|
||||
.eq('id', slipId);
|
||||
final fields = {
|
||||
'status': 'rejected',
|
||||
'approved_by': userId,
|
||||
'approved_at': DateTime.now().toUtc().toIso8601String(),
|
||||
};
|
||||
|
||||
await _notifyRequester(slipId: slipId, actorId: userId, approved: false);
|
||||
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<void> _notifyRequester({
|
||||
@@ -288,13 +485,16 @@ class PassSlipController {
|
||||
}
|
||||
|
||||
Future<void> completeSlip(String slipId) async {
|
||||
await _client
|
||||
.from('pass_slips')
|
||||
.update({
|
||||
'status': 'completed',
|
||||
'slip_end': DateTime.now().toUtc().toIso8601String(),
|
||||
})
|
||||
.eq('id', slipId);
|
||||
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<List<String>> _fetchRoleUserIds({
|
||||
|
||||
Reference in New Issue
Block a user