feat: 重构 Reminder Notification 系统并更新应用包名
This commit is contained in:
@@ -0,0 +1,313 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../../features/messages/data/apis/inbox_api.dart' show InboxApi;
|
||||
import '../../features/messages/data/models/inbox_message.dart';
|
||||
import '../../features/messages/data/repositories/inbox_repository.dart';
|
||||
|
||||
class InboxSyncStore extends ChangeNotifier {
|
||||
InboxSyncStore({
|
||||
required InboxRepository repository,
|
||||
required InboxApi inboxApi,
|
||||
}) : _repository = repository,
|
||||
_inboxApi = inboxApi;
|
||||
|
||||
final InboxRepository _repository;
|
||||
final InboxApi _inboxApi;
|
||||
|
||||
final Map<String, InboxMessage> _messagesById = <String, InboxMessage>{};
|
||||
final Map<String, int> _messageVersionById = <String, int>{};
|
||||
|
||||
StreamSubscription<String>? _sseSubscription;
|
||||
bool _started = false;
|
||||
bool _disposed = false;
|
||||
String? _activeUserId;
|
||||
String? _lastEventId;
|
||||
Object? _lastStreamError;
|
||||
|
||||
Object? get lastStreamError => _lastStreamError;
|
||||
|
||||
int get unreadCount => _messagesById.values.where((m) => !m.isRead).length;
|
||||
|
||||
List<InboxMessage> get unreadMessages {
|
||||
final list = _messagesById.values.where((m) => !m.isRead).toList();
|
||||
list.sort((a, b) => b.createdAt.compareTo(a.createdAt));
|
||||
return list;
|
||||
}
|
||||
|
||||
List<InboxMessage> get readMessages {
|
||||
final list = _messagesById.values.where((m) => m.isRead).toList();
|
||||
list.sort((a, b) => b.createdAt.compareTo(a.createdAt));
|
||||
return list;
|
||||
}
|
||||
|
||||
Future<void> ensureStarted() async {
|
||||
if (_activeUserId == null) {
|
||||
return;
|
||||
}
|
||||
if (_started) {
|
||||
return;
|
||||
}
|
||||
_started = true;
|
||||
await refreshSnapshot();
|
||||
unawaited(_streamLoop());
|
||||
}
|
||||
|
||||
Future<void> refreshSnapshot() async {
|
||||
final result = await Future.wait([
|
||||
_repository.getMessages(isRead: false, forceRefresh: true),
|
||||
_repository.getMessages(isRead: true, forceRefresh: true),
|
||||
]);
|
||||
final merged = <InboxMessage>[...result[0], ...result[1]];
|
||||
_messagesById
|
||||
..clear()
|
||||
..addEntries(merged.map((m) => MapEntry(m.id, m)));
|
||||
_messageVersionById
|
||||
..clear()
|
||||
..addEntries(
|
||||
merged.map((m) => MapEntry(m.id, m.createdAt.millisecondsSinceEpoch)),
|
||||
);
|
||||
_notifyIfActive();
|
||||
}
|
||||
|
||||
Future<void> stop() async {
|
||||
_started = false;
|
||||
final sub = _sseSubscription;
|
||||
_sseSubscription = null;
|
||||
await sub?.cancel();
|
||||
}
|
||||
|
||||
Future<void> resetForUser(String? userId) async {
|
||||
await stop();
|
||||
_activeUserId = userId;
|
||||
_lastEventId = null;
|
||||
_lastStreamError = null;
|
||||
_messagesById.clear();
|
||||
_messageVersionById.clear();
|
||||
if (userId != null) {
|
||||
await ensureStarted();
|
||||
}
|
||||
_notifyIfActive();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_disposed = true;
|
||||
unawaited(stop());
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _streamLoop() async {
|
||||
var retry = 0;
|
||||
while (_started && !_disposed) {
|
||||
try {
|
||||
final lines = await _inboxApi.streamEvents(lastEventId: _lastEventId);
|
||||
await _consumeLines(lines);
|
||||
retry = 0;
|
||||
if (_lastStreamError != null) {
|
||||
_lastStreamError = null;
|
||||
_notifyIfActive();
|
||||
}
|
||||
} catch (error) {
|
||||
retry += 1;
|
||||
_lastStreamError = error;
|
||||
_notifyIfActive();
|
||||
}
|
||||
if (!_started || _disposed) {
|
||||
break;
|
||||
}
|
||||
final waitMs = (retry * 300).clamp(300, 5000);
|
||||
await Future<void>.delayed(Duration(milliseconds: waitMs));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _consumeLines(Stream<String> lines) async {
|
||||
final completer = Completer<void>();
|
||||
String? eventId;
|
||||
String? eventType;
|
||||
final dataBuffer = StringBuffer();
|
||||
|
||||
void flushFrame() {
|
||||
if (dataBuffer.isEmpty) {
|
||||
eventId = null;
|
||||
eventType = null;
|
||||
return;
|
||||
}
|
||||
final raw = dataBuffer.toString();
|
||||
dataBuffer.clear();
|
||||
if (eventId != null && eventId!.isNotEmpty) {
|
||||
_lastEventId = eventId;
|
||||
}
|
||||
if (eventType == null || eventType == 'INBOX_MESSAGE') {
|
||||
eventId = null;
|
||||
eventType = null;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
final jsonValue = jsonDecode(raw);
|
||||
if (jsonValue is Map<String, dynamic>) {
|
||||
_applyEnvelope(jsonValue);
|
||||
}
|
||||
} catch (error) {
|
||||
_lastStreamError = StateError(
|
||||
'Failed to parse inbox SSE frame: $error',
|
||||
);
|
||||
_notifyIfActive();
|
||||
}
|
||||
eventId = null;
|
||||
eventType = null;
|
||||
}
|
||||
|
||||
late final StreamSubscription<String> subscription;
|
||||
subscription = lines.listen(
|
||||
(line) {
|
||||
if (!_started || _disposed) {
|
||||
if (!completer.isCompleted) {
|
||||
completer.complete();
|
||||
}
|
||||
unawaited(subscription.cancel());
|
||||
return;
|
||||
}
|
||||
if (line.isEmpty) {
|
||||
flushFrame();
|
||||
return;
|
||||
}
|
||||
if (line.startsWith(':')) {
|
||||
return;
|
||||
}
|
||||
if (line.startsWith('id:')) {
|
||||
eventId = line.substring(3).trim();
|
||||
return;
|
||||
}
|
||||
if (line.startsWith('event:')) {
|
||||
eventType = line.substring(6).trim();
|
||||
return;
|
||||
}
|
||||
if (line.startsWith('data:')) {
|
||||
final fragment = line.substring(5).trim();
|
||||
if (dataBuffer.isNotEmpty) {
|
||||
dataBuffer.write('\n');
|
||||
}
|
||||
dataBuffer.write(fragment);
|
||||
}
|
||||
},
|
||||
onError: (Object error, StackTrace stackTrace) {
|
||||
_lastStreamError = error;
|
||||
_notifyIfActive();
|
||||
if (!completer.isCompleted) {
|
||||
completer.completeError(error, stackTrace);
|
||||
}
|
||||
},
|
||||
onDone: () {
|
||||
flushFrame();
|
||||
if (!completer.isCompleted) {
|
||||
completer.complete();
|
||||
}
|
||||
},
|
||||
cancelOnError: false,
|
||||
);
|
||||
|
||||
_sseSubscription = subscription;
|
||||
await completer.future;
|
||||
if (identical(_sseSubscription, subscription)) {
|
||||
_sseSubscription = null;
|
||||
}
|
||||
}
|
||||
|
||||
void _applyEnvelope(Map<String, dynamic> envelope) {
|
||||
final messageId = envelope['message_id'];
|
||||
final op = envelope['op'];
|
||||
final version = envelope['version'];
|
||||
if (messageId is! String || op is! String || version is! int) {
|
||||
return;
|
||||
}
|
||||
final currentVersion = _messageVersionById[messageId];
|
||||
if (currentVersion != null && version <= currentVersion) {
|
||||
return;
|
||||
}
|
||||
|
||||
final data = envelope['data'];
|
||||
if (op == 'snapshot_required') {
|
||||
_messageVersionById[messageId] = version;
|
||||
unawaited(refreshSnapshot());
|
||||
return;
|
||||
}
|
||||
if (data is! Map<String, dynamic>) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (op) {
|
||||
case 'created':
|
||||
final messageRaw = data['message'];
|
||||
if (messageRaw is! Map<String, dynamic>) {
|
||||
return;
|
||||
}
|
||||
final message = InboxMessage.fromJson(messageRaw);
|
||||
_messagesById[message.id] = message;
|
||||
case 'read_changed':
|
||||
final existing = _messagesById[messageId];
|
||||
final isRead = data['is_read'];
|
||||
if (existing == null || isRead is! bool) {
|
||||
return;
|
||||
}
|
||||
_messagesById[messageId] = InboxMessage(
|
||||
id: existing.id,
|
||||
recipientId: existing.recipientId,
|
||||
senderId: existing.senderId,
|
||||
messageType: existing.messageType,
|
||||
scheduleItemId: existing.scheduleItemId,
|
||||
friendshipId: existing.friendshipId,
|
||||
content: existing.content,
|
||||
isRead: isRead,
|
||||
status: existing.status,
|
||||
createdAt: existing.createdAt,
|
||||
);
|
||||
case 'status_changed':
|
||||
final existing = _messagesById[messageId];
|
||||
final status = data['status'];
|
||||
if (existing == null || status is! String) {
|
||||
return;
|
||||
}
|
||||
_messagesById[messageId] = InboxMessage(
|
||||
id: existing.id,
|
||||
recipientId: existing.recipientId,
|
||||
senderId: existing.senderId,
|
||||
messageType: existing.messageType,
|
||||
scheduleItemId: existing.scheduleItemId,
|
||||
friendshipId: existing.friendshipId,
|
||||
content: existing.content,
|
||||
isRead: existing.isRead,
|
||||
status: _statusFromApi(status),
|
||||
createdAt: existing.createdAt,
|
||||
);
|
||||
default:
|
||||
return;
|
||||
}
|
||||
_messageVersionById[messageId] = version;
|
||||
_notifyIfActive();
|
||||
}
|
||||
|
||||
InboxMessageStatus _statusFromApi(String raw) {
|
||||
switch (raw) {
|
||||
case 'pending':
|
||||
return InboxMessageStatus.pending;
|
||||
case 'accepted':
|
||||
return InboxMessageStatus.accepted;
|
||||
case 'rejected':
|
||||
return InboxMessageStatus.rejected;
|
||||
case 'dismissed':
|
||||
return InboxMessageStatus.dismissed;
|
||||
default:
|
||||
throw StateError('Unsupported inbox message status: $raw');
|
||||
}
|
||||
}
|
||||
|
||||
void _notifyIfActive() {
|
||||
if (_disposed) {
|
||||
return;
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user