Offline Support
This commit is contained in:
@@ -5,6 +5,11 @@ import 'package:supabase_flutter/supabase_flutter.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
import '../brick/cache_helpers.dart';
|
||||
import '../brick/repository.dart';
|
||||
import '../utils/snackbar.dart' show isOfflineSaveError;
|
||||
import '../models/office.dart';
|
||||
import '../models/ticket.dart';
|
||||
import '../models/ticket_message.dart';
|
||||
@@ -12,11 +17,27 @@ import 'profile_provider.dart';
|
||||
import 'supabase_provider.dart';
|
||||
import 'user_offices_provider.dart';
|
||||
import 'tasks_provider.dart';
|
||||
import 'connectivity_provider.dart';
|
||||
import 'stream_recovery.dart';
|
||||
import 'realtime_controller.dart';
|
||||
|
||||
/// Pending offices created while offline.
|
||||
final offlinePendingOfficesProvider =
|
||||
StateProvider<List<Office>>((ref) => const []);
|
||||
|
||||
final officesProvider = StreamProvider<List<Office>>((ref) {
|
||||
final client = ref.watch(supabaseClientProvider);
|
||||
final pendingOffices = ref.watch(offlinePendingOfficesProvider);
|
||||
|
||||
List<Office> applyPending(List<Office> rows) {
|
||||
if (pendingOffices.isEmpty) return rows;
|
||||
final byId = {for (final r in rows) r.id: r};
|
||||
for (final p in pendingOffices) {
|
||||
byId.putIfAbsent(p.id, () => p);
|
||||
}
|
||||
return byId.values.toList()
|
||||
..sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()));
|
||||
}
|
||||
|
||||
final wrapper = StreamRecoveryWrapper<Office>(
|
||||
stream: client.from('offices').stream(primaryKey: ['id']).order('name'),
|
||||
@@ -27,10 +48,49 @@ final officesProvider = StreamProvider<List<Office>>((ref) {
|
||||
fromMap: Office.fromMap,
|
||||
channelName: 'offices',
|
||||
onStatusChanged: ref.read(realtimeControllerProvider).handleChannelStatus,
|
||||
onOfflineData: () async {
|
||||
final all = await cachedListFromBrick<Office>();
|
||||
all.sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()));
|
||||
return applyPending(all);
|
||||
},
|
||||
onCacheMirror: (rows) =>
|
||||
mirrorBatchToBrick<Office>(rows, tag: 'offices'),
|
||||
);
|
||||
|
||||
ref.onDispose(wrapper.dispose);
|
||||
return wrapper.stream.map((result) => result.data);
|
||||
|
||||
// Reconnect-replay for offline-created offices.
|
||||
ref.listen<bool>(isOnlineProvider, (wasOnline, isNowOnline) async {
|
||||
if (wasOnline == true || !isNowOnline) return;
|
||||
final pending = List<Office>.from(
|
||||
ref.read(offlinePendingOfficesProvider),
|
||||
);
|
||||
if (pending.isEmpty) return;
|
||||
final synced = <String>[];
|
||||
for (final office in pending) {
|
||||
try {
|
||||
final payload = <String, dynamic>{'id': office.id, 'name': office.name};
|
||||
if (office.serviceId != null) payload['service_id'] = office.serviceId;
|
||||
await client.from('offices').insert(payload);
|
||||
synced.add(office.id);
|
||||
} catch (e) {
|
||||
final s = e.toString();
|
||||
if (s.contains('23505') || s.contains('duplicate key')) {
|
||||
synced.add(office.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (synced.isNotEmpty) {
|
||||
final remaining = ref
|
||||
.read(offlinePendingOfficesProvider)
|
||||
.where((o) => !synced.contains(o.id))
|
||||
.toList();
|
||||
ref.read(offlinePendingOfficesProvider.notifier).state =
|
||||
List.unmodifiable(remaining);
|
||||
}
|
||||
});
|
||||
|
||||
return wrapper.stream.map((result) => applyPending(result.data));
|
||||
});
|
||||
|
||||
final officesOnceProvider = FutureProvider<List<Office>>((ref) async {
|
||||
@@ -70,7 +130,7 @@ final officesQueryProvider = StateProvider<OfficeQuery>(
|
||||
|
||||
final officesControllerProvider = Provider<OfficesController>((ref) {
|
||||
final client = ref.watch(supabaseClientProvider);
|
||||
return OfficesController(client);
|
||||
return OfficesController(client, ref);
|
||||
});
|
||||
|
||||
/// Ticket query parameters for server-side pagination and filtering.
|
||||
@@ -210,10 +270,90 @@ final ticketsProvider = StreamProvider<List<Ticket>>((ref) {
|
||||
fromMap: Ticket.fromMap,
|
||||
channelName: 'tickets',
|
||||
onStatusChanged: ref.read(realtimeControllerProvider).handleChannelStatus,
|
||||
onOfflineData: () => cachedListFromBrick<Ticket>(),
|
||||
onCacheMirror: (rows) =>
|
||||
mirrorBatchToBrick<Ticket>(rows, tag: 'tickets'),
|
||||
);
|
||||
|
||||
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 tickets
|
||||
final pendingRaw = List<Map<String, dynamic>>.from(
|
||||
ref.read(offlinePendingTicketRawProvider),
|
||||
);
|
||||
if (pendingRaw.isNotEmpty) {
|
||||
debugPrint(
|
||||
'[ticketsProvider] reconnected — syncing ${pendingRaw.length} ticket(s)',
|
||||
);
|
||||
final syncedIds = <String>[];
|
||||
for (final row in pendingRaw) {
|
||||
final id = row['id'] as String;
|
||||
try {
|
||||
await client.from('tickets').insert(row);
|
||||
syncedIds.add(id);
|
||||
} catch (e) {
|
||||
final msg = e.toString();
|
||||
if (msg.contains('23505') || msg.contains('duplicate key')) {
|
||||
syncedIds.add(id);
|
||||
} else {
|
||||
debugPrint('[ticketsProvider] failed to sync ticket id=$id: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
if (syncedIds.isNotEmpty) {
|
||||
final remainingRaw = ref
|
||||
.read(offlinePendingTicketRawProvider)
|
||||
.where((r) => !syncedIds.contains(r['id'] as String))
|
||||
.toList();
|
||||
ref.read(offlinePendingTicketRawProvider.notifier).state =
|
||||
List.unmodifiable(remainingRaw);
|
||||
final remainingModels = ref
|
||||
.read(offlinePendingTicketsProvider)
|
||||
.where((t) => !syncedIds.contains(t.id))
|
||||
.toList();
|
||||
ref.read(offlinePendingTicketsProvider.notifier).state =
|
||||
List.unmodifiable(remainingModels);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Replay offline ticket updates
|
||||
final pendingUpdates = Map<String, Map<String, dynamic>>.from(
|
||||
ref.read(offlinePendingTicketUpdatesProvider),
|
||||
);
|
||||
if (pendingUpdates.isNotEmpty) {
|
||||
debugPrint(
|
||||
'[ticketsProvider] reconnected — syncing ${pendingUpdates.length} ticket update(s)',
|
||||
);
|
||||
final syncedIds = <String>[];
|
||||
for (final entry in pendingUpdates.entries) {
|
||||
try {
|
||||
await client
|
||||
.from('tickets')
|
||||
.update(entry.value)
|
||||
.eq('id', entry.key);
|
||||
syncedIds.add(entry.key);
|
||||
} catch (e) {
|
||||
debugPrint(
|
||||
'[ticketsProvider] failed to sync update id=${entry.key}: $e',
|
||||
);
|
||||
}
|
||||
}
|
||||
if (syncedIds.isNotEmpty) {
|
||||
final remaining = Map<String, Map<String, dynamic>>.from(
|
||||
ref.read(offlinePendingTicketUpdatesProvider),
|
||||
);
|
||||
for (final id in syncedIds) {
|
||||
remaining.remove(id);
|
||||
}
|
||||
ref.read(offlinePendingTicketUpdatesProvider.notifier).state = remaining;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
var lastResultHash = '';
|
||||
Timer? debounceTimer;
|
||||
// broadcast() so Riverpod and any other listener can both receive events.
|
||||
@@ -239,6 +379,35 @@ final ticketsProvider = StreamProvider<List<Ticket>>((ref) {
|
||||
// prevents a duplicate rebuild if both arrive with identical data.
|
||||
unawaited(
|
||||
Future(() async {
|
||||
// When offline, seed from Brick local SQLite cache instead of network.
|
||||
if (!ref.read(isOnlineProvider) && AppRepository.isConfigured) {
|
||||
try {
|
||||
final cached = await AppRepository.instance.get<Ticket>(
|
||||
policy: OfflineFirstGetPolicy.localOnly,
|
||||
);
|
||||
debugPrint('[ticketsProvider] offline seed: ${cached.length} tickets from local cache');
|
||||
final payload = _buildTicketPayload(
|
||||
tickets: cached,
|
||||
isGlobal: isGlobal,
|
||||
allowedOfficeIds: allowedOfficeIds,
|
||||
query: query,
|
||||
);
|
||||
final processed = await compute(_processTicketsInIsolate, payload);
|
||||
final tickets = (processed as List<dynamic>)
|
||||
.cast<Map<String, dynamic>>()
|
||||
.map(Ticket.fromMap)
|
||||
.toList();
|
||||
final hash = tickets.fold('', (h, t) => '$h${t.id}');
|
||||
if (!controller.isClosed && hash != lastResultHash) {
|
||||
lastResultHash = hash;
|
||||
controller.add(tickets);
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('[ticketsProvider] offline seed error: $e');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Seed fetch: order newest-first and limit to 200 rows to reduce
|
||||
// initial payload. The realtime stream delivers subsequent changes.
|
||||
@@ -506,35 +675,91 @@ final taskMessagesProvider = StreamProvider.family<List<TicketMessage>, String>(
|
||||
},
|
||||
);
|
||||
|
||||
/// Tickets created offline that haven't synced to Supabase yet.
|
||||
/// Merged into the live stream in the UI — cleared entry-by-entry as the
|
||||
/// realtime stream delivers each synced UUID.
|
||||
final offlinePendingTicketsProvider =
|
||||
StateProvider<List<Ticket>>((ref) => const []);
|
||||
|
||||
/// Raw INSERT payloads for offline-created tickets — used for reconnect replay.
|
||||
final offlinePendingTicketRawProvider =
|
||||
StateProvider<List<Map<String, dynamic>>>((ref) => const []);
|
||||
|
||||
/// Field-level updates to existing tickets made offline, keyed by ticket id.
|
||||
final offlinePendingTicketUpdatesProvider =
|
||||
StateProvider<Map<String, Map<String, dynamic>>>((ref) => const {});
|
||||
|
||||
final ticketsControllerProvider = Provider<TicketsController>((ref) {
|
||||
final client = ref.watch(supabaseClientProvider);
|
||||
return TicketsController(client);
|
||||
return TicketsController(client, ref);
|
||||
});
|
||||
|
||||
class TicketsController {
|
||||
TicketsController(this._client);
|
||||
TicketsController(this._client, [this._ref]);
|
||||
|
||||
final SupabaseClient _client;
|
||||
final Ref? _ref;
|
||||
|
||||
Future<void> createTicket({
|
||||
/// Creates a ticket. Returns the new [Ticket] so callers can add it to
|
||||
/// [offlinePendingTicketsProvider] when offline.
|
||||
Future<Ticket?> createTicket({
|
||||
required String subject,
|
||||
required String description,
|
||||
required String officeId,
|
||||
}) async {
|
||||
final actorId = _client.auth.currentUser?.id;
|
||||
final data = await _client
|
||||
.from('tickets')
|
||||
.insert({
|
||||
// Generate UUID before the network call so Brick's offline queue captures
|
||||
// the same UUID in the queued request — preventing a UUID mismatch on replay.
|
||||
final id = const Uuid().v4();
|
||||
try {
|
||||
final data = await _client
|
||||
.from('tickets')
|
||||
.insert({
|
||||
'id': id,
|
||||
'subject': subject,
|
||||
'description': description,
|
||||
'office_id': officeId,
|
||||
'creator_id': actorId,
|
||||
})
|
||||
.select('id')
|
||||
.single();
|
||||
final ticketId = data['id'] as String?;
|
||||
if (ticketId == null) return null;
|
||||
unawaited(_notifyCreated(ticketId: ticketId, actorId: actorId));
|
||||
return null; // online path — ticket will arrive via realtime stream
|
||||
} catch (e) {
|
||||
if (!isOfflineSaveError(e)) rethrow;
|
||||
final ticket = Ticket(
|
||||
id: id,
|
||||
subject: subject,
|
||||
description: description,
|
||||
officeId: officeId,
|
||||
creatorId: actorId ?? '',
|
||||
status: 'pending',
|
||||
createdAt: DateTime.now().toUtc(),
|
||||
respondedAt: null,
|
||||
promotedAt: null,
|
||||
closedAt: null,
|
||||
);
|
||||
// Queue for reconnect replay and persist locally for cold-launch.
|
||||
final ref = _ref;
|
||||
if (ref != null) {
|
||||
final currentRaw = List<Map<String, dynamic>>.from(
|
||||
ref.read(offlinePendingTicketRawProvider),
|
||||
);
|
||||
currentRaw.add({
|
||||
'id': id,
|
||||
'subject': subject,
|
||||
'description': description,
|
||||
'office_id': officeId,
|
||||
'creator_id': _client.auth.currentUser?.id,
|
||||
})
|
||||
.select('id')
|
||||
.single();
|
||||
final ticketId = data['id'] as String?;
|
||||
if (ticketId == null) return;
|
||||
unawaited(_notifyCreated(ticketId: ticketId, actorId: actorId));
|
||||
'creator_id': actorId,
|
||||
});
|
||||
ref.read(offlinePendingTicketRawProvider.notifier).state =
|
||||
List.unmodifiable(currentRaw);
|
||||
}
|
||||
mirrorBatchToBrick<Ticket>([ticket], tag: 'offline_ticket');
|
||||
return ticket;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _notifyCreated({
|
||||
@@ -661,27 +886,66 @@ class TicketsController {
|
||||
required String? ticketId,
|
||||
required String content,
|
||||
}) async {
|
||||
final senderId = _client.auth.currentUser?.id;
|
||||
final payload = <String, dynamic>{
|
||||
'task_id': taskId,
|
||||
'content': content,
|
||||
'sender_id': _client.auth.currentUser?.id,
|
||||
'sender_id': senderId,
|
||||
};
|
||||
if (ticketId != null) {
|
||||
payload['ticket_id'] = ticketId;
|
||||
}
|
||||
final data = await _client
|
||||
.from('ticket_messages')
|
||||
.insert(payload)
|
||||
.select()
|
||||
.single();
|
||||
return TicketMessage.fromMap(data);
|
||||
try {
|
||||
final data = await _client
|
||||
.from('ticket_messages')
|
||||
.insert(payload)
|
||||
.select()
|
||||
.single();
|
||||
return TicketMessage.fromMap(data);
|
||||
} catch (e) {
|
||||
if (isOfflineSaveError(e)) {
|
||||
// Store message locally so it appears in the UI immediately and is
|
||||
// replayed by tasksProvider's reconnect listener when back online.
|
||||
final ref = _ref;
|
||||
if (ref != null) {
|
||||
final createdAt = DateTime.now().toUtc().toIso8601String();
|
||||
final msgPayload = Map<String, dynamic>.from(payload)
|
||||
..['created_at'] = createdAt;
|
||||
final current = Map<String, List<Map<String, dynamic>>>.from(
|
||||
ref.read(offlinePendingMessagesProvider),
|
||||
);
|
||||
current[taskId] = [...(current[taskId] ?? []), msgPayload];
|
||||
ref.read(offlinePendingMessagesProvider.notifier).state = current;
|
||||
// Return a synthetic TicketMessage so the caller's mention logic
|
||||
// still has a message object to work with.
|
||||
return TicketMessage(
|
||||
id: -DateTime.now().millisecondsSinceEpoch,
|
||||
ticketId: ticketId,
|
||||
taskId: taskId,
|
||||
senderId: senderId,
|
||||
content: content,
|
||||
createdAt: DateTime.now(),
|
||||
);
|
||||
}
|
||||
}
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> updateTicketStatus({
|
||||
required String ticketId,
|
||||
required String status,
|
||||
}) async {
|
||||
await _client.from('tickets').update({'status': status}).eq('id', ticketId);
|
||||
try {
|
||||
await _client
|
||||
.from('tickets')
|
||||
.update({'status': status})
|
||||
.eq('id', ticketId);
|
||||
} catch (e) {
|
||||
if (!isOfflineSaveError(e)) rethrow;
|
||||
_queueTicketUpdate(ticketId, {'status': status});
|
||||
return;
|
||||
}
|
||||
|
||||
// If ticket is promoted, create a linked Task (only once) — the
|
||||
// TasksController.createTask already runs auto-assignment on creation.
|
||||
@@ -718,6 +982,7 @@ class TicketsController {
|
||||
}
|
||||
|
||||
/// Update editable ticket fields such as subject, description, and office.
|
||||
/// Offline: queues the field patch.
|
||||
Future<void> updateTicket({
|
||||
required String ticketId,
|
||||
String? subject,
|
||||
@@ -730,29 +995,59 @@ class TicketsController {
|
||||
if (officeId != null) payload['office_id'] = officeId;
|
||||
if (payload.isEmpty) return;
|
||||
|
||||
await _client.from('tickets').update(payload).eq('id', ticketId);
|
||||
|
||||
// record an activity row for edit operations (best-effort)
|
||||
try {
|
||||
final actorId = _client.auth.currentUser?.id;
|
||||
await _client.from('ticket_messages').insert({
|
||||
'ticket_id': ticketId,
|
||||
'sender_id': actorId,
|
||||
'content': 'Ticket updated',
|
||||
});
|
||||
} catch (_) {}
|
||||
await _client.from('tickets').update(payload).eq('id', ticketId);
|
||||
// record an activity row for edit operations (best-effort)
|
||||
try {
|
||||
final actorId = _client.auth.currentUser?.id;
|
||||
await _client.from('ticket_messages').insert({
|
||||
'ticket_id': ticketId,
|
||||
'sender_id': actorId,
|
||||
'content': 'Ticket updated',
|
||||
});
|
||||
} catch (_) {}
|
||||
} catch (e) {
|
||||
if (!isOfflineSaveError(e)) rethrow;
|
||||
_queueTicketUpdate(ticketId, payload);
|
||||
}
|
||||
}
|
||||
|
||||
void _queueTicketUpdate(String ticketId, Map<String, dynamic> fields) {
|
||||
final ref = _ref;
|
||||
if (ref == null) return;
|
||||
final current = Map<String, Map<String, dynamic>>.from(
|
||||
ref.read(offlinePendingTicketUpdatesProvider),
|
||||
);
|
||||
current[ticketId] = {...?current[ticketId], ...fields};
|
||||
ref.read(offlinePendingTicketUpdatesProvider.notifier).state = current;
|
||||
}
|
||||
}
|
||||
|
||||
class OfficesController {
|
||||
OfficesController(this._client);
|
||||
OfficesController(this._client, [this._ref]);
|
||||
|
||||
final SupabaseClient _client;
|
||||
final Ref? _ref;
|
||||
|
||||
Future<void> createOffice({required String name, String? serviceId}) async {
|
||||
final payload = {'name': name};
|
||||
final id = const Uuid().v4();
|
||||
final payload = <String, dynamic>{'id': id, 'name': name};
|
||||
if (serviceId != null) payload['service_id'] = serviceId;
|
||||
await _client.from('offices').insert(payload);
|
||||
try {
|
||||
await _client.from('offices').insert(payload);
|
||||
} catch (e) {
|
||||
if (!isOfflineSaveError(e)) rethrow;
|
||||
final ref = _ref;
|
||||
if (ref == null) return;
|
||||
final office = Office(id: id, name: name, serviceId: serviceId);
|
||||
final current = List<Office>.from(
|
||||
ref.read(offlinePendingOfficesProvider),
|
||||
);
|
||||
current.add(office);
|
||||
ref.read(offlinePendingOfficesProvider.notifier).state =
|
||||
List.unmodifiable(current);
|
||||
mirrorBatchToBrick<Office>([office], tag: 'offline_offices');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> updateOffice({
|
||||
|
||||
Reference in New Issue
Block a user