diff --git a/apps/android/app/src/main/res/raw/reminder_1.wav b/apps/android/app/src/main/res/raw/reminder_1.wav new file mode 100644 index 0000000..8f2fab5 Binary files /dev/null and b/apps/android/app/src/main/res/raw/reminder_1.wav differ diff --git a/apps/assets/reminder_sound/1.WAV b/apps/assets/reminder_sound/1.WAV new file mode 100644 index 0000000..8f2fab5 Binary files /dev/null and b/apps/assets/reminder_sound/1.WAV differ diff --git a/apps/assets/reminder_sound/2.WAV b/apps/assets/reminder_sound/2.WAV new file mode 100644 index 0000000..4599216 Binary files /dev/null and b/apps/assets/reminder_sound/2.WAV differ diff --git a/apps/ios/Runner.xcodeproj/project.pbxproj b/apps/ios/Runner.xcodeproj/project.pbxproj index 03f0535..ef66a71 100644 --- a/apps/ios/Runner.xcodeproj/project.pbxproj +++ b/apps/ios/Runner.xcodeproj/project.pbxproj @@ -15,6 +15,7 @@ 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; + D1E92316A42F4A26BE0A0E11 /* reminder_1.wav in Resources */ = {isa = PBXBuildFile; fileRef = D1E92316A42F4A26BE0A0E10 /* reminder_1.wav */; }; AF132A70E83FBE1B638C6F9F /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 90FE62ECAC858C9D6D8F555A /* Pods_RunnerTests.framework */; }; /* End PBXBuildFile section */ @@ -61,6 +62,7 @@ 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + D1E92316A42F4A26BE0A0E10 /* reminder_1.wav */ = {isa = PBXFileReference; lastKnownFileType = audio.wav; path = reminder_1.wav; sourceTree = ""; }; 990B7AE4995B1DA3D21F475A /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = ""; }; 9B0AA8E2CDF83E1EE2B9D8B0 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; DCDE481F29A6AC188DDBFB70 /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = ""; }; @@ -155,6 +157,7 @@ 97C146FA1CF9000F007C117D /* Main.storyboard */, 97C146FD1CF9000F007C117D /* Assets.xcassets */, 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */, + D1E92316A42F4A26BE0A0E10 /* reminder_1.wav */, 97C147021CF9000F007C117D /* Info.plist */, 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */, 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, @@ -259,6 +262,7 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( + D1E92316A42F4A26BE0A0E11 /* reminder_1.wav in Resources */, 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */, 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */, 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */, diff --git a/apps/ios/Runner/reminder_1.wav b/apps/ios/Runner/reminder_1.wav new file mode 100644 index 0000000..8f2fab5 Binary files /dev/null and b/apps/ios/Runner/reminder_1.wav differ diff --git a/apps/lib/app/app.dart b/apps/lib/app/app.dart index 1e5ce51..db5237c 100644 --- a/apps/lib/app/app.dart +++ b/apps/lib/app/app.dart @@ -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 { late final GoRouter _router; StreamSubscription? _reminderTapSubscription; String? _pendingReminderRoute; - int _cacheScopeVersion = 0; + Future _authTransitionQueue = Future.value(); Future _onAuthenticated(String userId) async { - _cacheScopeVersion += 1; - final scopeKey = 'user:$userId:v$_cacheScopeVersion'; - CacheScope.configureProvider(() => scopeKey); + await sl().activate(userId); await sl().resetForUser(userId); - await sl().switchUser(userId); await sl().ensureStartedFor(userId); } Future _onUnauthenticated() async { - _cacheScopeVersion += 1; - final scopeKey = 'anonymous:v$_cacheScopeVersion'; - CacheScope.configureProvider(() => scopeKey); + await sl().clearActiveUserScope(); await sl().resetForUser(null); await sl().switchUser(null); sl().reset(); @@ -57,8 +53,7 @@ class _LinksyAppState extends State { void initState() { super.initState(); _authBloc = sl(); - 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 { 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 { super.dispose(); } + void _enqueueAuthTransition(Future 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.value( @@ -96,15 +115,10 @@ class _LinksyAppState extends State { child: BlocListener( 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( diff --git a/apps/lib/app/di/injection.dart b/apps/lib/app/di/injection.dart index 3b1cb16..69ef208 100644 --- a/apps/lib/app/di/injection.dart +++ b/apps/lib/app/di/injection.dart @@ -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 configureDependencies() async { sl.registerSingleton(memoryCacheStore); sl.registerSingleton(persistentCacheStore); sl.registerSingleton(hybridCacheStore); + sl.registerSingleton( + SessionScopeManager(cacheStore: hybridCacheStore), + ); sl.registerSingleton( CacheInvalidator(store: hybridCacheStore), ); diff --git a/apps/lib/app/router/app_router.dart b/apps/lib/app/router/app_router.dart index b76f928..aead0e9 100644 --- a/apps/lib/app/router/app_router.dart +++ b/apps/lib/app/router/app_router.dart @@ -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.value( value: sl(), - 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, diff --git a/apps/lib/app/services/session_scope_manager.dart b/apps/lib/app/services/session_scope_manager.dart new file mode 100644 index 0000000..b4605ad --- /dev/null +++ b/apps/lib/app/services/session_scope_manager.dart @@ -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 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 clearActiveUserScope() async { + final userId = _activeUserId; + _activeUserId = null; + CacheScope.resetProvider(); + if (userId != null) { + await _clearUserCache(userId); + } + } + + Future _clearUserCache(String userId) { + return _cacheStore.clearByPrefix('cache:user:$userId:'); + } +} diff --git a/apps/lib/core/notification/services/reminder_scheduler_service.dart b/apps/lib/core/notification/services/reminder_scheduler_service.dart index 966ffc8..a7523c5 100644 --- a/apps/lib/core/notification/services/reminder_scheduler_service.dart +++ b/apps/lib/core/notification/services/reminder_scheduler_service.dart @@ -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 _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 pending, + required List desired, + }) { + final desiredById = { + for (final alarm in desired) + _notificationId(alarm.eventId, alarm.fireTimeBucket): alarm, + }; + + final existingPayloadById = >{}; + 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 = []; + 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 = []; + 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 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 idsToCancel; + final List alarmsToSchedule; +} + @pragma('vm:entry-point') void _onBackgroundTap(NotificationResponse response) { debugPrint('Background reminder tap received: ${response.payload}'); diff --git a/apps/lib/data/cache/cache_scope.dart b/apps/lib/data/cache/cache_scope.dart index 6547fb8..40474b9 100644 --- a/apps/lib/data/cache/cache_scope.dart +++ b/apps/lib/data/cache/cache_scope.dart @@ -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; } diff --git a/apps/lib/data/cache/cache_store.dart b/apps/lib/data/cache/cache_store.dart index 557da54..7e8da7d 100644 --- a/apps/lib/data/cache/cache_store.dart +++ b/apps/lib/data/cache/cache_store.dart @@ -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))); } } diff --git a/apps/lib/data/cache/cached_repository.dart b/apps/lib/data/cache/cached_repository.dart index 79ef4e8..6b3cdc3 100644 --- a/apps/lib/data/cache/cached_repository.dart +++ b/apps/lib/data/cache/cached_repository.dart @@ -32,7 +32,10 @@ abstract class CachedRepository { 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 { } Future?> readCacheEntry(String key, {String? scopeToken}) { - return _readDecodedEntry(CacheScope.scopedKey(key, scopeToken: scopeToken)); + final resolvedScope = _resolveScopeToken(scopeToken); + if (resolvedScope == null) { + return Future?>.value(null); + } + return _readDecodedEntry( + CacheScope.scopedKey(key, scopeToken: resolvedScope), + ); } Future writeCacheEntry(String key, T value, {String? scopeToken}) { + final resolvedScope = _resolveScopeToken(scopeToken); + if (resolvedScope == null) { + return Future.value(); + } return store.write>( - _scopedKey(key, scopeToken: scopeToken), + _scopedKey(key, scopeToken: resolvedScope), CacheEntry(value: encodeValue(value), fetchedAt: now()), ); } @@ -98,7 +111,11 @@ abstract class CachedRepository { } Future removeCacheKey(String key, {String? scopeToken}) { - return store.remove(CacheScope.scopedKey(key, scopeToken: scopeToken)); + final resolvedScope = _resolveScopeToken(scopeToken); + if (resolvedScope == null) { + return Future.value(); + } + return store.remove(CacheScope.scopedKey(key, scopeToken: resolvedScope)); } void refreshInBackground({ @@ -140,4 +157,12 @@ abstract class CachedRepository { 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(); + } } diff --git a/apps/lib/features/home/presentation/screens/home_screen.dart b/apps/lib/features/home/presentation/screens/home_screen.dart index 43ca6cb..d3fa1f0 100644 --- a/apps/lib/features/home/presentation/screens/home_screen.dart +++ b/apps/lib/features/home/presentation/screens/home_screen.dart @@ -52,6 +52,7 @@ class HomeScreen extends StatefulWidget { final VoiceRecorder? voiceRecorder; final Future Function(String filePath)? onTranscribeAudio; final ChatBloc? chatBloc; + final String? initialUserId; final bool autoLoadHistory; final List 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 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 } } + @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()); diff --git a/apps/lib/features/todo/presentation/screens/todo_quadrants_screen.dart b/apps/lib/features/todo/presentation/screens/todo_quadrants_screen.dart index 6986248..e161de9 100644 --- a/apps/lib/features/todo/presentation/screens/todo_quadrants_screen.dart +++ b/apps/lib/features/todo/presentation/screens/todo_quadrants_screen.dart @@ -42,14 +42,23 @@ class _TodoQuadrantsScreenState extends State { 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 { 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 { 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 { } 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 = {}; void reindex(List 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 { } 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 { 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) { diff --git a/apps/lib/l10n/app_en.arb b/apps/lib/l10n/app_en.arb index 882a9fe..979190a 100644 --- a/apps/lib/l10n/app_en.arb +++ b/apps/lib/l10n/app_en.arb @@ -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", diff --git a/apps/lib/l10n/app_localizations.dart b/apps/lib/l10n/app_localizations.dart index cae1a09..9878f53 100644 --- a/apps/lib/l10n/app_localizations.dart +++ b/apps/lib/l10n/app_localizations.dart @@ -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. diff --git a/apps/lib/l10n/app_localizations_en.dart b/apps/lib/l10n/app_localizations_en.dart index 3ac3c67..545d763 100644 --- a/apps/lib/l10n/app_localizations_en.dart +++ b/apps/lib/l10n/app_localizations_en.dart @@ -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 => diff --git a/apps/lib/l10n/app_localizations_zh.dart b/apps/lib/l10n/app_localizations_zh.dart index 4347d1f..38bc7a5 100644 --- a/apps/lib/l10n/app_localizations_zh.dart +++ b/apps/lib/l10n/app_localizations_zh.dart @@ -1130,7 +1130,7 @@ class AppLocalizationsZh extends AppLocalizations { String get settingsClearCacheAction => '确认清理'; @override - String get settingsClearCacheSuccess => '缓存已清理,正在重新拉取数据'; + String get settingsClearCacheSuccess => '缓存已清理'; @override String get settingsClearCacheFailed => '清理缓存失败,请稍后重试'; diff --git a/apps/lib/l10n/app_zh.arb b/apps/lib/l10n/app_zh.arb index b00f8f4..c471f20 100644 --- a/apps/lib/l10n/app_zh.arb +++ b/apps/lib/l10n/app_zh.arb @@ -464,7 +464,7 @@ "settingsClearCacheTitle": "清理本地缓存", "settingsClearCacheMessage": "将清理本地缓存并重新拉取最新数据,是否继续?", "settingsClearCacheAction": "确认清理", - "settingsClearCacheSuccess": "缓存已清理,正在重新拉取数据", + "settingsClearCacheSuccess": "缓存已清理", "settingsClearCacheFailed": "清理缓存失败,请稍后重试", "settingsJobDetailTitle": "任务详情", "settingsJobCreatePageTitle": "新建周期计划", diff --git a/apps/pubspec.yaml b/apps/pubspec.yaml index 610060b..98d37df 100644 --- a/apps/pubspec.yaml +++ b/apps/pubspec.yaml @@ -1,7 +1,7 @@ name: social_app description: "Social App - A Flutter mobile application" publish_to: 'none' -version: 0.1.2+4 +version: 0.1.2+5 environment: sdk: ^3.10.7 @@ -45,6 +45,7 @@ flutter: assets: - assets/images/ - assets/branding/ + - assets/reminder_sound/ flutter_launcher_icons: android: true diff --git a/apps/test/app/router/app_router_redirect_test.dart b/apps/test/app/router/app_router_redirect_test.dart index 82a58e1..7e1d897 100644 --- a/apps/test/app/router/app_router_redirect_test.dart +++ b/apps/test/app/router/app_router_redirect_test.dart @@ -75,7 +75,11 @@ void main() { }); test('home route screen is wrapped with ChatBloc provider', () { - final widget = buildHomeRouteScreen(); + final widget = buildHomeRouteScreen( + const AuthAuthenticated( + user: AuthUser(id: 'u1', phone: '13800138000'), + ), + ); expect(widget, isA>()); }); diff --git a/apps/test/app/services/session_scope_manager_test.dart b/apps/test/app/services/session_scope_manager_test.dart new file mode 100644 index 0000000..c5a720e --- /dev/null +++ b/apps/test/app/services/session_scope_manager_test.dart @@ -0,0 +1,63 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:social_app/app/services/session_scope_manager.dart'; +import 'package:social_app/data/cache/cache_scope.dart'; +import 'package:social_app/data/cache/cache_store.dart'; + +class _RecordingHybridCacheStore extends HybridCacheStore { + _RecordingHybridCacheStore() + : super(memory: MemoryCacheStore(), persistent: PersistentCacheStore()); + + final List clearedPrefixes = []; + + @override + Future clearByPrefix(String prefix) async { + clearedPrefixes.add(prefix); + } +} + +void main() { + late _RecordingHybridCacheStore cacheStore; + late SessionScopeManager manager; + + setUp(() { + cacheStore = _RecordingHybridCacheStore(); + manager = SessionScopeManager(cacheStore: cacheStore); + CacheScope.resetProvider(); + }); + + tearDown(() { + CacheScope.resetProvider(); + }); + + test('activate configures stable user scope token', () async { + await manager.activate('u-1'); + + expect(CacheScope.token(), 'user:u-1'); + expect(cacheStore.clearedPrefixes, isEmpty); + }); + + test('activate clears previous user cache when switching user', () async { + await manager.activate('u-1'); + await manager.activate('u-2'); + + expect(cacheStore.clearedPrefixes, ['cache:user:u-1:']); + expect(CacheScope.token(), 'user:u-2'); + }); + + test('activate does not clear cache when user is unchanged', () async { + await manager.activate('u-1'); + await manager.activate('u-1'); + + expect(cacheStore.clearedPrefixes, isEmpty); + expect(CacheScope.token(), 'user:u-1'); + }); + + test('clearActiveUserScope clears current user cache', () async { + await manager.activate('u-1'); + + await manager.clearActiveUserScope(); + + expect(cacheStore.clearedPrefixes, ['cache:user:u-1:']); + expect(() => CacheScope.token(), throwsStateError); + }); +} diff --git a/apps/test/core/notification/reminder_reconcile_service_test.dart b/apps/test/core/notification/reminder_reconcile_service_test.dart index 5ff341f..eb89561 100644 --- a/apps/test/core/notification/reminder_reconcile_service_test.dart +++ b/apps/test/core/notification/reminder_reconcile_service_test.dart @@ -1,3 +1,6 @@ +import 'dart:convert'; + +import 'package:flutter_local_notifications/flutter_local_notifications.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:social_app/core/notification/models/reminder_alarm.dart'; import 'package:social_app/core/notification/services/reminder_scheduler_service.dart'; @@ -63,4 +66,125 @@ void main() { expect(alarms, isEmpty); }); + + test('buildUpsertPlan skips unchanged pending reminders', () { + final alarm = ReminderAlarm( + eventId: 'evt_5', + title: 'Sync', + startAt: DateTime(2026, 4, 1, 10, 0), + endAt: DateTime(2026, 4, 1, 11, 0), + timezone: 'Asia/Shanghai', + reminderMinutes: 15, + fireAt: DateTime(2026, 4, 1, 9, 45), + fireTimeBucket: 29114145, + location: 'Room 1', + notes: 'Bring notes', + ); + final id = ReminderSchedulerService.notificationIdFor( + alarm.eventId, + alarm.fireTimeBucket, + ); + + final pending = [ + PendingNotificationRequest( + id, + alarm.title, + '开始时间 10:00 · Room 1', + jsonEncode(alarm.toJson()), + ), + ]; + + final plan = ReminderSchedulerService.buildUpsertPlan( + eventId: alarm.eventId, + pending: pending, + desired: [alarm], + ); + + expect(plan.idsToCancel, isEmpty); + expect(plan.alarmsToSchedule, isEmpty); + }); + + test('buildUpsertPlan reschedules when payload changed', () { + final desired = ReminderAlarm( + eventId: 'evt_6', + title: 'Daily', + startAt: DateTime(2026, 4, 1, 8, 0), + endAt: DateTime(2026, 4, 1, 9, 0), + timezone: 'Asia/Shanghai', + reminderMinutes: 10, + fireAt: DateTime(2026, 4, 1, 7, 50), + fireTimeBucket: 29114030, + location: 'Desk', + notes: 'Updated', + ); + final stale = ReminderAlarm( + eventId: desired.eventId, + title: desired.title, + startAt: desired.startAt, + endAt: desired.endAt, + timezone: desired.timezone, + reminderMinutes: desired.reminderMinutes, + fireAt: desired.fireAt, + fireTimeBucket: desired.fireTimeBucket, + location: desired.location, + notes: 'Old notes', + ); + final id = ReminderSchedulerService.notificationIdFor( + desired.eventId, + desired.fireTimeBucket, + ); + + final plan = ReminderSchedulerService.buildUpsertPlan( + eventId: desired.eventId, + pending: [ + PendingNotificationRequest( + id, + stale.title, + '开始时间 08:00 · Desk', + jsonEncode(stale.toJson()), + ), + ], + desired: [desired], + ); + + expect(plan.idsToCancel, [id]); + expect(plan.alarmsToSchedule.length, 1); + expect(plan.alarmsToSchedule.first.notes, 'Updated'); + }); + + test('buildUpsertPlan cancels and rebuilds when payload is invalid', () { + final desired = ReminderAlarm( + eventId: 'evt_7', + title: 'Check-in', + startAt: DateTime(2026, 4, 2, 9, 0), + endAt: DateTime(2026, 4, 2, 9, 30), + timezone: 'Asia/Shanghai', + reminderMinutes: 10, + fireAt: DateTime(2026, 4, 2, 8, 50), + fireTimeBucket: 29115530, + location: 'Room A', + notes: 'fresh', + ); + final id = ReminderSchedulerService.notificationIdFor( + desired.eventId, + desired.fireTimeBucket, + ); + + final plan = ReminderSchedulerService.buildUpsertPlan( + eventId: desired.eventId, + pending: [ + PendingNotificationRequest( + id, + desired.title, + '开始时间 09:00 · Room A', + '{"eventId":"${desired.eventId}"}', + ), + ], + desired: [desired], + ); + + expect(plan.idsToCancel, [id]); + expect(plan.alarmsToSchedule.length, 1); + expect(plan.alarmsToSchedule.first.eventId, desired.eventId); + }); } diff --git a/apps/test/data/cache/cached_repository_test.dart b/apps/test/data/cache/cached_repository_test.dart index 119fae5..6cdda23 100644 --- a/apps/test/data/cache/cached_repository_test.dart +++ b/apps/test/data/cache/cached_repository_test.dart @@ -1,5 +1,6 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:social_app/data/cache/cache_policy.dart'; +import 'package:social_app/data/cache/cache_scope.dart'; import 'package:social_app/data/cache/cached_repository.dart'; import 'package:social_app/data/cache/cache_store.dart'; @@ -29,6 +30,14 @@ class _IntCachedRepository extends CachedRepository { void main() { group('CachedRepository', () { + setUp(() { + CacheScope.configureProvider(() => 'user:test'); + }); + + tearDown(() { + CacheScope.resetProvider(); + }); + test('reads from cache after first load', () async { final repo = _IntCachedRepository( store: HybridCacheStore( @@ -59,5 +68,22 @@ void main() { expect(refreshed, 2); expect(repo.loadCount, 2); }); + + test('falls back to remote load when scope is unavailable', () async { + CacheScope.resetProvider(); + final repo = _IntCachedRepository( + store: HybridCacheStore( + memory: MemoryCacheStore(), + persistent: PersistentCacheStore(), + ), + ); + + final first = await repo.fetch(); + final second = await repo.fetch(); + + expect(first, 1); + expect(second, 2); + expect(repo.loadCount, 2); + }); }); } diff --git a/apps/test/data/cache/hybrid_cache_store_test.dart b/apps/test/data/cache/hybrid_cache_store_test.dart index 0e0a119..16ccb52 100644 --- a/apps/test/data/cache/hybrid_cache_store_test.dart +++ b/apps/test/data/cache/hybrid_cache_store_test.dart @@ -1,5 +1,6 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:shared_preferences/shared_preferences.dart'; +import 'package:social_app/data/cache/cache_scope.dart'; import 'package:social_app/data/cache/cache_store.dart'; void main() { @@ -24,5 +25,20 @@ void main() { final secondRead = await hybrid.read('k'); expect(secondRead, 'v'); }); + + test('cache invalidator no-ops without configured scope', () async { + CacheScope.resetProvider(); + final invalidator = CacheInvalidator( + store: HybridCacheStore( + memory: MemoryCacheStore(), + persistent: PersistentCacheStore(), + ), + ); + + expect( + () => invalidator.invalidate('calendar:day:2026-03-31'), + returnsNormally, + ); + }); }); } diff --git a/apps/test/features/chat/data/repositories/chat_history_repository_test.dart b/apps/test/features/chat/data/repositories/chat_history_repository_test.dart index d25c650..9cf072b 100644 --- a/apps/test/features/chat/data/repositories/chat_history_repository_test.dart +++ b/apps/test/features/chat/data/repositories/chat_history_repository_test.dart @@ -82,7 +82,7 @@ class _FakeChatApi implements ChatApi { void main() { setUp(() { - CacheScope.configureProvider(() => null); + CacheScope.configureProvider(() => 'user:test'); }); tearDown(() { diff --git a/deploy/static/releases/manifest.json b/deploy/static/releases/manifest.json index 6426a95..bef1130 100644 --- a/deploy/static/releases/manifest.json +++ b/deploy/static/releases/manifest.json @@ -43,6 +43,17 @@ "release_notes": "\u5347\u7ea7\u6d4b\u8bd5\u5305\uff1a\u9a8c\u8bc1\u8986\u76d6\u5b89\u88c5\u540e\u4fdd\u6301\u767b\u5f55\u6001", "file_size": 61845996, "sha256": "be9e725062e4cefef1486aed4a04fa5b7323ad1e11328a41552543d3c71e5060" + }, + { + "platform": "android", + "channel": "release", + "version_name": "0.1.2", + "version_code": 5, + "min_supported_version_code": 5, + "file_name": "social-app-android-v0.1.2+5-release.apk", + "release_notes": "\u91cd\u6784 Reminder Notification \u7cfb\u7edf\u5e76\u66f4\u65b0\u5e94\u7528\u5305\u540d", + "file_size": 61813288, + "sha256": "899a3ae89f9931d9ef1bf5354eeae75d4b5a81ecce83f05a2820c95ff6771e55" } ] } diff --git a/docs/bugs/architecture-cache-scope-bugs.md b/docs/bugs/architecture-cache-scope-bugs.md new file mode 100644 index 0000000..c894b32 --- /dev/null +++ b/docs/bugs/architecture-cache-scope-bugs.md @@ -0,0 +1,103 @@ + +--- + +## 6. 日历提醒未使用自定义提示音 + +**文件**: `apps/lib/core/notification/services/reminder_scheduler_service.dart` + +**问题描述**: +已添加 `apps/assets/reminder_sound/1.WAV` 自定义提示音文件,但代码中未使用,仍使用系统默认铃声: + +```dart +// 当前代码 +const androidDetails = AndroidNotificationDetails( + _channelId, + _channelName, + // ... + playSound: true, // 使用系统默认 + // 没有 customSound 配置 +); +``` + +**根因**: +添加了资源文件但未在通知配置中引用。 + +**建议修复**: +1. 在 `pubspec.yaml` 添加 assets 路径: +```yaml +flutter: + assets: + - assets/reminder_sound/ +``` + +2. 在通知配置中引用自定义铃声: +```dart +// Android +final androidDetails = AndroidNotificationDetails( + _channelId, + _channelName, + playSound: true, + sound: RawResourceAndroidNotificationSound('reminder_sound/1'), + // ... +); + +// iOS +const iosDetails = DarwinNotificationDetails( + presentSound: true, + sound: 'reminder_sound_1.wav', + // ... +); +``` + +--- + +## 7. 通知调度缺少变化检测(重复重建) + +**文件**: `apps/lib/core/notification/services/reminder_scheduler_service.dart` + +**问题描述**: +每次获取日历事件都会调用 `upsertEventReminders`,内部执行 `cancelEventReminders` + 重建,即使事件没变化: + +```dart +Future upsertEventReminders(event) async { + await cancelEventReminders(event.eventId); // 总是先取消 + for (alarm in buildAlarmsForEvent(event)) { + await _scheduleAlarm(alarm); // 总是重建 + } +} +``` + +**影响**: +- 通知 ID 变化导致系统重新调度,浪费资源 +- 用户频繁访问日历时产生不必要的操作 + +**建议修复**: +```dart +Future upsertEventReminders(event) async { + final existing = await _getScheduledAlarm(event.eventId); + if (existing != null && _isSameAlarm(existing, event)) { + return; // 跳过,没变化 + } + await cancelEventReminders(event.eventId); + for (alarm in buildAlarmsForEvent(event)) { + await _scheduleAlarm(alarm); + } +} +``` + + + + +## 相关文件 + +- `apps/lib/app/app.dart` +- `apps/lib/data/cache/cache_scope.dart` +- `apps/lib/data/cache/cache_store.dart` +- `apps/lib/data/cache/cached_repository.dart` +- `apps/lib/core/inbox/inbox_sync_store.dart` +- `apps/lib/features/chat/presentation/bloc/chat_bloc.dart` +- `apps/lib/app/services/app_prewarm_orchestrator.dart` +- `apps/lib/core/notification/services/reminder_scheduler_service.dart` +- `apps/lib/core/notification/services/reminder_permission_service.dart` +- `apps/lib/core/notification/services/reminder_reconcile_service.dart` +- `apps/assets/reminder_sound/` diff --git a/docs/bugs/路由守卫逻辑分散.md b/docs/bugs/路由守卫逻辑分散.md deleted file mode 100644 index 0d608d6..0000000 --- a/docs/bugs/路由守卫逻辑分散.md +++ /dev/null @@ -1,106 +0,0 @@ -# 路由守卫逻辑分散 - -## 问题描述 - -当前路由守卫逻辑分散在两处,可能导致判断不一致: - -1. `app_router.dart` 的 `redirect()` - 核心守卫逻辑 -2. `LinksyApp` 的 `BlocListener` - 预留了位置但未使用 - -## 当前代码 - -```dart -// app_router.dart -GoRouter createAppRouter(AuthBloc authBloc) { - return GoRouter( - refreshListenable: GoRouterRefreshStream(authBloc.stream), - redirect: (context, state) { - final authState = authBloc.state; - final isAuthenticated = authState is AuthAuthenticated; - // ... 守卫判断逻辑 - }, - ); -} - -// app.dart (LinksyApp) -BlocListener( - listener: (context, state) { - // Handle auth state changes if needed ← 预留但未使用 - }, -) -``` - -## 问题 - -| 问题 | 说明 | -|------|------| -| 逻辑分散 | 守卫在 `redirect()`,但 BlocListener 预留了位置 | -| 隐患 | 将来可能有人在两处都加逻辑,导致不一致 | -| 职责不清 | 到底是 redirect 管跳转,还是 BlocListener 管跳转 | - -## 建议方案 - -**方案1:路由守卫集中在 redirect()(当前方案,保持但清理)** - -```dart -// app_router.dart -GoRouter createAppRouter() { - return GoRouter( - refreshListenable: GoRouterRefreshStream(sl().stream), - redirect: (context, state) { - // 唯一的守卫逻辑 - }, - ); -} - -// LinksyApp - 只做副作用,不做路由跳转 -BlocListener( - listener: (context, state) { - // 埋点、Toast 等副作用 - }, -) -``` - -**方案2:路由守卫集中在 BlocListener** - -```dart -// app_router.dart - 不再有 redirect -GoRouter createAppRouter() { - return GoRouter( - routes: [...], - ); -} - -// LinksyApp - 唯一的路由守卫入口 -BlocListener( - listener: (context, state) { - final router = GoRouter.of(context); - if (state is AuthUnauthenticated) { - if (!isPublicRoute(router.matchedLocation)) { - router.go(AppRoutes.authLogin); - } - } else if (state is AuthAuthenticated) { - if (router.matchedLocation == AppRoutes.authLogin) { - router.go(AppRoutes.homeMain); - } - } - }, -) -``` - -## 收益 - -| 收益 | 说明 | -|------|------| -| 单一职责 | 路由跳转只在一处判断 | -| 可维护 | 将来不会有人误在另一处加逻辑 | -| 清晰 | 开发者知道去哪改守卫逻辑 | - -## 涉及文件 - -- `apps/lib/app/app.dart` -- `apps/lib/app/router/app_router.dart` - -## 状态 - -- [ ] 待修复 diff --git a/docs/plans/2026-03-30-agent-calendar-inbox-stability.md b/docs/plans/2026-03-30-agent-calendar-inbox-stability.md deleted file mode 100644 index bd5b28e..0000000 --- a/docs/plans/2026-03-30-agent-calendar-inbox-stability.md +++ /dev/null @@ -1,74 +0,0 @@ -# Agent Calendar/Inbox Stability Implementation Plan - -> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. - -**Goal:** 修复 agent 日历分享失败、对话后日历不刷新、邀请信息不完整,并新增设置页一键清理本地缓存后强制重新拉取。 - -**Architecture:** 后端补齐 `calendar_share` 依赖注入与邀请 payload 字段,确保工具链与 API 路由行为一致。前端在 Chat 工具结果事件上增加日历刷新钩子,并在设置页提供缓存清理入口,复用既有 prewarm 机制触发重新拉取。协议文档同步更新 inbox 邀请结构,避免前后端契约漂移。 - -**Tech Stack:** FastAPI + SQLAlchemy + AgentScope tools, Flutter + CachedRepository + SharedPreferences cache. - ---- - -### Task 1: 修复 calendar_share 在 Agent 工具链中的依赖缺失 - -**Files:** -- Modify: `backend/src/core/agentscope/tools/utils/calendar_domain.py` - -**Steps:** -1. 在 `create_schedule_service` 注入 `SQLAlchemyUserRepository`。 -2. 保持路由层与工具层对 `ScheduleItemService` 的依赖一致。 -3. 回归验证 `calendar_share` 不再因为 actor lookup 依赖缺失而失败。 - -### Task 2: 扩充 calendar invite payload(邀请人 + 时间 + 描述) - -**Files:** -- Modify: `backend/src/v1/schedule_items/service.py` -- Modify: `docs/protocols/models/inbox-messages.md` -- Test: `backend/tests/unit/v1/schedule_items/test_share.py` - -**Steps:** -1. 在 `share` 中构建邀请消息时写入 `actor.phone`。 -2. 在 `item` 中写入 `description/start_at/end_at/timezone`。 -3. 更新协议文档 `CalendarInviteContent`。 -4. 补充/更新单测断言新增字段。 - -### Task 3: 对话工具成功后触发日历缓存刷新钩子 - -**Files:** -- Modify: `apps/lib/features/chat/presentation/bloc/chat_bloc.dart` -- Modify: `apps/lib/features/chat/presentation/bloc/chat_bloc_events.dart` -- Modify: `apps/lib/app/di/injection.dart` -- Test: `apps/test/features/chat/presentation/bloc/chat_bloc_test.dart` - -**Steps:** -1. 给 `ChatBloc` 增加可注入回调 `onCalendarMutated`。 -2. 在 `ToolCallResultEvent` 中识别 `calendar_write` 成功/部分成功并触发回调。 -3. DI 中将回调绑定为 `CalendarRepository.getDayEvents/getMonthEvents(forceRefresh: true)`。 -4. 添加回归测试验证回调触发。 - -### Task 4: 设置页新增“清理缓存”并触发重新拉取 - -**Files:** -- Modify: `apps/lib/data/cache/cache_store.dart` -- Modify: `apps/lib/features/settings/presentation/screens/settings_screen.dart` -- Modify: `apps/lib/l10n/app_zh.arb` -- Modify: `apps/lib/l10n/app_en.arb` - -**Steps:** -1. 在 `HybridCacheStore` 增加按前缀清理能力(`cache:`)。 -2. 设置页在“检查更新”下新增“清理缓存”。 -3. 点击后清理缓存并触发 prewarm + inbox 快照刷新。 -4. 同步中英文文案并生成本地化代码。 - -### Task 5: 验证 - -**Commands:** -- `uv run pytest backend/tests/unit/v1/schedule_items/test_share.py backend/tests/unit/core/agentscope/test_calendar_tools.py -k "share or calendar_share"` -- `flutter test test/features/chat/presentation/bloc/chat_bloc_test.dart` -- `flutter analyze` - -**Expected:** -- 后端分享链路测试通过,新增邀请字段存在。 -- ChatBloc 回归测试通过,`calendar_write` 成功时触发刷新回调。 -- Flutter 静态检查通过,无新增错误。