Offline Support

This commit is contained in:
2026-04-27 06:20:39 +08:00
parent 7d8851a94a
commit 48cd3bcd60
121 changed files with 14798 additions and 2214 deletions
+269 -69
View File
@@ -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({