feat: 重构 Reminder Notification 系统并更新应用包名

This commit is contained in:
qzl
2026-03-30 18:36:57 +08:00
parent 9fb2a6857b
commit 91bf3c3f96
90 changed files with 5133 additions and 3017 deletions
+313
View File
@@ -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();
}
}