Implemented per stream subscription recovery with polling fallback

This commit is contained in:
2026-03-01 17:24:04 +08:00
parent e91e7b43d2
commit c9479f01f0
19 changed files with 894 additions and 494 deletions
+101 -31
View File
@@ -12,14 +12,22 @@ import 'profile_provider.dart';
import 'supabase_provider.dart';
import 'user_offices_provider.dart';
import 'tasks_provider.dart';
import 'stream_recovery.dart';
final officesProvider = StreamProvider<List<Office>>((ref) {
final client = ref.watch(supabaseClientProvider);
return client
.from('offices')
.stream(primaryKey: ['id'])
.order('name')
.map((rows) => rows.map(Office.fromMap).toList());
final wrapper = StreamRecoveryWrapper<Office>(
stream: client.from('offices').stream(primaryKey: ['id']).order('name'),
onPollData: () async {
final data = await client.from('offices').select().order('name');
return data.map(Office.fromMap).toList();
},
fromMap: Office.fromMap,
);
ref.onDispose(wrapper.dispose);
return wrapper.stream.map((result) => result.data);
});
final officesOnceProvider = FutureProvider<List<Office>>((ref) async {
@@ -128,16 +136,38 @@ final ticketsProvider = StreamProvider<List<Ticket>>((ref) {
profile.role == 'dispatcher' ||
profile.role == 'it_staff';
// Use stream for realtime updates. Offload expensive client-side
// filtering/sorting/pagination to a background isolate via `compute`
// so UI navigation and builds remain smooth.
final baseStream = client.from('tickets').stream(primaryKey: ['id']);
// Wrap realtime stream with recovery logic
final wrapper = StreamRecoveryWrapper<Ticket>(
stream: client.from('tickets').stream(primaryKey: ['id']),
onPollData: () async {
// Polling fallback: fetch all tickets once
final data = await client.from('tickets').select();
return data.cast<Map<String, dynamic>>().map(Ticket.fromMap).toList();
},
fromMap: Ticket.fromMap,
);
return baseStream.asyncMap((rows) async {
// rows is List<dynamic> of maps coming from Supabase
final rowsList = (rows as List<dynamic>).cast<Map<String, dynamic>>();
ref.onDispose(wrapper.dispose);
// Process tickets with filtering/pagination after recovery
return wrapper.stream.asyncMap((result) async {
final rowsList = result.data
.map(
(ticket) => <String, dynamic>{
'id': ticket.id,
'subject': ticket.subject,
'description': ticket.description,
'status': ticket.status,
'office_id': ticket.officeId,
'creator_id': ticket.creatorId,
'created_at': ticket.createdAt.toIso8601String(),
'responded_at': ticket.respondedAt?.toIso8601String(),
'promoted_at': ticket.promotedAt?.toIso8601String(),
'closed_at': ticket.closedAt?.toIso8601String(),
},
)
.toList();
// Prepare lightweight serializable args for background processing
final allowedOfficeIds =
assignmentsAsync.valueOrNull
?.where((assignment) => assignment.userId == profile.id)
@@ -160,7 +190,6 @@ final ticketsProvider = StreamProvider<List<Ticket>>((ref) {
final processed = await compute(_processTicketsInIsolate, payload);
// `processed` is List<Map<String,dynamic>> — convert to Ticket objects
final tickets = (processed as List<dynamic>)
.cast<Map<String, dynamic>>()
.map(Ticket.fromMap)
@@ -246,32 +275,73 @@ final ticketsQueryProvider = StateProvider<TicketQuery>(
final ticketMessagesProvider =
StreamProvider.family<List<TicketMessage>, String>((ref, ticketId) {
final client = ref.watch(supabaseClientProvider);
return client
.from('ticket_messages')
.stream(primaryKey: ['id'])
.eq('ticket_id', ticketId)
.order('created_at', ascending: false)
.map((rows) => rows.map(TicketMessage.fromMap).toList());
final wrapper = StreamRecoveryWrapper<TicketMessage>(
stream: client
.from('ticket_messages')
.stream(primaryKey: ['id'])
.eq('ticket_id', ticketId)
.order('created_at', ascending: false),
onPollData: () async {
final data = await client
.from('ticket_messages')
.select()
.eq('ticket_id', ticketId)
.order('created_at', ascending: false);
return data.map(TicketMessage.fromMap).toList();
},
fromMap: TicketMessage.fromMap,
);
ref.onDispose(wrapper.dispose);
return wrapper.stream.map((result) => result.data);
});
final ticketMessagesAllProvider = StreamProvider<List<TicketMessage>>((ref) {
final client = ref.watch(supabaseClientProvider);
return client
.from('ticket_messages')
.stream(primaryKey: ['id'])
.order('created_at', ascending: false)
.map((rows) => rows.map(TicketMessage.fromMap).toList());
final wrapper = StreamRecoveryWrapper<TicketMessage>(
stream: client
.from('ticket_messages')
.stream(primaryKey: ['id'])
.order('created_at', ascending: false),
onPollData: () async {
final data = await client
.from('ticket_messages')
.select()
.order('created_at', ascending: false);
return data.map(TicketMessage.fromMap).toList();
},
fromMap: TicketMessage.fromMap,
);
ref.onDispose(wrapper.dispose);
return wrapper.stream.map((result) => result.data);
});
final taskMessagesProvider = StreamProvider.family<List<TicketMessage>, String>(
(ref, taskId) {
final client = ref.watch(supabaseClientProvider);
return client
.from('ticket_messages')
.stream(primaryKey: ['id'])
.eq('task_id', taskId)
.order('created_at', ascending: false)
.map((rows) => rows.map(TicketMessage.fromMap).toList());
final wrapper = StreamRecoveryWrapper<TicketMessage>(
stream: client
.from('ticket_messages')
.stream(primaryKey: ['id'])
.eq('task_id', taskId)
.order('created_at', ascending: false),
onPollData: () async {
final data = await client
.from('ticket_messages')
.select()
.eq('task_id', taskId)
.order('created_at', ascending: false);
return data.map(TicketMessage.fromMap).toList();
},
fromMap: TicketMessage.fromMap,
);
ref.onDispose(wrapper.dispose);
return wrapper.stream.map((result) => result.data);
},
);