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
+331 -36
View File
@@ -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({