Files
tasq/lib/providers/chat_provider.dart
T
2026-04-27 06:20:39 +08:00

188 lines
6.0 KiB
Dart

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/chat_message.dart';
import '../utils/app_time.dart';
import '../utils/snackbar.dart' show isOfflineSaveError;
import 'connectivity_provider.dart';
import 'supabase_provider.dart';
import 'stream_recovery.dart';
import 'realtime_controller.dart';
// ---------------------------------------------------------------------------
// Offline-pending state
// ---------------------------------------------------------------------------
/// Pending chat messages keyed by threadId.
/// Messages sync on reconnect when the thread's provider is active.
final offlinePendingChatMessagesProvider =
StateProvider<Map<String, List<ChatMessage>>>((ref) => const {});
// ---------------------------------------------------------------------------
// Read provider
// ---------------------------------------------------------------------------
/// Real-time chat messages for a swap request thread.
final chatMessagesProvider = StreamProvider.family<List<ChatMessage>, String>((
ref,
threadId,
) {
final client = ref.watch(supabaseClientProvider);
final pendingByThread = ref.watch(offlinePendingChatMessagesProvider);
final pendingForThread = pendingByThread[threadId] ?? const [];
List<ChatMessage> applyPending(List<ChatMessage> rows) {
if (pendingForThread.isEmpty) return rows;
final byId = {for (final r in rows) r.id: r};
for (final p in pendingForThread) {
byId.putIfAbsent(p.id, () => p);
}
return byId.values.toList()
..sort((a, b) => a.createdAt.compareTo(b.createdAt));
}
final wrapper = StreamRecoveryWrapper<ChatMessage>(
stream: client
.from('chat_messages')
.stream(primaryKey: ['id'])
.eq('thread_id', threadId)
.order('created_at'),
onPollData: () async {
final data = await client
.from('chat_messages')
.select()
.eq('thread_id', threadId)
.order('created_at');
return data.map(ChatMessage.fromMap).toList();
},
fromMap: ChatMessage.fromMap,
channelName: 'chat_messages_$threadId',
onStatusChanged: ref.read(realtimeControllerProvider).handleChannelStatus,
onOfflineData: () async {
final all = await cachedListFromBrick<ChatMessage>();
final filtered = all
.where((m) => m.threadId == threadId)
.toList()
..sort((a, b) => a.createdAt.compareTo(b.createdAt));
return applyPending(filtered);
},
onCacheMirror: (rows) =>
mirrorBatchToBrick<ChatMessage>(rows, tag: 'chat_messages'),
);
ref.onDispose(wrapper.dispose);
// Reconnect-replay: fires when this thread's screen is active.
ref.listen<bool>(isOnlineProvider, (wasOnline, isNowOnline) async {
if (wasOnline == true || !isNowOnline) return;
final pending = List<ChatMessage>.from(
(ref.read(offlinePendingChatMessagesProvider)[threadId] ?? []),
);
if (pending.isEmpty) return;
debugPrint(
'[chatMessagesProvider($threadId)] reconnected — syncing ${pending.length} message(s)',
);
final synced = <String>[];
for (final msg in pending) {
try {
await client.from('chat_messages').insert({
'id': msg.id,
'thread_id': msg.threadId,
'sender_id': msg.senderId,
'body': msg.body,
});
synced.add(msg.id);
} catch (e) {
final s = e.toString();
if (s.contains('23505') || s.contains('duplicate key')) {
synced.add(msg.id);
} else {
debugPrint(
'[chatMessagesProvider] failed to sync message id=${msg.id}: $e',
);
}
}
}
if (synced.isNotEmpty) {
final current = Map<String, List<ChatMessage>>.from(
ref.read(offlinePendingChatMessagesProvider),
);
final remaining =
(current[threadId] ?? []).where((m) => !synced.contains(m.id)).toList();
if (remaining.isEmpty) {
current.remove(threadId);
} else {
current[threadId] = remaining;
}
ref.read(offlinePendingChatMessagesProvider.notifier).state =
Map.unmodifiable(current);
}
});
return wrapper.stream.map((result) => applyPending(result.data));
});
// ---------------------------------------------------------------------------
// Controller
// ---------------------------------------------------------------------------
final chatControllerProvider = Provider<ChatController>((ref) {
final client = ref.watch(supabaseClientProvider);
return ChatController(client, ref);
});
class ChatController {
ChatController(this._client, [this._ref]);
final SupabaseClient _client;
final Ref? _ref;
/// Send a message to a thread. Offline: queues locally.
Future<void> sendMessage({
required String threadId,
required String body,
}) async {
final userId = _client.auth.currentUser?.id;
if (userId == null) throw Exception('Not authenticated');
final id = const Uuid().v4();
try {
await _client.from('chat_messages').insert({
'id': id,
'thread_id': threadId,
'sender_id': userId,
'body': body,
});
} catch (e) {
if (!isOfflineSaveError(e)) rethrow;
final message = ChatMessage(
id: id,
threadId: threadId,
senderId: userId,
body: body,
createdAt: AppTime.now(),
);
final ref = _ref;
if (ref != null) {
final current = Map<String, List<ChatMessage>>.from(
ref.read(offlinePendingChatMessagesProvider),
);
current[threadId] = [...(current[threadId] ?? []), message];
ref.read(offlinePendingChatMessagesProvider.notifier).state =
Map.unmodifiable(current);
}
mirrorBatchToBrick<ChatMessage>([message], tag: 'offline_chat');
debugPrint('[ChatController] sendMessage queued offline: $id');
}
}
}