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
+493 -81
View File
@@ -6,10 +6,16 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import 'package:flutter/material.dart';
import 'package:uuid/uuid.dart';
import '../brick/cache_helpers.dart';
import '../models/it_service_request.dart';
import '../models/it_service_request_assignment.dart';
import '../models/it_service_request_activity_log.dart';
import '../models/it_service_request_action.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 'user_offices_provider.dart';
@@ -60,6 +66,27 @@ final itServiceRequestQueryProvider = StateProvider<ItServiceRequestQuery>(
(ref) => const ItServiceRequestQuery(),
);
// ---------------------------------------------------------------------------
// Offline-pending state
// ---------------------------------------------------------------------------
/// IT Service Requests created offline (typed model — merged into live stream).
final offlinePendingItServiceRequestsProvider =
StateProvider<List<ItServiceRequest>>((ref) => const []);
/// Raw RPC params for offline-created requests — used for reconnect replay.
final offlinePendingItServiceRequestRawProvider =
StateProvider<List<Map<String, dynamic>>>((ref) => const []);
/// Field-level updates to existing requests made offline, keyed by request id.
final offlinePendingItServiceRequestUpdatesProvider =
StateProvider<Map<String, Map<String, dynamic>>>((ref) => const {});
/// Pending action-taken entries submitted offline.
/// Each entry: {id, request_id, user_id, action_taken}
final offlinePendingIsrActionsProvider =
StateProvider<List<Map<String, dynamic>>>((ref) => const []);
// ---------------------------------------------------------------------------
// Stream providers
// ---------------------------------------------------------------------------
@@ -71,6 +98,24 @@ final itServiceRequestsProvider = StreamProvider<List<ItServiceRequest>>((ref) {
final profile = ref.watch(currentProfileProvider).valueOrNull;
final userOfficesAsync = ref.watch(userOfficesProvider);
final pendingNew = ref.watch(offlinePendingItServiceRequestsProvider);
final pendingUpdates =
ref.watch(offlinePendingItServiceRequestUpdatesProvider);
List<ItServiceRequest> applyPending(List<ItServiceRequest> 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] = _applyUpdateFields(existing, fields);
});
for (final p in pendingNew) {
byId.putIfAbsent(p.id, () => p);
}
return byId.values.toList()
..sort((a, b) => b.createdAt.compareTo(a.createdAt));
}
final wrapper = StreamRecoveryWrapper<ItServiceRequest>(
stream: client
.from('it_service_requests')
@@ -87,12 +132,140 @@ final itServiceRequestsProvider = StreamProvider<List<ItServiceRequest>>((ref) {
fromMap: ItServiceRequest.fromMap,
channelName: 'it_service_requests',
onStatusChanged: ref.read(realtimeControllerProvider).handleChannelStatus,
onOfflineData: () async {
final all = await cachedListFromBrick<ItServiceRequest>();
all.sort((a, b) => b.createdAt.compareTo(a.createdAt));
return applyPending(all);
},
onCacheMirror: (rows) =>
mirrorBatchToBrick<ItServiceRequest>(rows, tag: 'it_service_requests'),
);
ref.onDispose(wrapper.dispose);
// Reconnect-replay: POST queued items directly, bypassing Brick's queue.
ref.listen<bool>(isOnlineProvider, (wasOnline, isNowOnline) async {
if (wasOnline == true || !isNowOnline) return;
// 1. Replay offline-created requests (via RPC)
final pendingRaw = List<Map<String, dynamic>>.from(
ref.read(offlinePendingItServiceRequestRawProvider),
);
if (pendingRaw.isNotEmpty) {
debugPrint(
'[itServiceRequestsProvider] reconnected — syncing ${pendingRaw.length} request(s)',
);
final syncedIds = <String>[];
for (final params in pendingRaw) {
final id = params['p_id'] as String;
try {
await client.rpc(
'insert_it_service_request_with_number',
params: params,
);
syncedIds.add(id);
} catch (e) {
final msg = e.toString();
if (msg.contains('23505') || msg.contains('duplicate key')) {
syncedIds.add(id);
} else {
debugPrint(
'[itServiceRequestsProvider] failed to sync id=$id: $e',
);
}
}
}
if (syncedIds.isNotEmpty) {
final remainingRaw = ref
.read(offlinePendingItServiceRequestRawProvider)
.where((p) => !syncedIds.contains(p['p_id'] as String))
.toList();
ref.read(offlinePendingItServiceRequestRawProvider.notifier).state =
List.unmodifiable(remainingRaw);
final remainingModels = ref
.read(offlinePendingItServiceRequestsProvider)
.where((r) => !syncedIds.contains(r.id))
.toList();
ref.read(offlinePendingItServiceRequestsProvider.notifier).state =
List.unmodifiable(remainingModels);
}
}
// 2. Replay offline field/status updates
final pendingUpdatesMap = Map<String, Map<String, dynamic>>.from(
ref.read(offlinePendingItServiceRequestUpdatesProvider),
);
if (pendingUpdatesMap.isNotEmpty) {
debugPrint(
'[itServiceRequestsProvider] reconnected — syncing ${pendingUpdatesMap.length} update(s)',
);
final syncedIds = <String>[];
for (final entry in pendingUpdatesMap.entries) {
try {
await client
.from('it_service_requests')
.update(entry.value)
.eq('id', entry.key);
syncedIds.add(entry.key);
} catch (e) {
debugPrint(
'[itServiceRequestsProvider] failed to sync update id=${entry.key}: $e',
);
}
}
if (syncedIds.isNotEmpty) {
final remaining = Map<String, Map<String, dynamic>>.from(
ref.read(offlinePendingItServiceRequestUpdatesProvider),
);
for (final id in syncedIds) {
remaining.remove(id);
}
ref.read(offlinePendingItServiceRequestUpdatesProvider.notifier).state =
remaining;
}
}
// 3. Replay pending action-taken entries
final pendingActions = List<Map<String, dynamic>>.from(
ref.read(offlinePendingIsrActionsProvider),
);
if (pendingActions.isNotEmpty) {
debugPrint(
'[itServiceRequestsProvider] reconnected — syncing ${pendingActions.length} action(s)',
);
final syncedIds = <String>[];
for (final action in pendingActions) {
final id = action['id'] as String;
try {
await client
.from('it_service_request_actions')
.upsert(action, onConflict: 'request_id,user_id');
syncedIds.add(id);
} catch (e) {
final msg = e.toString();
if (msg.contains('23505') || msg.contains('duplicate key')) {
syncedIds.add(id);
} else {
debugPrint(
'[itServiceRequestsProvider] failed to sync action id=$id: $e',
);
}
}
}
if (syncedIds.isNotEmpty) {
final remaining = ref
.read(offlinePendingIsrActionsProvider)
.where((a) => !syncedIds.contains(a['id'] as String))
.toList();
ref.read(offlinePendingIsrActionsProvider.notifier).state =
List.unmodifiable(remaining);
}
}
});
return wrapper.stream.map((result) {
var items = result.data;
// Standard users see only requests from their offices or created by them
var items = applyPending(result.data);
if (profile != null && profile.role == 'standard') {
final officeIds = (userOfficesAsync.valueOrNull ?? [])
.where((a) => a.userId == userId)
@@ -145,6 +318,15 @@ final itServiceRequestAssignmentsProvider =
onStatusChanged: ref
.read(realtimeControllerProvider)
.handleChannelStatus,
onOfflineData: () async {
final all = await cachedListFromBrick<ItServiceRequestAssignment>();
all.sort((a, b) => b.createdAt.compareTo(a.createdAt));
return all;
},
onCacheMirror: (rows) => mirrorBatchToBrick<ItServiceRequestAssignment>(
rows,
tag: 'isr_assignments',
),
);
ref.onDispose(wrapper.dispose);
@@ -177,6 +359,19 @@ final itServiceRequestActivityLogsProvider =
onStatusChanged: ref
.read(realtimeControllerProvider)
.handleChannelStatus,
onOfflineData: () async {
final all = await cachedListFromBrick<ItServiceRequestActivityLog>();
final filtered = all
.where((l) => l.requestId == requestId)
.toList()
..sort((a, b) => b.createdAt.compareTo(a.createdAt));
return filtered;
},
onCacheMirror: (rows) =>
mirrorBatchToBrick<ItServiceRequestActivityLog>(
rows,
tag: 'isr_activity_logs',
),
);
ref.onDispose(wrapper.dispose);
@@ -209,6 +404,18 @@ final itServiceRequestActionsProvider =
onStatusChanged: ref
.read(realtimeControllerProvider)
.handleChannelStatus,
onOfflineData: () async {
final all = await cachedListFromBrick<ItServiceRequestAction>();
final filtered = all
.where((a) => a.requestId == requestId)
.toList()
..sort((a, b) => b.createdAt.compareTo(a.createdAt));
return filtered;
},
onCacheMirror: (rows) => mirrorBatchToBrick<ItServiceRequestAction>(
rows,
tag: 'isr_actions',
),
);
ref.onDispose(wrapper.dispose);
@@ -222,15 +429,19 @@ final itServiceRequestActionsProvider =
final itServiceRequestControllerProvider = Provider<ItServiceRequestController>(
(ref) {
final client = ref.watch(supabaseClientProvider);
return ItServiceRequestController(client);
return ItServiceRequestController(client, ref);
},
);
class ItServiceRequestController {
ItServiceRequestController(this._client);
ItServiceRequestController(this._client, [this._ref]);
final SupabaseClient _client;
final Ref? _ref;
/// Creates a new IT Service Request with auto-generated number.
///
/// Returns `{'id': ..., 'request_number': ...}` on success. If offline,
/// queues locally and returns `{'id': localId, 'request_number': null, 'pending': true}`.
Future<Map<String, dynamic>> createRequest({
required String eventName,
required List<String> services,
@@ -243,18 +454,22 @@ class ItServiceRequestController {
final userId = _client.auth.currentUser?.id;
if (userId == null) throw Exception('Not authenticated');
final offlineId = const Uuid().v4();
final rpcParams = {
'p_id': offlineId,
'p_event_name': eventName,
'p_services': services,
'p_creator_id': userId,
'p_office_id': officeId,
'p_requested_by': requestedBy,
'p_requested_by_user_id': requestedByUserId,
'p_status': status,
};
try {
final result = await _client.rpc(
'insert_it_service_request_with_number',
params: {
'p_event_name': eventName,
'p_services': services,
'p_creator_id': userId,
'p_office_id': officeId,
'p_requested_by': requestedBy,
'p_requested_by_user_id': requestedByUserId,
'p_status': status,
},
params: rpcParams,
);
final row = result is List
@@ -269,7 +484,6 @@ class ItServiceRequestController {
.eq('id', requestId);
}
// Activity log
await _client.from('it_service_request_activity_logs').insert({
'request_id': requestId,
'actor_id': userId,
@@ -278,12 +492,36 @@ class ItServiceRequestController {
return {'id': requestId, 'request_number': row['request_number']};
} catch (e) {
debugPrint('createRequest error: $e');
rethrow;
if (!isOfflineSaveError(e)) {
debugPrint('createRequest error: $e');
rethrow;
}
final now = DateTime.now().toUtc();
final local = ItServiceRequest(
id: offlineId,
services: services,
eventName: eventName,
status: status,
outsidePremiseAllowed: false,
createdAt: now,
updatedAt: now,
creatorId: userId,
officeId: officeId,
requestedBy: requestedBy,
requestedByUserId: requestedByUserId,
servicesOther: servicesOther,
);
_queuePendingRequest(local, rpcParams);
debugPrint(
'[ItServiceRequestController] createRequest queued offline: $offlineId',
);
return {'id': offlineId, 'request_number': null, 'pending': true};
}
}
/// Updates IT Service Request fields.
/// Updates IT Service Request fields. Offline: queues the field patch.
Future<void> updateRequest({
required String requestId,
String? eventName,
@@ -345,22 +583,26 @@ class ItServiceRequestController {
if (updates.isEmpty) return;
await _client
.from('it_service_requests')
.update(updates)
.eq('id', requestId);
try {
await _client
.from('it_service_requests')
.update(updates)
.eq('id', requestId);
// Log updated fields
final userId = _client.auth.currentUser?.id;
await _client.from('it_service_request_activity_logs').insert({
'request_id': requestId,
'actor_id': userId,
'action_type': 'updated',
'meta': {'fields': updates.keys.toList()},
});
final userId = _client.auth.currentUser?.id;
await _client.from('it_service_request_activity_logs').insert({
'request_id': requestId,
'actor_id': userId,
'action_type': 'updated',
'meta': {'fields': updates.keys.toList()},
});
} catch (e) {
if (!isOfflineSaveError(e)) rethrow;
_queueRequestUpdate(requestId, updates);
}
}
/// Update only the status of an IT Service Request.
/// Update only the status of an IT Service Request. Offline: queues.
Future<void> updateStatus({
required String requestId,
required String status,
@@ -383,44 +625,56 @@ class ItServiceRequestController {
}
}
await _client
.from('it_service_requests')
.update(updates)
.eq('id', requestId);
try {
await _client
.from('it_service_requests')
.update(updates)
.eq('id', requestId);
await _client.from('it_service_request_activity_logs').insert({
'request_id': requestId,
'actor_id': userId,
'action_type': 'status_changed',
'meta': {'status': status},
});
await _client.from('it_service_request_activity_logs').insert({
'request_id': requestId,
'actor_id': userId,
'action_type': 'status_changed',
'meta': {'status': status},
});
} catch (e) {
if (!isOfflineSaveError(e)) rethrow;
_queueRequestUpdate(requestId, updates);
}
}
/// Approve a request (admin only). Sets status to 'scheduled'.
/// Approve a request (admin only). Offline: queues the status patch.
Future<void> approveRequest({
required String requestId,
required String approverName,
}) async {
final userId = _client.auth.currentUser?.id;
await _client
.from('it_service_requests')
.update({
'status': 'scheduled',
'approved_by': approverName,
'approved_by_user_id': userId,
'approved_at': DateTime.now().toUtc().toIso8601String(),
})
.eq('id', requestId);
final updates = <String, dynamic>{
'status': 'scheduled',
'approved_by': approverName,
'approved_by_user_id': userId,
'approved_at': DateTime.now().toUtc().toIso8601String(),
};
await _client.from('it_service_request_activity_logs').insert({
'request_id': requestId,
'actor_id': userId,
'action_type': 'approved',
});
try {
await _client
.from('it_service_requests')
.update(updates)
.eq('id', requestId);
await _client.from('it_service_request_activity_logs').insert({
'request_id': requestId,
'actor_id': userId,
'action_type': 'approved',
});
} catch (e) {
if (!isOfflineSaveError(e)) rethrow;
_queueRequestUpdate(requestId, updates);
}
}
// -----------------------------------------------------------------------
// Assignment management
// Assignment management (online-only — admin/dispatcher function)
// -----------------------------------------------------------------------
Future<void> assignStaff({
@@ -463,7 +717,7 @@ class ItServiceRequestController {
}
// -----------------------------------------------------------------------
// Action Taken
// Action Taken — offline-safe
// -----------------------------------------------------------------------
Future<String> createOrUpdateAction({
@@ -473,31 +727,44 @@ class ItServiceRequestController {
final userId = _client.auth.currentUser?.id;
if (userId == null) throw Exception('Not authenticated');
// Check if action already exists for this user
final existing = await _client
.from('it_service_request_actions')
.select('id')
.eq('request_id', requestId)
.eq('user_id', userId)
.maybeSingle();
final actionId = const Uuid().v4();
if (existing != null) {
await _client
try {
final existing = await _client
.from('it_service_request_actions')
.update({'action_taken': actionTaken})
.eq('id', existing['id']);
return existing['id'] as String;
} else {
final result = await _client
.from('it_service_request_actions')
.insert({
'request_id': requestId,
'user_id': userId,
'action_taken': actionTaken,
})
.select('id')
.single();
return result['id'] as String;
.eq('request_id', requestId)
.eq('user_id', userId)
.maybeSingle();
if (existing != null) {
await _client
.from('it_service_request_actions')
.update({'action_taken': actionTaken})
.eq('id', existing['id']);
return existing['id'] as String;
} else {
final result = await _client
.from('it_service_request_actions')
.insert({
'id': actionId,
'request_id': requestId,
'user_id': userId,
'action_taken': actionTaken,
})
.select('id')
.single();
return result['id'] as String;
}
} catch (e) {
if (!isOfflineSaveError(e)) rethrow;
_queuePendingAction({
'id': actionId,
'request_id': requestId,
'user_id': userId,
'action_taken': actionTaken,
});
return actionId;
}
}
@@ -550,7 +817,6 @@ class ItServiceRequestController {
}
Future<void> deleteEvidence({required String evidenceId}) async {
// Get path first
final row = await _client
.from('it_service_request_evidence')
.select('file_path')
@@ -630,7 +896,6 @@ class ItServiceRequestController {
await _client.storage.from('it_service_attachments').remove([path]);
}
/// List evidence attachments for a request from the database.
Future<List<Map<String, dynamic>>> listEvidence(String requestId) async {
final rows = await _client
.from('it_service_request_evidence')
@@ -651,4 +916,151 @@ class ItServiceRequestController {
)
.toList();
}
// -----------------------------------------------------------------------
// Queue helpers
// -----------------------------------------------------------------------
void _queuePendingRequest(
ItServiceRequest model,
Map<String, dynamic> rpcParams,
) {
final ref = _ref;
if (ref == null) return;
final currentModels = List<ItServiceRequest>.from(
ref.read(offlinePendingItServiceRequestsProvider),
);
currentModels.add(model);
ref.read(offlinePendingItServiceRequestsProvider.notifier).state =
List.unmodifiable(currentModels);
final currentRaw = List<Map<String, dynamic>>.from(
ref.read(offlinePendingItServiceRequestRawProvider),
);
currentRaw.add(rpcParams);
ref.read(offlinePendingItServiceRequestRawProvider.notifier).state =
List.unmodifiable(currentRaw);
mirrorBatchToBrick<ItServiceRequest>([model], tag: 'offline_isr');
}
void _queueRequestUpdate(String requestId, Map<String, dynamic> fields) {
final ref = _ref;
if (ref == null) return;
final current = Map<String, Map<String, dynamic>>.from(
ref.read(offlinePendingItServiceRequestUpdatesProvider),
);
current[requestId] = {...?current[requestId], ...fields};
ref.read(offlinePendingItServiceRequestUpdatesProvider.notifier).state =
current;
}
void _queuePendingAction(Map<String, dynamic> action) {
final ref = _ref;
if (ref == null) return;
final current = List<Map<String, dynamic>>.from(
ref.read(offlinePendingIsrActionsProvider),
);
// Replace existing entry for the same request+user if present
final existing = current.indexWhere(
(a) =>
a['request_id'] == action['request_id'] &&
a['user_id'] == action['user_id'],
);
if (existing >= 0) {
current[existing] = action;
} else {
current.add(action);
}
ref.read(offlinePendingIsrActionsProvider.notifier).state =
List.unmodifiable(current);
}
}
// ---------------------------------------------------------------------------
// Field-patch helper (avoids copyWith pattern on a model with 25+ fields)
// ---------------------------------------------------------------------------
ItServiceRequest _applyUpdateFields(
ItServiceRequest r,
Map<String, dynamic> f,
) {
DateTime? parseOpt(String key) {
if (!f.containsKey(key)) return null;
final v = f[key];
return v == null ? null : AppTime.parse(v as String);
}
return ItServiceRequest(
id: r.id,
requestNumber: r.requestNumber,
services: f.containsKey('services')
? (f['services'] as List<dynamic>).cast<String>()
: r.services,
servicesOther: f.containsKey('services_other')
? f['services_other'] as String?
: r.servicesOther,
eventName: (f['event_name'] as String?) ?? r.eventName,
eventDetails: f.containsKey('event_details')
? f['event_details'] as String?
: r.eventDetails,
eventDate:
f.containsKey('event_date') ? parseOpt('event_date') : r.eventDate,
eventEndDate: f.containsKey('event_end_date')
? parseOpt('event_end_date')
: r.eventEndDate,
dryRunDate: f.containsKey('dry_run_date')
? parseOpt('dry_run_date')
: r.dryRunDate,
dryRunEndDate: f.containsKey('dry_run_end_date')
? parseOpt('dry_run_end_date')
: r.dryRunEndDate,
contactPerson: f.containsKey('contact_person')
? f['contact_person'] as String?
: r.contactPerson,
contactNumber: f.containsKey('contact_number')
? f['contact_number'] as String?
: r.contactNumber,
remarks:
f.containsKey('remarks') ? f['remarks'] as String? : r.remarks,
officeId:
f.containsKey('office_id') ? f['office_id'] as String? : r.officeId,
requestedBy: f.containsKey('requested_by')
? f['requested_by'] as String?
: r.requestedBy,
requestedByUserId: f.containsKey('requested_by_user_id')
? f['requested_by_user_id'] as String?
: r.requestedByUserId,
approvedBy: f.containsKey('approved_by')
? f['approved_by'] as String?
: r.approvedBy,
approvedByUserId: f.containsKey('approved_by_user_id')
? f['approved_by_user_id'] as String?
: r.approvedByUserId,
approvedAt: f.containsKey('approved_at')
? parseOpt('approved_at')
: r.approvedAt,
status: (f['status'] as String?) ?? r.status,
outsidePremiseAllowed:
(f['outside_premise_allowed'] as bool?) ?? r.outsidePremiseAllowed,
cancellationReason: f.containsKey('cancellation_reason')
? f['cancellation_reason'] as String?
: r.cancellationReason,
cancelledAt: f.containsKey('cancelled_at')
? parseOpt('cancelled_at')
: r.cancelledAt,
creatorId: r.creatorId,
createdAt: r.createdAt,
updatedAt: r.updatedAt,
completedAt: f.containsKey('completed_at')
? parseOpt('completed_at')
: r.completedAt,
dateTimeReceived: f.containsKey('date_time_received')
? parseOpt('date_time_received')
: r.dateTimeReceived,
dateTimeChecked: f.containsKey('date_time_checked')
? parseOpt('date_time_checked')
: r.dateTimeChecked,
);
}