feat: 重构会话管理与提醒通知系统

This commit is contained in:
qzl
2026-03-31 18:26:36 +08:00
parent a8c262e9c7
commit 9a231dae9e
31 changed files with 650 additions and 223 deletions
+35 -21
View File
@@ -13,6 +13,7 @@ import '../features/auth/presentation/bloc/auth_state.dart';
import '../features/chat/presentation/bloc/chat_bloc.dart';
import '../data/cache/cache_scope.dart';
import 'services/app_prewarm_orchestrator.dart';
import 'services/session_scope_manager.dart';
import 'router/app_router.dart';
import '../core/theme/app_theme.dart';
import '../core/inbox/inbox_sync_store.dart';
@@ -33,21 +34,16 @@ class _LinksyAppState extends State<LinksyApp> {
late final GoRouter _router;
StreamSubscription<ReminderNotificationTap>? _reminderTapSubscription;
String? _pendingReminderRoute;
int _cacheScopeVersion = 0;
Future<void> _authTransitionQueue = Future<void>.value();
Future<void> _onAuthenticated(String userId) async {
_cacheScopeVersion += 1;
final scopeKey = 'user:$userId:v$_cacheScopeVersion';
CacheScope.configureProvider(() => scopeKey);
await sl<SessionScopeManager>().activate(userId);
await sl<InboxSyncStore>().resetForUser(userId);
await sl<ChatBloc>().switchUser(userId);
await sl<AppPrewarmOrchestrator>().ensureStartedFor(userId);
}
Future<void> _onUnauthenticated() async {
_cacheScopeVersion += 1;
final scopeKey = 'anonymous:v$_cacheScopeVersion';
CacheScope.configureProvider(() => scopeKey);
await sl<SessionScopeManager>().clearActiveUserScope();
await sl<InboxSyncStore>().resetForUser(null);
await sl<ChatBloc>().switchUser(null);
sl<AppPrewarmOrchestrator>().reset();
@@ -57,8 +53,7 @@ class _LinksyAppState extends State<LinksyApp> {
void initState() {
super.initState();
_authBloc = sl<AuthBloc>();
const initialScopeKey = 'anonymous:v0';
CacheScope.configureProvider(() => initialScopeKey);
CacheScope.resetProvider();
_authBloc.add(AuthStarted());
_router = createAppRouter(_authBloc);
SchedulerBinding.instance.addPostFrameCallback((_) {
@@ -75,11 +70,18 @@ class _LinksyAppState extends State<LinksyApp> {
void _onReminderTap(ReminderNotificationTap tap) {
final route = AppRoutes.calendarReminderAlarm(tap.eventId);
if (_authBloc.state is AuthAuthenticated) {
_router.go(route);
return;
}
_pendingReminderRoute = route;
_enqueueAuthTransition(() async {
if (_authBloc.state is! AuthAuthenticated) {
return;
}
final pendingRoute = _pendingReminderRoute;
if (pendingRoute == null) {
return;
}
_pendingReminderRoute = null;
_router.go(pendingRoute);
});
}
@override
@@ -89,6 +91,23 @@ class _LinksyAppState extends State<LinksyApp> {
super.dispose();
}
void _enqueueAuthTransition(Future<void> Function() transition) {
_authTransitionQueue = _authTransitionQueue
.catchError((Object error, StackTrace stackTrace) {
FlutterError.reportError(
FlutterErrorDetails(exception: error, stack: stackTrace),
);
Zone.current.handleUncaughtError(error, stackTrace);
})
.then((_) => transition())
.catchError((Object error, StackTrace stackTrace) {
FlutterError.reportError(
FlutterErrorDetails(exception: error, stack: stackTrace),
);
Zone.current.handleUncaughtError(error, stackTrace);
});
}
@override
Widget build(BuildContext context) {
return BlocProvider<AuthBloc>.value(
@@ -96,15 +115,10 @@ class _LinksyAppState extends State<LinksyApp> {
child: BlocListener<AuthBloc, AuthState>(
listener: (context, state) {
if (state is AuthAuthenticated) {
unawaited(_onAuthenticated(state.user.id));
final pendingRoute = _pendingReminderRoute;
if (pendingRoute != null) {
_pendingReminderRoute = null;
_router.go(pendingRoute);
}
_enqueueAuthTransition(() => _onAuthenticated(state.user.id));
}
if (state is AuthUnauthenticated) {
unawaited(_onUnauthenticated());
_enqueueAuthTransition(_onUnauthenticated);
}
},
child: MaterialApp.router(
+4
View File
@@ -36,6 +36,7 @@ import '../../features/contacts/data/apis/users_api.dart';
import '../../features/todo/data/apis/todo_api.dart';
import '../../features/todo/data/repositories/todo_repository.dart';
import '../services/app_prewarm_orchestrator.dart';
import '../services/session_scope_manager.dart';
import '../services/auth_session_controller.dart';
import '../../core/notification/services/reminder_scheduler_service.dart';
import '../../core/notification/services/reminder_permission_service.dart';
@@ -83,6 +84,9 @@ Future<void> configureDependencies() async {
sl.registerSingleton<MemoryCacheStore>(memoryCacheStore);
sl.registerSingleton<PersistentCacheStore>(persistentCacheStore);
sl.registerSingleton<HybridCacheStore>(hybridCacheStore);
sl.registerSingleton<SessionScopeManager>(
SessionScopeManager(cacheStore: hybridCacheStore),
);
sl.registerSingleton<CacheInvalidator>(
CacheInvalidator(store: hybridCacheStore),
);
+4 -3
View File
@@ -91,10 +91,11 @@ String? resolveAuthRedirect({
return null;
}
Widget buildHomeRouteScreen() {
Widget buildHomeRouteScreen(AuthState authState) {
final userId = authState is AuthAuthenticated ? authState.user.id : null;
return BlocProvider<ChatBloc>.value(
value: sl<ChatBloc>(),
child: const HomeScreen(),
child: HomeScreen(initialUserId: userId),
);
}
@@ -149,7 +150,7 @@ GoRouter createAppRouter(AuthBloc authBloc) {
),
GoRoute(
path: AppRoutes.homeMain,
builder: (context, state) => buildHomeRouteScreen(),
builder: (context, state) => buildHomeRouteScreen(authBloc.state),
),
GoRoute(
path: AppRoutes.messageInviteList,
@@ -0,0 +1,42 @@
import '../../data/cache/cache_scope.dart';
import '../../data/cache/cache_store.dart';
class SessionScopeManager {
SessionScopeManager({required HybridCacheStore cacheStore})
: _cacheStore = cacheStore;
final HybridCacheStore _cacheStore;
String? _activeUserId;
Future<void> activate(String userId) async {
final normalizedUserId = userId.trim();
if (normalizedUserId.isEmpty) {
throw StateError('User id cannot be empty when activating cache scope');
}
final previousUserId = _activeUserId;
if (previousUserId == normalizedUserId) {
return;
}
_activeUserId = normalizedUserId;
CacheScope.configureProvider(() => 'user:$normalizedUserId');
if (previousUserId != null && previousUserId != normalizedUserId) {
await _clearUserCache(previousUserId);
}
}
Future<void> clearActiveUserScope() async {
final userId = _activeUserId;
_activeUserId = null;
CacheScope.resetProvider();
if (userId != null) {
await _clearUserCache(userId);
}
}
Future<void> _clearUserCache(String userId) {
return _cacheStore.clearByPrefix('cache:user:$userId:');
}
}
@@ -12,10 +12,12 @@ class ReminderSchedulerService {
ReminderSchedulerService({FlutterLocalNotificationsPlugin? plugin})
: _plugin = plugin ?? FlutterLocalNotificationsPlugin();
static const String _channelId = 'calendar_reminder_alarm_v2';
static const String _channelId = 'calendar_reminder_alarm_v3';
static const String _channelName = 'Schedule alarm';
static const String _channelDescription =
'Alarm-style notifications for scheduled events';
static const String _androidSoundResource = 'reminder_1';
static const String _iosSoundFile = 'reminder_1.wav';
final FlutterLocalNotificationsPlugin _plugin;
final List<void Function(ReminderNotificationTap tap)> _tapCallbacks = [];
@@ -45,6 +47,8 @@ class ReminderSchedulerService {
_channelName,
description: _channelDescription,
importance: Importance.max,
playSound: true,
sound: RawResourceAndroidNotificationSound(_androidSoundResource),
),
);
@@ -120,9 +124,18 @@ class ReminderSchedulerService {
DateTime? now,
}) async {
await _ensureInitialized();
await cancelEventReminders(event.eventId);
final alarms = buildAlarmsForEvent(event, now: now);
for (final alarm in alarms) {
final pending = await _plugin.pendingNotificationRequests();
final plan = buildUpsertPlan(
eventId: event.eventId,
pending: pending,
desired: alarms,
);
for (final id in plan.idsToCancel) {
await _plugin.cancel(id);
}
for (final alarm in plan.alarmsToSchedule) {
await _scheduleAlarm(alarm);
}
}
@@ -252,6 +265,7 @@ class ReminderSchedulerService {
category: AndroidNotificationCategory.alarm,
timeoutAfter: 15000,
playSound: true,
sound: RawResourceAndroidNotificationSound(_androidSoundResource),
enableVibration: true,
fullScreenIntent: false,
ticker: 'calendar-reminder',
@@ -259,6 +273,7 @@ class ReminderSchedulerService {
const iosDetails = DarwinNotificationDetails(
presentAlert: true,
presentSound: true,
sound: _iosSoundFile,
interruptionLevel: InterruptionLevel.timeSensitive,
);
@@ -344,6 +359,77 @@ class ReminderSchedulerService {
return hash;
}
static int notificationIdFor(String eventId, int fireTimeBucket) {
return _notificationId(eventId, fireTimeBucket);
}
static ReminderUpsertPlan buildUpsertPlan({
required String eventId,
required List<PendingNotificationRequest> pending,
required List<ReminderAlarm> desired,
}) {
final desiredById = <int, ReminderAlarm>{
for (final alarm in desired)
_notificationId(alarm.eventId, alarm.fireTimeBucket): alarm,
};
final existingPayloadById = <int, Map<String, dynamic>>{};
for (final request in pending) {
final raw = request.payload;
if (raw == null || raw.isEmpty) {
continue;
}
final payload = _parsePayload(raw);
if (payload['eventId'] != eventId) {
continue;
}
existingPayloadById[request.id] = payload;
}
final idsToCancel = <int>[];
for (final entry in existingPayloadById.entries) {
final desiredAlarm = desiredById[entry.key];
if (desiredAlarm == null) {
idsToCancel.add(entry.key);
continue;
}
if (!_matchesAlarmPayload(entry.value, desiredAlarm)) {
idsToCancel.add(entry.key);
}
}
final alarmsToSchedule = <ReminderAlarm>[];
for (final entry in desiredById.entries) {
final existingPayload = existingPayloadById[entry.key];
if (existingPayload == null ||
!_matchesAlarmPayload(existingPayload, entry.value)) {
alarmsToSchedule.add(entry.value);
}
}
return ReminderUpsertPlan(
idsToCancel: idsToCancel,
alarmsToSchedule: alarmsToSchedule,
);
}
static bool _matchesAlarmPayload(
Map<String, dynamic> payload,
ReminderAlarm alarm,
) {
return payload['eventId'] == alarm.eventId &&
payload['title'] == alarm.title &&
payload['startAt'] == alarm.startAt.toIso8601String() &&
payload['endAt'] == alarm.endAt?.toIso8601String() &&
payload['timezone'] == alarm.timezone &&
payload['reminderMinutes'] == alarm.reminderMinutes &&
payload['fireAt'] == alarm.fireAt.toIso8601String() &&
payload['fireTimeBucket'] == alarm.fireTimeBucket &&
payload['location'] == alarm.location &&
payload['notes'] == alarm.notes &&
payload['version'] == alarm.version;
}
static tz.Location _safeLocation(String timezone) {
try {
return tz.getLocation(timezone);
@@ -353,6 +439,16 @@ class ReminderSchedulerService {
}
}
class ReminderUpsertPlan {
const ReminderUpsertPlan({
required this.idsToCancel,
required this.alarmsToSchedule,
});
final List<int> idsToCancel;
final List<ReminderAlarm> alarmsToSchedule;
}
@pragma('vm:entry-point')
void _onBackgroundTap(NotificationResponse response) {
debugPrint('Background reminder tap received: ${response.payload}');
+9 -1
View File
@@ -14,9 +14,17 @@ class CacheScope {
}
static String token() {
final value = maybeToken();
if (value == null) {
throw StateError('CacheScope not configured - user must be logged in');
}
return value;
}
static String? maybeToken() {
final raw = _provider?.call()?.trim();
if (raw == null || raw.isEmpty) {
return 'anonymous';
return null;
}
return raw;
}
+3 -2
View File
@@ -222,8 +222,9 @@ class CacheInvalidator {
void invalidate(String key) {
final store = _store;
if (store != null) {
unawaited(store.remove(CacheScope.scopedKey(key)));
final scope = CacheScope.maybeToken();
if (store != null && scope != null) {
unawaited(store.remove(CacheScope.scopedKey(key, scopeToken: scope)));
}
}
+29 -4
View File
@@ -32,7 +32,10 @@ abstract class CachedRepository<T> {
bool Function(T loaded)? shouldWriteLoaded,
bool forceRefresh = false,
}) async {
final scopeToken = CacheScope.token();
final scopeToken = CacheScope.maybeToken();
if (scopeToken == null) {
return loadFromRemote();
}
final scopedKey = CacheScope.scopedKey(key, scopeToken: scopeToken);
if (forceRefresh) {
@@ -71,12 +74,22 @@ abstract class CachedRepository<T> {
}
Future<CacheEntry<T>?> readCacheEntry(String key, {String? scopeToken}) {
return _readDecodedEntry(CacheScope.scopedKey(key, scopeToken: scopeToken));
final resolvedScope = _resolveScopeToken(scopeToken);
if (resolvedScope == null) {
return Future<CacheEntry<T>?>.value(null);
}
return _readDecodedEntry(
CacheScope.scopedKey(key, scopeToken: resolvedScope),
);
}
Future<void> writeCacheEntry(String key, T value, {String? scopeToken}) {
final resolvedScope = _resolveScopeToken(scopeToken);
if (resolvedScope == null) {
return Future<void>.value();
}
return store.write<CacheEntry<Object?>>(
_scopedKey(key, scopeToken: scopeToken),
_scopedKey(key, scopeToken: resolvedScope),
CacheEntry<Object?>(value: encodeValue(value), fetchedAt: now()),
);
}
@@ -98,7 +111,11 @@ abstract class CachedRepository<T> {
}
Future<void> removeCacheKey(String key, {String? scopeToken}) {
return store.remove(CacheScope.scopedKey(key, scopeToken: scopeToken));
final resolvedScope = _resolveScopeToken(scopeToken);
if (resolvedScope == null) {
return Future<void>.value();
}
return store.remove(CacheScope.scopedKey(key, scopeToken: resolvedScope));
}
void refreshInBackground({
@@ -140,4 +157,12 @@ abstract class CachedRepository<T> {
String _scopedKey(String key, {String? scopeToken}) {
return CacheScope.scopedKey(key, scopeToken: scopeToken);
}
String? _resolveScopeToken(String? scopeToken) {
final scoped = scopeToken?.trim();
if (scoped != null && scoped.isNotEmpty) {
return scoped;
}
return CacheScope.maybeToken();
}
}
@@ -52,6 +52,7 @@ class HomeScreen extends StatefulWidget {
final VoiceRecorder? voiceRecorder;
final Future<String> Function(String filePath)? onTranscribeAudio;
final ChatBloc? chatBloc;
final String? initialUserId;
final bool autoLoadHistory;
final List<XFile> initialSelectedImages;
@@ -60,6 +61,7 @@ class HomeScreen extends StatefulWidget {
this.voiceRecorder,
this.onTranscribeAudio,
this.chatBloc,
this.initialUserId,
this.autoLoadHistory = true,
this.initialSelectedImages = const [],
});
@@ -119,7 +121,10 @@ class _HomeScreenState extends State<HomeScreen>
duration: const Duration(milliseconds: _rippleDurationMs),
);
_selectedImages.addAll(widget.initialSelectedImages);
if (widget.autoLoadHistory &&
final initialUserId = widget.initialUserId?.trim();
if (initialUserId != null && initialUserId.isNotEmpty) {
unawaited(_chatBloc.switchUser(initialUserId));
} else if (widget.autoLoadHistory &&
_chatBloc.state.items.isEmpty &&
!_chatBloc.state.isLoadingHistory) {
_chatBloc.loadHistory();
@@ -166,6 +171,20 @@ class _HomeScreenState extends State<HomeScreen>
}
}
@override
void didUpdateWidget(covariant HomeScreen oldWidget) {
super.didUpdateWidget(oldWidget);
final oldUserId = oldWidget.initialUserId?.trim();
final newUserId = widget.initialUserId?.trim();
if (oldUserId == newUserId) {
return;
}
final normalized = (newUserId != null && newUserId.isNotEmpty)
? newUserId
: null;
unawaited(_chatBloc.switchUser(normalized));
}
@override
void didPopNext() {
unawaited(_inboxSyncStore.refreshSnapshot());
@@ -42,14 +42,23 @@ class _TodoQuadrantsScreenState extends State<TodoQuadrantsScreen> {
int newItemIndex,
int newListIndex,
) async {
print(
'DEBUG _onItemReorder: oldItemIndex=$oldItemIndex, oldListIndex=$oldListIndex, newItemIndex=$newItemIndex, newListIndex=$newListIndex',
);
if (_isReordering) {
print('DEBUG _onItemReorder: early return - _isReordering=true');
return;
}
final sourceQuadrant = _quadrantByListIndex(oldListIndex);
final targetQuadrant = _quadrantByListIndex(newListIndex);
print(
'DEBUG _onItemReorder: sourceQuadrant=$sourceQuadrant, targetQuadrant=$targetQuadrant',
);
final sourceItems = _sortedQuadrantTodos(sourceQuadrant);
print('DEBUG _onItemReorder: sourceItems.length=${sourceItems.length}');
if (oldItemIndex < 0 || oldItemIndex >= sourceItems.length) {
print('DEBUG _onItemReorder: early return - index out of bounds');
return;
}
@@ -66,7 +75,9 @@ class _TodoQuadrantsScreenState extends State<TodoQuadrantsScreen> {
targetQuadrant: targetQuadrant,
insertIndex: newItemIndex,
);
print('DEBUG _onItemReorder: reordered=$reordered');
if (reordered == null) {
print('DEBUG _onItemReorder: early return - reordered is null');
return;
}
@@ -117,19 +128,27 @@ class _TodoQuadrantsScreenState extends State<TodoQuadrantsScreen> {
required int targetQuadrant,
required int insertIndex,
}) {
print(
'DEBUG _reorderTodos: todoId=$todoId, sourceQuadrant=$sourceQuadrant, targetQuadrant=$targetQuadrant, insertIndex=$insertIndex',
);
final byId = {for (final todo in _todos) todo.id: todo};
final moving = byId[todoId];
print('DEBUG _reorderTodos: moving=$moving');
if (moving == null) {
print('DEBUG _reorderTodos: early return - moving is null');
return null;
}
final sourceList = _sortedQuadrantTodos(sourceQuadrant);
print('DEBUG _reorderTodos: sourceList.length=${sourceList.length}');
final targetList = sourceQuadrant == targetQuadrant
? sourceList
: _sortedQuadrantTodos(targetQuadrant);
final sourceIndex = sourceList.indexWhere((todo) => todo.id == todoId);
print('DEBUG _reorderTodos: sourceIndex=$sourceIndex');
if (sourceIndex == -1) {
print('DEBUG _reorderTodos: early return - sourceIndex is -1');
return null;
}
@@ -154,16 +173,33 @@ class _TodoQuadrantsScreenState extends State<TodoQuadrantsScreen> {
}
final moved = extracted.copyWith(priority: targetQuadrant);
print(
'DEBUG _reorderTodos: moved.priority=${moved.priority}, moved.order=${moved.order}',
);
mutableTarget.insert(targetIndex, moved);
print('DEBUG _reorderTodos: mutableTarget.length=${mutableTarget.length}');
for (var i = 0; i < mutableTarget.length; i++) {
print(
'DEBUG _reorderTodos: mutableTarget[$i] id=${mutableTarget[i].id}, priority=${mutableTarget[i].priority}, order=${mutableTarget[i].order}',
);
}
final updatedById = <String, TodoResponse>{};
void reindex(List<TodoResponse> list, int priority) {
print(
'DEBUG _reorderTodos: reindex called with priority=$priority, list.length=${list.length}',
);
for (var index = 0; index < list.length; index += 1) {
final current = list[index];
final updated = current.copyWith(priority: priority, order: index);
list[index] = updated;
print(
'DEBUG _reorderTodos: reindex item id=${current.id}, current.priority=${current.priority}, updated.priority=${updated.priority}, current.order=${current.order}, updated.order=${updated.order}',
);
if (current.priority != updated.priority ||
current.order != updated.order) {
print('DEBUG _reorderTodos: adding to updatedById id=${updated.id}');
updatedById[updated.id] = updated;
}
}
@@ -174,9 +210,12 @@ class _TodoQuadrantsScreenState extends State<TodoQuadrantsScreen> {
} else {
reindex(mutableSource, sourceQuadrant);
reindex(mutableTarget, targetQuadrant);
updatedById[moved.id] = moved;
}
print('DEBUG _reorderTodos: updatedById.length=${updatedById.length}');
if (updatedById.isEmpty) {
print('DEBUG _reorderTodos: returning null because updatedById is empty');
return null;
}
@@ -454,13 +493,19 @@ class _TodoQuadrantsScreenState extends State<TodoQuadrantsScreen> {
onWillAcceptWithDetails: (details) => true,
onAcceptWithDetails: (details) {
final info = details.data;
print(
'DEBUG: onAccept - sourceQuadrant=${info.sourceQuadrant}, targetQuadrant=$value, sourceIndex=${info.sourceIndex}, todoId=${info.todoId}',
);
if (info.sourceQuadrant != value) {
print('DEBUG: calling _onItemReorder');
_onItemReorder(
info.sourceIndex,
_listIndexByQuadrant(info.sourceQuadrant),
0,
_listIndexByQuadrant(value),
);
} else {
print('DEBUG: same quadrant, no reorder');
}
},
builder: (context, candidateData, rejectedData) {
+1 -1
View File
@@ -464,7 +464,7 @@
"settingsClearCacheTitle": "Clear Local Cache",
"settingsClearCacheMessage": "This will clear local cache and fetch fresh data. Continue?",
"settingsClearCacheAction": "Clear",
"settingsClearCacheSuccess": "Cache cleared. Refreshing data...",
"settingsClearCacheSuccess": "Cache cleared",
"settingsClearCacheFailed": "Failed to clear cache. Please try again later",
"settingsJobDetailTitle": "Job Detail",
"settingsJobCreatePageTitle": "Create Recurring Plan",
+1 -1
View File
@@ -2171,7 +2171,7 @@ abstract class AppLocalizations {
/// No description provided for @settingsClearCacheSuccess.
///
/// In zh, this message translates to:
/// **'缓存已清理,正在重新拉取数据'**
/// **'缓存已清理'**
String get settingsClearCacheSuccess;
/// No description provided for @settingsClearCacheFailed.
+1 -1
View File
@@ -1163,7 +1163,7 @@ class AppLocalizationsEn extends AppLocalizations {
String get settingsClearCacheAction => 'Clear';
@override
String get settingsClearCacheSuccess => 'Cache cleared. Refreshing data...';
String get settingsClearCacheSuccess => 'Cache cleared';
@override
String get settingsClearCacheFailed =>
+1 -1
View File
@@ -1130,7 +1130,7 @@ class AppLocalizationsZh extends AppLocalizations {
String get settingsClearCacheAction => '确认清理';
@override
String get settingsClearCacheSuccess => '缓存已清理,正在重新拉取数据';
String get settingsClearCacheSuccess => '缓存已清理';
@override
String get settingsClearCacheFailed => '清理缓存失败,请稍后重试';
+1 -1
View File
@@ -464,7 +464,7 @@
"settingsClearCacheTitle": "清理本地缓存",
"settingsClearCacheMessage": "将清理本地缓存并重新拉取最新数据,是否继续?",
"settingsClearCacheAction": "确认清理",
"settingsClearCacheSuccess": "缓存已清理,正在重新拉取数据",
"settingsClearCacheSuccess": "缓存已清理",
"settingsClearCacheFailed": "清理缓存失败,请稍后重试",
"settingsJobDetailTitle": "任务详情",
"settingsJobCreatePageTitle": "新建周期计划",