Offline Support
This commit is contained in:
@@ -1,17 +1,22 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter/foundation.dart' show debugPrint;
|
||||
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/attendance_log.dart';
|
||||
import '../utils/app_time.dart';
|
||||
import '../utils/snackbar.dart' show isOfflineSaveError;
|
||||
import 'connectivity_provider.dart';
|
||||
import 'profile_provider.dart';
|
||||
import 'reports_provider.dart';
|
||||
import 'supabase_provider.dart';
|
||||
import 'stream_recovery.dart';
|
||||
import 'realtime_controller.dart';
|
||||
|
||||
/// Date range for attendance logbook, defaults to "Last 7 Days".
|
||||
/// Date range for attendance logbook, defaults to "Today".
|
||||
final attendanceDateRangeProvider = StateProvider<ReportDateRange>((ref) {
|
||||
final now = AppTime.now();
|
||||
final today = DateTime(now.year, now.month, now.day);
|
||||
@@ -25,12 +30,32 @@ final attendanceDateRangeProvider = StateProvider<ReportDateRange>((ref) {
|
||||
/// Filter for logbook users (multi-select). If empty, all users are shown.
|
||||
final attendanceUserFilterProvider = StateProvider<List<String>>((ref) => []);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Offline-pending state
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Check-ins created offline (typed model — merged into live stream).
|
||||
final offlinePendingCheckInsProvider =
|
||||
StateProvider<List<AttendanceLog>>((ref) => const []);
|
||||
|
||||
/// Raw RPC params for offline check-ins — used for reconnect replay.
|
||||
/// Each entry: {p_duty_id, p_lat, p_lng, localId}
|
||||
final offlinePendingCheckInRawProvider =
|
||||
StateProvider<List<Map<String, dynamic>>>((ref) => const []);
|
||||
|
||||
/// Attendance field updates made offline, keyed by attendance log id.
|
||||
/// Covers: checkouts, verification status updates, skipVerification.
|
||||
final offlinePendingAttendanceUpdatesProvider =
|
||||
StateProvider<Map<String, Map<String, dynamic>>>((ref) => const {});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Read provider
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// All visible attendance logs (own for standard, all for admin/dispatcher/it_staff).
|
||||
final attendanceLogsProvider = StreamProvider<List<AttendanceLog>>((ref) {
|
||||
final client = ref.watch(supabaseClientProvider);
|
||||
|
||||
// Use .select() so the stream is only recreated when the user id or role
|
||||
// actually changes (not on avatar/name edits, etc.).
|
||||
final profileId = ref.watch(
|
||||
currentProfileProvider.select((p) => p.valueOrNull?.id),
|
||||
);
|
||||
@@ -45,6 +70,56 @@ final attendanceLogsProvider = StreamProvider<List<AttendanceLog>>((ref) {
|
||||
profileRole == 'dispatcher' ||
|
||||
profileRole == 'it_staff';
|
||||
|
||||
final pendingCheckIns = ref.watch(offlinePendingCheckInsProvider);
|
||||
final pendingUpdates = ref.watch(offlinePendingAttendanceUpdatesProvider);
|
||||
|
||||
List<AttendanceLog> applyPending(List<AttendanceLog> rows) {
|
||||
final byId = {for (final r in rows) r.id: r};
|
||||
|
||||
// Apply pending checkouts and other field updates
|
||||
pendingUpdates.forEach((id, fields) {
|
||||
final existing = byId[id];
|
||||
if (existing == null) return;
|
||||
byId[id] = AttendanceLog(
|
||||
id: existing.id,
|
||||
userId: existing.userId,
|
||||
dutyScheduleId: existing.dutyScheduleId,
|
||||
shiftType: existing.shiftType,
|
||||
checkInAt: existing.checkInAt,
|
||||
checkInLat: existing.checkInLat,
|
||||
checkInLng: existing.checkInLng,
|
||||
checkOutAt: fields.containsKey('check_out_at')
|
||||
? (fields['check_out_at'] != null
|
||||
? AppTime.parse(fields['check_out_at'] as String)
|
||||
: null)
|
||||
: existing.checkOutAt,
|
||||
checkOutLat: (fields['check_out_lat'] as num?)?.toDouble() ??
|
||||
existing.checkOutLat,
|
||||
checkOutLng: (fields['check_out_lng'] as num?)?.toDouble() ??
|
||||
existing.checkOutLng,
|
||||
justification: existing.justification,
|
||||
checkOutJustification:
|
||||
(fields['check_out_justification'] as String?) ??
|
||||
existing.checkOutJustification,
|
||||
verificationStatus:
|
||||
(fields['verification_status'] as String?) ??
|
||||
existing.verificationStatus,
|
||||
checkInVerificationPhotoUrl: existing.checkInVerificationPhotoUrl,
|
||||
checkOutVerificationPhotoUrl: existing.checkOutVerificationPhotoUrl,
|
||||
);
|
||||
});
|
||||
|
||||
// Append offline check-ins not yet on server
|
||||
for (final p in pendingCheckIns) {
|
||||
if (hasFullAccess || p.userId == profileId) {
|
||||
byId.putIfAbsent(p.id, () => p);
|
||||
}
|
||||
}
|
||||
|
||||
return byId.values.toList()
|
||||
..sort((a, b) => b.checkInAt.compareTo(a.checkInAt));
|
||||
}
|
||||
|
||||
final wrapper = StreamRecoveryWrapper<AttendanceLog>(
|
||||
stream: hasFullAccess
|
||||
? client
|
||||
@@ -68,55 +143,225 @@ final attendanceLogsProvider = StreamProvider<List<AttendanceLog>>((ref) {
|
||||
fromMap: AttendanceLog.fromMap,
|
||||
channelName: 'attendance_logs',
|
||||
onStatusChanged: ref.read(realtimeControllerProvider).handleChannelStatus,
|
||||
onOfflineData: () async {
|
||||
final all = await cachedListFromBrick<AttendanceLog>();
|
||||
final filtered = hasFullAccess
|
||||
? all
|
||||
: all.where((log) => log.userId == profileId).toList();
|
||||
filtered.sort((a, b) => b.checkInAt.compareTo(a.checkInAt));
|
||||
return applyPending(filtered);
|
||||
},
|
||||
onCacheMirror: (rows) =>
|
||||
mirrorBatchToBrick<AttendanceLog>(rows, tag: 'attendance_logs'),
|
||||
);
|
||||
|
||||
ref.onDispose(wrapper.dispose);
|
||||
return wrapper.stream.map((result) => result.data);
|
||||
|
||||
// Reconnect-replay: POST/PATCH queued items directly, bypassing Brick's queue.
|
||||
ref.listen<bool>(isOnlineProvider, (wasOnline, isNowOnline) async {
|
||||
if (wasOnline == true || !isNowOnline) return;
|
||||
|
||||
// 1. Replay offline check-ins
|
||||
final pendingRaw = List<Map<String, dynamic>>.from(
|
||||
ref.read(offlinePendingCheckInRawProvider),
|
||||
);
|
||||
if (pendingRaw.isNotEmpty) {
|
||||
debugPrint(
|
||||
'[attendanceLogsProvider] reconnected — syncing ${pendingRaw.length} check-in(s)',
|
||||
);
|
||||
final syncedLocalIds = <String>[];
|
||||
for (final params in pendingRaw) {
|
||||
final localId = params['localId'] as String;
|
||||
try {
|
||||
await client.rpc(
|
||||
'attendance_check_in',
|
||||
params: {
|
||||
'p_duty_id': params['p_duty_id'],
|
||||
'p_lat': params['p_lat'],
|
||||
'p_lng': params['p_lng'],
|
||||
},
|
||||
);
|
||||
syncedLocalIds.add(localId);
|
||||
} catch (e) {
|
||||
final msg = e.toString();
|
||||
if (msg.contains('23505') || msg.contains('duplicate key')) {
|
||||
syncedLocalIds.add(localId);
|
||||
} else {
|
||||
debugPrint(
|
||||
'[attendanceLogsProvider] failed to sync check-in localId=$localId: $e',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (syncedLocalIds.isNotEmpty) {
|
||||
final remainingRaw = ref
|
||||
.read(offlinePendingCheckInRawProvider)
|
||||
.where((p) => !syncedLocalIds.contains(p['localId'] as String))
|
||||
.toList();
|
||||
ref.read(offlinePendingCheckInRawProvider.notifier).state =
|
||||
List.unmodifiable(remainingRaw);
|
||||
|
||||
final remainingModels = ref
|
||||
.read(offlinePendingCheckInsProvider)
|
||||
.where((l) => !syncedLocalIds.contains(l.id))
|
||||
.toList();
|
||||
ref.read(offlinePendingCheckInsProvider.notifier).state =
|
||||
List.unmodifiable(remainingModels);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Replay pending checkouts and field updates (direct PATCH)
|
||||
final pendingUpdatesMap = Map<String, Map<String, dynamic>>.from(
|
||||
ref.read(offlinePendingAttendanceUpdatesProvider),
|
||||
);
|
||||
if (pendingUpdatesMap.isNotEmpty) {
|
||||
debugPrint(
|
||||
'[attendanceLogsProvider] reconnected — syncing ${pendingUpdatesMap.length} attendance update(s)',
|
||||
);
|
||||
final syncedIds = <String>[];
|
||||
for (final entry in pendingUpdatesMap.entries) {
|
||||
final attendanceId = entry.key;
|
||||
final fields = Map<String, dynamic>.from(entry.value);
|
||||
|
||||
// If this was a checkout via RPC, call the RPC instead of a raw PATCH
|
||||
if (fields.containsKey('_rpc_checkout')) {
|
||||
fields.remove('_rpc_checkout');
|
||||
try {
|
||||
await client.rpc(
|
||||
'attendance_check_out',
|
||||
params: {
|
||||
'p_attendance_id': attendanceId,
|
||||
'p_lat': fields['check_out_lat'],
|
||||
'p_lng': fields['check_out_lng'],
|
||||
'p_justification': fields['check_out_justification'],
|
||||
},
|
||||
);
|
||||
syncedIds.add(attendanceId);
|
||||
} catch (e) {
|
||||
debugPrint(
|
||||
'[attendanceLogsProvider] failed to sync checkout id=$attendanceId: $e',
|
||||
);
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
await client
|
||||
.from('attendance_logs')
|
||||
.update(fields)
|
||||
.eq('id', attendanceId);
|
||||
syncedIds.add(attendanceId);
|
||||
} catch (e) {
|
||||
debugPrint(
|
||||
'[attendanceLogsProvider] failed to sync update id=$attendanceId: $e',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (syncedIds.isNotEmpty) {
|
||||
final remaining = Map<String, Map<String, dynamic>>.from(
|
||||
ref.read(offlinePendingAttendanceUpdatesProvider),
|
||||
);
|
||||
for (final id in syncedIds) {
|
||||
remaining.remove(id);
|
||||
}
|
||||
ref.read(offlinePendingAttendanceUpdatesProvider.notifier).state =
|
||||
remaining;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return wrapper.stream.map((result) => applyPending(result.data));
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Controller
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
final attendanceControllerProvider = Provider<AttendanceController>((ref) {
|
||||
final client = ref.watch(supabaseClientProvider);
|
||||
return AttendanceController(client);
|
||||
return AttendanceController(client, ref);
|
||||
});
|
||||
|
||||
class AttendanceController {
|
||||
AttendanceController(this._client);
|
||||
AttendanceController(this._client, [this._ref]);
|
||||
|
||||
final SupabaseClient _client;
|
||||
final Ref? _ref;
|
||||
|
||||
/// Check in to a duty schedule. Returns the attendance log ID.
|
||||
/// Offline: queues locally via StateProvider, returns local UUID.
|
||||
Future<String?> checkIn({
|
||||
required String dutyScheduleId,
|
||||
required double lat,
|
||||
required double lng,
|
||||
String shiftType = 'normal',
|
||||
}) async {
|
||||
final data = await _client.rpc(
|
||||
'attendance_check_in',
|
||||
params: {'p_duty_id': dutyScheduleId, 'p_lat': lat, 'p_lng': lng},
|
||||
);
|
||||
return data as String?;
|
||||
try {
|
||||
final data = await _client.rpc(
|
||||
'attendance_check_in',
|
||||
params: {'p_duty_id': dutyScheduleId, 'p_lat': lat, 'p_lng': lng},
|
||||
);
|
||||
return data as String?;
|
||||
} catch (e) {
|
||||
if (!isOfflineSaveError(e)) rethrow;
|
||||
|
||||
final userId = _client.auth.currentUser?.id;
|
||||
if (userId == null) rethrow;
|
||||
|
||||
final id = const Uuid().v4();
|
||||
final log = AttendanceLog(
|
||||
id: id,
|
||||
userId: userId,
|
||||
dutyScheduleId: dutyScheduleId,
|
||||
shiftType: shiftType,
|
||||
checkInAt: AppTime.now(),
|
||||
checkInLat: lat,
|
||||
checkInLng: lng,
|
||||
verificationStatus: 'pending',
|
||||
);
|
||||
|
||||
_queuePendingCheckIn(
|
||||
log,
|
||||
{'p_duty_id': dutyScheduleId, 'p_lat': lat, 'p_lng': lng, 'localId': id},
|
||||
);
|
||||
debugPrint('[AttendanceController] checkIn queued offline: $id');
|
||||
return id;
|
||||
}
|
||||
}
|
||||
|
||||
/// Check out from an attendance log.
|
||||
/// Check out from an attendance log. Offline: queues the checkout params.
|
||||
Future<void> checkOut({
|
||||
required String attendanceId,
|
||||
required double lat,
|
||||
required double lng,
|
||||
String? justification,
|
||||
}) async {
|
||||
await _client.rpc(
|
||||
'attendance_check_out',
|
||||
params: {
|
||||
'p_attendance_id': attendanceId,
|
||||
'p_lat': lat,
|
||||
'p_lng': lng,
|
||||
'p_justification': justification,
|
||||
},
|
||||
);
|
||||
try {
|
||||
await _client.rpc(
|
||||
'attendance_check_out',
|
||||
params: {
|
||||
'p_attendance_id': attendanceId,
|
||||
'p_lat': lat,
|
||||
'p_lng': lng,
|
||||
'p_justification': justification,
|
||||
},
|
||||
);
|
||||
} catch (e) {
|
||||
if (!isOfflineSaveError(e)) rethrow;
|
||||
|
||||
_queueAttendanceUpdate(attendanceId, {
|
||||
'_rpc_checkout': true,
|
||||
'check_out_at': AppTime.now().toIso8601String(),
|
||||
'check_out_lat': lat,
|
||||
'check_out_lng': lng,
|
||||
'check_out_justification': justification,
|
||||
});
|
||||
debugPrint(
|
||||
'[AttendanceController] checkOut queued offline: $attendanceId',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Overtime check-in (no pre-existing schedule required).
|
||||
/// Creates an overtime duty schedule + attendance log in one RPC call.
|
||||
/// Overtime check-in (online-only — requires server-side schedule creation).
|
||||
Future<String?> overtimeCheckIn({
|
||||
required double lat,
|
||||
required double lng,
|
||||
@@ -130,41 +375,99 @@ class AttendanceController {
|
||||
}
|
||||
|
||||
/// Upload a verification selfie and update the attendance log.
|
||||
/// Offline: queues the status update only (photo bytes are not persisted).
|
||||
Future<void> uploadVerification({
|
||||
required String attendanceId,
|
||||
required Uint8List bytes,
|
||||
required String fileName,
|
||||
required String status, // 'verified', 'unverified'
|
||||
required String status,
|
||||
bool isCheckOut = false,
|
||||
}) async {
|
||||
final userId = _client.auth.currentUser!.id;
|
||||
final ext = fileName.split('.').last.toLowerCase();
|
||||
final prefix = isCheckOut ? 'checkout' : 'checkin';
|
||||
final path = '$userId/${prefix}_$attendanceId.$ext';
|
||||
await _client.storage
|
||||
.from('attendance-verification')
|
||||
.uploadBinary(
|
||||
path,
|
||||
bytes,
|
||||
fileOptions: const FileOptions(upsert: true),
|
||||
);
|
||||
final url = _client.storage
|
||||
.from('attendance-verification')
|
||||
.getPublicUrl(path);
|
||||
final column = isCheckOut
|
||||
? 'check_out_verification_photo_url'
|
||||
: 'check_in_verification_photo_url';
|
||||
await _client
|
||||
.from('attendance_logs')
|
||||
.update({'verification_status': status, column: url})
|
||||
.eq('id', attendanceId);
|
||||
|
||||
try {
|
||||
await _client.storage
|
||||
.from('attendance-verification')
|
||||
.uploadBinary(
|
||||
path,
|
||||
bytes,
|
||||
fileOptions: const FileOptions(upsert: true),
|
||||
);
|
||||
final url = _client.storage
|
||||
.from('attendance-verification')
|
||||
.getPublicUrl(path);
|
||||
final column = isCheckOut
|
||||
? 'check_out_verification_photo_url'
|
||||
: 'check_in_verification_photo_url';
|
||||
await _client
|
||||
.from('attendance_logs')
|
||||
.update({'verification_status': status, column: url})
|
||||
.eq('id', attendanceId);
|
||||
} catch (e) {
|
||||
if (!isOfflineSaveError(e)) rethrow;
|
||||
// Queue status update without photo URL — photo bytes are not persisted
|
||||
// across sessions; user will need to re-upload photo when online.
|
||||
_queueAttendanceUpdate(attendanceId, {'verification_status': status});
|
||||
debugPrint(
|
||||
'[AttendanceController] uploadVerification status queued offline (photo not persisted): $attendanceId',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Mark an attendance log as skipped verification.
|
||||
/// Mark an attendance log as skipped verification. Offline: queues the patch.
|
||||
Future<void> skipVerification(String attendanceId) async {
|
||||
await _client
|
||||
.from('attendance_logs')
|
||||
.update({'verification_status': 'skipped'})
|
||||
.eq('id', attendanceId);
|
||||
try {
|
||||
await _client
|
||||
.from('attendance_logs')
|
||||
.update({'verification_status': 'skipped'})
|
||||
.eq('id', attendanceId);
|
||||
} catch (e) {
|
||||
if (!isOfflineSaveError(e)) rethrow;
|
||||
_queueAttendanceUpdate(attendanceId, {'verification_status': 'skipped'});
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Queue helpers
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
void _queuePendingCheckIn(
|
||||
AttendanceLog log,
|
||||
Map<String, dynamic> rpcParams,
|
||||
) {
|
||||
final ref = _ref;
|
||||
if (ref == null) return;
|
||||
|
||||
final currentModels = List<AttendanceLog>.from(
|
||||
ref.read(offlinePendingCheckInsProvider),
|
||||
);
|
||||
currentModels.add(log);
|
||||
ref.read(offlinePendingCheckInsProvider.notifier).state =
|
||||
List.unmodifiable(currentModels);
|
||||
|
||||
final currentRaw = List<Map<String, dynamic>>.from(
|
||||
ref.read(offlinePendingCheckInRawProvider),
|
||||
);
|
||||
currentRaw.add(rpcParams);
|
||||
ref.read(offlinePendingCheckInRawProvider.notifier).state =
|
||||
List.unmodifiable(currentRaw);
|
||||
|
||||
mirrorBatchToBrick<AttendanceLog>([log], tag: 'offline_checkin');
|
||||
}
|
||||
|
||||
void _queueAttendanceUpdate(
|
||||
String attendanceId,
|
||||
Map<String, dynamic> fields,
|
||||
) {
|
||||
final ref = _ref;
|
||||
if (ref == null) return;
|
||||
final current = Map<String, Map<String, dynamic>>.from(
|
||||
ref.read(offlinePendingAttendanceUpdatesProvider),
|
||||
);
|
||||
current[attendanceId] = {...?current[attendanceId], ...fields};
|
||||
ref.read(offlinePendingAttendanceUpdatesProvider.notifier).state = current;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user