From cbbed29a75cab3ec2d3f7bedbce0897aade880fb Mon Sep 17 00:00:00 2001 From: qzl Date: Fri, 20 Mar 2026 15:20:29 +0800 Subject: [PATCH 01/12] feat: enforce hierarchical back navigation and home-only exit --- apps/lib/core/router/app_router.dart | 12 ++++++++---- .../calendar/ui/screens/calendar_dayweek_screen.dart | 4 ++-- .../calendar/ui/screens/calendar_month_screen.dart | 4 ++-- .../home/ui/navigation/home_return_policy.dart | 6 ++++++ .../settings/ui/screens/settings_screen.dart | 3 ++- .../todo/ui/screens/todo_quadrants_screen.dart | 4 ++-- .../home/ui/navigation/home_return_policy_test.dart | 9 +++++++++ 7 files changed, 31 insertions(+), 11 deletions(-) diff --git a/apps/lib/core/router/app_router.dart b/apps/lib/core/router/app_router.dart index 7ad6485..75aca49 100644 --- a/apps/lib/core/router/app_router.dart +++ b/apps/lib/core/router/app_router.dart @@ -26,15 +26,19 @@ import '../../features/settings/ui/screens/features_screen.dart'; import '../../features/settings/ui/screens/memory_screen.dart'; import '../../features/settings/ui/screens/edit_profile_screen.dart'; -final _protectedRoutes = [ +final _homeSecondLevelRoutes = [ AppRoutes.homeMain, - AppRoutes.contactsList, - AppRoutes.contactsAdd, AppRoutes.calendarDayWeek, AppRoutes.calendarMonth, - '/calendar/events', AppRoutes.todoList, AppRoutes.settingsMain, +]; + +final _protectedRoutes = [ + ..._homeSecondLevelRoutes, + AppRoutes.contactsList, + AppRoutes.contactsAdd, + '/calendar/events', AppRoutes.settingsFeatures, AppRoutes.settingsMemory, AppRoutes.settingsEditProfile, diff --git a/apps/lib/features/calendar/ui/screens/calendar_dayweek_screen.dart b/apps/lib/features/calendar/ui/screens/calendar_dayweek_screen.dart index 0e52cc9..4b4da5d 100644 --- a/apps/lib/features/calendar/ui/screens/calendar_dayweek_screen.dart +++ b/apps/lib/features/calendar/ui/screens/calendar_dayweek_screen.dart @@ -119,7 +119,7 @@ class _CalendarDayWeekScreenState extends State canPop: false, onPopInvokedWithResult: (didPop, result) { if (!didPop) { - returnToHomePreserveState(context); + returnToHomePreserveState(context, forceGoHome: true); } }, child: SafeArea( @@ -702,7 +702,7 @@ class _CalendarDayWeekScreenState extends State _calendarManager.setViewType(CalendarViewType.day); context.push(AppRoutes.calendarMonth); }, - onHomeTap: () => returnToHomePreserveState(context), + onHomeTap: () => returnToHomePreserveState(context, forceGoHome: true), ); } } diff --git a/apps/lib/features/calendar/ui/screens/calendar_month_screen.dart b/apps/lib/features/calendar/ui/screens/calendar_month_screen.dart index bf6355b..c4dab8a 100644 --- a/apps/lib/features/calendar/ui/screens/calendar_month_screen.dart +++ b/apps/lib/features/calendar/ui/screens/calendar_month_screen.dart @@ -105,7 +105,7 @@ class _CalendarMonthScreenState extends State canPop: false, onPopInvokedWithResult: (didPop, result) { if (!didPop) { - returnToHomePreserveState(context); + returnToHomePreserveState(context, forceGoHome: true); } }, child: SafeArea( @@ -522,7 +522,7 @@ class _CalendarMonthScreenState extends State context.push(AppRoutes.todoList); }, onCalendarTap: () {}, - onHomeTap: () => returnToHomePreserveState(context), + onHomeTap: () => returnToHomePreserveState(context, forceGoHome: true), ); } } diff --git a/apps/lib/features/home/ui/navigation/home_return_policy.dart b/apps/lib/features/home/ui/navigation/home_return_policy.dart index 8da767d..dd47128 100644 --- a/apps/lib/features/home/ui/navigation/home_return_policy.dart +++ b/apps/lib/features/home/ui/navigation/home_return_policy.dart @@ -8,7 +8,11 @@ enum HomeReturnAction { pop, goHome } HomeReturnAction resolveHomeReturnAction({ required bool canPop, required bool isAuthEntry, + bool forceGoHome = false, }) { + if (forceGoHome) { + return HomeReturnAction.goHome; + } if (isAuthEntry) { return HomeReturnAction.goHome; } @@ -21,10 +25,12 @@ HomeReturnAction resolveHomeReturnAction({ void returnToHomePreserveState( BuildContext context, { bool isAuthEntry = false, + bool forceGoHome = false, }) { final action = resolveHomeReturnAction( canPop: context.canPop(), isAuthEntry: isAuthEntry, + forceGoHome: forceGoHome, ); switch (action) { case HomeReturnAction.pop: diff --git a/apps/lib/features/settings/ui/screens/settings_screen.dart b/apps/lib/features/settings/ui/screens/settings_screen.dart index f645069..febb69e 100644 --- a/apps/lib/features/settings/ui/screens/settings_screen.dart +++ b/apps/lib/features/settings/ui/screens/settings_screen.dart @@ -20,6 +20,7 @@ import 'package:social_app/features/settings/data/settings_api.dart'; import 'package:social_app/features/settings/data/services/settings_user_cache.dart'; import 'package:social_app/features/users/data/models/user_response.dart'; import 'package:social_app/features/users/data/users_api.dart'; +import 'package:social_app/features/home/ui/navigation/home_return_policy.dart'; import '../widgets/settings_page_scaffold.dart'; const settingsProfileEditButtonKey = ValueKey('settings_profile_edit_button'); @@ -90,7 +91,7 @@ class _SettingsScreenState extends State { Widget build(BuildContext context) { return SettingsPageScaffold( title: '设置', - onBack: () => context.pop(), + onBack: () => returnToHomePreserveState(context, forceGoHome: true), body: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ diff --git a/apps/lib/features/todo/ui/screens/todo_quadrants_screen.dart b/apps/lib/features/todo/ui/screens/todo_quadrants_screen.dart index 165418d..23ddf6b 100644 --- a/apps/lib/features/todo/ui/screens/todo_quadrants_screen.dart +++ b/apps/lib/features/todo/ui/screens/todo_quadrants_screen.dart @@ -298,7 +298,7 @@ class _TodoQuadrantsScreenState extends State { canPop: false, onPopInvokedWithResult: (didPop, result) { if (!didPop) { - returnToHomePreserveState(context); + returnToHomePreserveState(context, forceGoHome: true); } }, child: SafeArea( @@ -563,7 +563,7 @@ class _TodoQuadrantsScreenState extends State { context.push('${AppRoutes.calendarDayWeek}?date=$dateStr'); } }, - onHomeTap: () => returnToHomePreserveState(context), + onHomeTap: () => returnToHomePreserveState(context, forceGoHome: true), ); } } diff --git a/apps/test/features/home/ui/navigation/home_return_policy_test.dart b/apps/test/features/home/ui/navigation/home_return_policy_test.dart index 7f06678..46ddfa8 100644 --- a/apps/test/features/home/ui/navigation/home_return_policy_test.dart +++ b/apps/test/features/home/ui/navigation/home_return_policy_test.dart @@ -3,6 +3,15 @@ import 'package:social_app/features/home/ui/navigation/home_return_policy.dart'; void main() { group('resolveHomeReturnAction', () { + test('second-level pages should return to home instead of exiting app', () { + final action = resolveHomeReturnAction( + canPop: false, + isAuthEntry: false, + forceGoHome: true, + ); + expect(action, HomeReturnAction.goHome); + }); + test('business route with back stack prefers pop', () { final action = resolveHomeReturnAction(canPop: true, isAuthEntry: false); expect(action, HomeReturnAction.pop); From 035ca46bd0968c974a0d3984f55b0da72b5b6126 Mon Sep 17 00:00:00 2001 From: qzl Date: Fri, 20 Mar 2026 15:21:52 +0800 Subject: [PATCH 02/12] feat: add unified cache policy primitives --- apps/lib/core/cache/cache_entry.dart | 6 +++ apps/lib/core/cache/cache_key.dart | 17 +++++++ apps/lib/core/cache/cache_policy.dart | 49 +++++++++++++++++++++ apps/test/core/cache/cache_policy_test.dart | 19 ++++++++ 4 files changed, 91 insertions(+) create mode 100644 apps/lib/core/cache/cache_entry.dart create mode 100644 apps/lib/core/cache/cache_key.dart create mode 100644 apps/lib/core/cache/cache_policy.dart create mode 100644 apps/test/core/cache/cache_policy_test.dart diff --git a/apps/lib/core/cache/cache_entry.dart b/apps/lib/core/cache/cache_entry.dart new file mode 100644 index 0000000..95051c1 --- /dev/null +++ b/apps/lib/core/cache/cache_entry.dart @@ -0,0 +1,6 @@ +class CacheEntry { + final T value; + final DateTime fetchedAt; + + const CacheEntry({required this.value, required this.fetchedAt}); +} diff --git a/apps/lib/core/cache/cache_key.dart b/apps/lib/core/cache/cache_key.dart new file mode 100644 index 0000000..b5f75b6 --- /dev/null +++ b/apps/lib/core/cache/cache_key.dart @@ -0,0 +1,17 @@ +class CacheKey { + final String value; + + const CacheKey(this.value); + + @override + String toString() => value; + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other is CacheKey && other.value == value); + } + + @override + int get hashCode => value.hashCode; +} diff --git a/apps/lib/core/cache/cache_policy.dart b/apps/lib/core/cache/cache_policy.dart new file mode 100644 index 0000000..0ebd479 --- /dev/null +++ b/apps/lib/core/cache/cache_policy.dart @@ -0,0 +1,49 @@ +class CacheDecision { + final bool canUseCached; + final bool shouldRefreshInBackground; + final bool mustBlockForNetwork; + + const CacheDecision({ + required this.canUseCached, + required this.shouldRefreshInBackground, + required this.mustBlockForNetwork, + }); +} + +class CachePolicy { + final Duration softTtl; + final Duration hardTtl; + final Duration minRefreshInterval; + + const CachePolicy({ + required this.softTtl, + required this.hardTtl, + this.minRefreshInterval = Duration.zero, + }); + + CacheDecision evaluate({required DateTime now, required DateTime fetchedAt}) { + final age = now.difference(fetchedAt); + if (age >= hardTtl) { + return const CacheDecision( + canUseCached: false, + shouldRefreshInBackground: false, + mustBlockForNetwork: true, + ); + } + + if (age >= softTtl) { + final shouldRefresh = age >= minRefreshInterval; + return CacheDecision( + canUseCached: true, + shouldRefreshInBackground: shouldRefresh, + mustBlockForNetwork: false, + ); + } + + return const CacheDecision( + canUseCached: true, + shouldRefreshInBackground: false, + mustBlockForNetwork: false, + ); + } +} diff --git a/apps/test/core/cache/cache_policy_test.dart b/apps/test/core/cache/cache_policy_test.dart new file mode 100644 index 0000000..8f3db23 --- /dev/null +++ b/apps/test/core/cache/cache_policy_test.dart @@ -0,0 +1,19 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:social_app/core/cache/cache_policy.dart'; + +void main() { + test('soft expired should allow stale read with background refresh', () { + final now = DateTime(2026, 3, 20, 12); + final policy = CachePolicy( + softTtl: const Duration(minutes: 2), + hardTtl: const Duration(minutes: 30), + minRefreshInterval: const Duration(minutes: 1), + ); + + final fetchedAt = now.subtract(const Duration(minutes: 3)); + final decision = policy.evaluate(now: now, fetchedAt: fetchedAt); + expect(decision.canUseCached, true); + expect(decision.shouldRefreshInBackground, true); + expect(decision.mustBlockForNetwork, false); + }); +} From 632db2b68b274fd218bc01072c83d1380bf37c75 Mon Sep 17 00:00:00 2001 From: qzl Date: Fri, 20 Mar 2026 15:23:56 +0800 Subject: [PATCH 03/12] feat: implement hybrid cache store with singleflight --- apps/lib/core/cache/cache_store.dart | 5 ++ apps/lib/core/cache/hybrid_cache_store.dart | 55 +++++++++++++++++++ apps/lib/core/cache/memory_cache_store.dart | 24 ++++++++ .../core/cache/persistent_cache_store.dart | 24 ++++++++ .../core/cache/hybrid_cache_store_test.dart | 27 +++++++++ 5 files changed, 135 insertions(+) create mode 100644 apps/lib/core/cache/cache_store.dart create mode 100644 apps/lib/core/cache/hybrid_cache_store.dart create mode 100644 apps/lib/core/cache/memory_cache_store.dart create mode 100644 apps/lib/core/cache/persistent_cache_store.dart create mode 100644 apps/test/core/cache/hybrid_cache_store_test.dart diff --git a/apps/lib/core/cache/cache_store.dart b/apps/lib/core/cache/cache_store.dart new file mode 100644 index 0000000..6b4f768 --- /dev/null +++ b/apps/lib/core/cache/cache_store.dart @@ -0,0 +1,5 @@ +abstract class CacheStore { + Future read(String key); + Future write(String key, T value); + Future remove(String key); +} diff --git a/apps/lib/core/cache/hybrid_cache_store.dart b/apps/lib/core/cache/hybrid_cache_store.dart new file mode 100644 index 0000000..a148fcc --- /dev/null +++ b/apps/lib/core/cache/hybrid_cache_store.dart @@ -0,0 +1,55 @@ +import 'memory_cache_store.dart'; +import 'persistent_cache_store.dart'; + +class HybridCacheStore { + final MemoryCacheStore memory; + final PersistentCacheStore persistent; + final Map> _inflight = >{}; + + HybridCacheStore({required this.memory, required this.persistent}); + + Future read(String key) async { + final memoryValue = await memory.read(key); + if (memoryValue != null) { + return memoryValue; + } + final persistentValue = await persistent.read(key); + if (persistentValue != null) { + await memory.write(key, persistentValue); + } + return persistentValue; + } + + Future write(String key, T value) async { + await memory.write(key, value); + await persistent.write(key, value); + } + + Future remove(String key) async { + await memory.remove(key); + await persistent.remove(key); + } + + Future getOrLoad(String key, {required Future Function() loader}) { + final running = _inflight[key]; + if (running != null) { + return running.then((value) => value as T); + } + + final future = () async { + final cached = await read(key); + if (cached != null) { + return cached; + } + + final loaded = await loader(); + await write(key, loaded); + return loaded; + }(); + + _inflight[key] = future; + return future.whenComplete(() { + _inflight.remove(key); + }); + } +} diff --git a/apps/lib/core/cache/memory_cache_store.dart b/apps/lib/core/cache/memory_cache_store.dart new file mode 100644 index 0000000..d91f467 --- /dev/null +++ b/apps/lib/core/cache/memory_cache_store.dart @@ -0,0 +1,24 @@ +import 'cache_store.dart'; + +class MemoryCacheStore implements CacheStore { + final Map _values = {}; + + @override + Future read(String key) async { + final value = _values[key]; + if (value is T) { + return value; + } + return null; + } + + @override + Future write(String key, T value) async { + _values[key] = value; + } + + @override + Future remove(String key) async { + _values.remove(key); + } +} diff --git a/apps/lib/core/cache/persistent_cache_store.dart b/apps/lib/core/cache/persistent_cache_store.dart new file mode 100644 index 0000000..160a7f9 --- /dev/null +++ b/apps/lib/core/cache/persistent_cache_store.dart @@ -0,0 +1,24 @@ +import 'cache_store.dart'; + +class PersistentCacheStore implements CacheStore { + final Map _values = {}; + + @override + Future read(String key) async { + final value = _values[key]; + if (value is T) { + return value; + } + return null; + } + + @override + Future write(String key, T value) async { + _values[key] = value; + } + + @override + Future remove(String key) async { + _values.remove(key); + } +} diff --git a/apps/test/core/cache/hybrid_cache_store_test.dart b/apps/test/core/cache/hybrid_cache_store_test.dart new file mode 100644 index 0000000..770a6e5 --- /dev/null +++ b/apps/test/core/cache/hybrid_cache_store_test.dart @@ -0,0 +1,27 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:social_app/core/cache/hybrid_cache_store.dart'; +import 'package:social_app/core/cache/memory_cache_store.dart'; +import 'package:social_app/core/cache/persistent_cache_store.dart'; + +void main() { + test('same key concurrent load should execute loader once', () async { + var calls = 0; + final store = HybridCacheStore( + memory: MemoryCacheStore(), + persistent: PersistentCacheStore(), + ); + + Future loader() async { + calls += 1; + await Future.delayed(const Duration(milliseconds: 20)); + return 'ok'; + } + + await Future.wait([ + store.getOrLoad('k', loader: loader), + store.getOrLoad('k', loader: loader), + ]); + + expect(calls, 1); + }); +} From 1cea877bf1b21db887f69035563f1d776ad776c1 Mon Sep 17 00:00:00 2001 From: qzl Date: Fri, 20 Mar 2026 15:25:33 +0800 Subject: [PATCH 04/12] refactor: wire unified cache and invalidator in di --- apps/lib/core/cache/cache_invalidator.dart | 27 +++++++++++++++++++ apps/lib/core/di/injection.dart | 17 ++++++++++++ .../core/cache/cache_invalidator_test.dart | 11 ++++++++ 3 files changed, 55 insertions(+) create mode 100644 apps/lib/core/cache/cache_invalidator.dart create mode 100644 apps/test/core/cache/cache_invalidator_test.dart diff --git a/apps/lib/core/cache/cache_invalidator.dart b/apps/lib/core/cache/cache_invalidator.dart new file mode 100644 index 0000000..b6e3181 --- /dev/null +++ b/apps/lib/core/cache/cache_invalidator.dart @@ -0,0 +1,27 @@ +import 'dart:async'; + +import 'hybrid_cache_store.dart'; + +class CacheInvalidator { + final HybridCacheStore? _store; + final Set _invalidated = {}; + + CacheInvalidator({HybridCacheStore? store}) : _store = store; + + void invalidate(String key) { + _invalidated.add(key); + final removeFuture = _store?.remove(key); + if (removeFuture != null) { + unawaited(removeFuture); + } + } + + void invalidateCalendarDay(DateTime date) { + final month = '${date.year}-${date.month.toString().padLeft(2, '0')}'; + final day = '$month-${date.day.toString().padLeft(2, '0')}'; + invalidate('calendar:day:$day'); + invalidate('calendar:month:$month'); + } + + bool wasInvalidated(String key) => _invalidated.contains(key); +} diff --git a/apps/lib/core/di/injection.dart b/apps/lib/core/di/injection.dart index 60c3822..a7557fc 100644 --- a/apps/lib/core/di/injection.dart +++ b/apps/lib/core/di/injection.dart @@ -2,6 +2,10 @@ import 'package:dio/dio.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:get_it/get_it.dart'; import 'package:shared_preferences/shared_preferences.dart'; +import '../cache/cache_invalidator.dart'; +import '../cache/hybrid_cache_store.dart'; +import '../cache/memory_cache_store.dart'; +import '../cache/persistent_cache_store.dart'; import '../api/api_client.dart'; import '../api/i_api_client.dart'; import '../storage/token_storage.dart'; @@ -56,6 +60,19 @@ Future configureDependencies() async { final sharedPreferences = await SharedPreferences.getInstance(); sl.registerSingleton(sharedPreferences); + final memoryCacheStore = MemoryCacheStore(); + final persistentCacheStore = PersistentCacheStore(); + final hybridCacheStore = HybridCacheStore( + memory: memoryCacheStore, + persistent: persistentCacheStore, + ); + sl.registerSingleton(memoryCacheStore); + sl.registerSingleton(persistentCacheStore); + sl.registerSingleton(hybridCacheStore); + sl.registerSingleton( + CacheInvalidator(store: hybridCacheStore), + ); + final usersApi = UsersApi(apiClient); sl.registerSingleton(usersApi); diff --git a/apps/test/core/cache/cache_invalidator_test.dart b/apps/test/core/cache/cache_invalidator_test.dart new file mode 100644 index 0000000..a41635c --- /dev/null +++ b/apps/test/core/cache/cache_invalidator_test.dart @@ -0,0 +1,11 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:social_app/core/cache/cache_invalidator.dart'; + +void main() { + test('invalidate calendar day should also invalidate month key', () { + final inv = CacheInvalidator(); + inv.invalidateCalendarDay(DateTime(2026, 3, 20)); + expect(inv.wasInvalidated('calendar:day:2026-03-20'), true); + expect(inv.wasInvalidated('calendar:month:2026-03'), true); + }); +} From a99973fb96efd8ab1cecf19918983b7a917bf33c Mon Sep 17 00:00:00 2001 From: qzl Date: Fri, 20 Mar 2026 15:29:06 +0800 Subject: [PATCH 05/12] refactor: merge profile cache into unified cache repository --- apps/lib/core/di/injection.dart | 11 ++- .../data/services/settings_user_cache.dart | 45 +++------ .../user_profile_cache_repository.dart | 83 ++++++++++++++++ .../settings/ui/screens/settings_screen.dart | 4 +- .../services/settings_user_cache_test.dart | 97 +++++++++---------- .../user_profile_cache_repository_test.dart | 47 +++++++++ 6 files changed, 202 insertions(+), 85 deletions(-) create mode 100644 apps/lib/features/settings/data/services/user_profile_cache_repository.dart create mode 100644 apps/test/features/settings/data/services/user_profile_cache_repository_test.dart diff --git a/apps/lib/core/di/injection.dart b/apps/lib/core/di/injection.dart index a7557fc..597a752 100644 --- a/apps/lib/core/di/injection.dart +++ b/apps/lib/core/di/injection.dart @@ -25,6 +25,7 @@ import '../../features/friends/data/friends_api.dart'; import '../../features/messages/data/inbox_api.dart'; import '../../features/settings/data/settings_api.dart'; import '../../features/settings/data/services/settings_user_cache.dart'; +import '../../features/settings/data/services/user_profile_cache_repository.dart'; import '../../features/users/data/users_api.dart'; import '../../features/todo/data/todo_api.dart'; @@ -76,6 +77,12 @@ Future configureDependencies() async { final usersApi = UsersApi(apiClient); sl.registerSingleton(usersApi); + final userProfileCacheRepository = UserProfileCacheRepository( + store: hybridCacheStore, + remoteLoader: usersApi.getMe, + ); + sl.registerSingleton(userProfileCacheRepository); + final calendarApi = CalendarApi(apiClient); sl.registerSingleton(calendarApi); @@ -100,7 +107,9 @@ Future configureDependencies() async { final settingsApi = SettingsApi(apiClient); sl.registerSingleton(settingsApi); - sl.registerSingleton(SettingsUserCache()); + sl.registerSingleton( + SettingsUserCache(userProfileCacheRepository), + ); final inboxApi = InboxApi(apiClient); sl.registerSingleton(inboxApi); diff --git a/apps/lib/features/settings/data/services/settings_user_cache.dart b/apps/lib/features/settings/data/services/settings_user_cache.dart index 4cc1e90..6fbceb4 100644 --- a/apps/lib/features/settings/data/services/settings_user_cache.dart +++ b/apps/lib/features/settings/data/services/settings_user_cache.dart @@ -1,49 +1,30 @@ +import 'dart:async'; + import '../../../users/data/models/user_response.dart'; +import 'user_profile_cache_repository.dart'; class SettingsUserCache { + final UserProfileCacheRepository _repository; + + SettingsUserCache(this._repository); + UserResponse? _cachedUser; - Future? _inflight; - int _generation = 0; UserResponse? get cachedUser => _cachedUser; - Future getOrLoad(Future Function() loader) { - final cached = _cachedUser; - if (cached != null) { - return Future.value(cached); - } - - final inflight = _inflight; - if (inflight != null) { - return inflight; - } - - final generation = _generation; - late final Future request; - request = loader() - .then((user) { - if (generation == _generation) { - _cachedUser = user; - } - return user; - }) - .whenComplete(() { - if (identical(_inflight, request)) { - _inflight = null; - } - }); - - _inflight = request; - return request; + Future getProfile({bool forceRefresh = false}) async { + final user = await _repository.getProfile(forceRefresh: forceRefresh); + _cachedUser = user; + return user; } void set(UserResponse user) { _cachedUser = user; + unawaited(_repository.setCached(user)); } void invalidate() { - _generation += 1; _cachedUser = null; - _inflight = null; + unawaited(_repository.invalidate()); } } diff --git a/apps/lib/features/settings/data/services/user_profile_cache_repository.dart b/apps/lib/features/settings/data/services/user_profile_cache_repository.dart new file mode 100644 index 0000000..578c9e4 --- /dev/null +++ b/apps/lib/features/settings/data/services/user_profile_cache_repository.dart @@ -0,0 +1,83 @@ +import 'dart:async'; + +import '../../../../core/cache/cache_entry.dart'; +import '../../../../core/cache/cache_policy.dart'; +import '../../../../core/cache/hybrid_cache_store.dart'; +import '../../../users/data/models/user_response.dart'; + +class UserProfileCacheRepository { + static const String cacheKey = 'settings:user_profile'; + + final HybridCacheStore store; + final CachePolicy policy; + final DateTime Function() now; + final Future Function() remoteLoader; + + Future? _refreshInFlight; + + UserProfileCacheRepository({ + required this.store, + required this.remoteLoader, + CachePolicy? policy, + DateTime Function()? now, + }) : policy = + policy ?? + const CachePolicy( + softTtl: Duration(minutes: 2), + hardTtl: Duration(minutes: 30), + minRefreshInterval: Duration(minutes: 1), + ), + now = now ?? DateTime.now; + + Future getProfile({bool forceRefresh = false}) async { + if (forceRefresh) { + return _refreshAndRead(); + } + + final cached = await store.read>(cacheKey); + if (cached == null) { + return _refreshAndRead(); + } + + final decision = policy.evaluate(now: now(), fetchedAt: cached.fetchedAt); + if (decision.shouldRefreshInBackground) { + _refreshInBackground(); + } + if (decision.mustBlockForNetwork || !decision.canUseCached) { + return _refreshAndRead(); + } + return cached.value; + } + + Future setCached(UserResponse user) { + return store.write>( + cacheKey, + CacheEntry(value: user, fetchedAt: now()), + ); + } + + Future invalidate() => store.remove(cacheKey); + + void _refreshInBackground() { + final running = _refreshInFlight; + if (running != null) { + return; + } + final task = _refreshAndWrite().whenComplete(() { + _refreshInFlight = null; + }); + _refreshInFlight = task; + unawaited(task); + } + + Future _refreshAndRead() async { + await _refreshAndWrite(); + final cached = await store.read>(cacheKey); + return cached!.value; + } + + Future _refreshAndWrite() async { + final remote = await remoteLoader(); + await setCached(remote); + } +} diff --git a/apps/lib/features/settings/ui/screens/settings_screen.dart b/apps/lib/features/settings/ui/screens/settings_screen.dart index febb69e..7f9274c 100644 --- a/apps/lib/features/settings/ui/screens/settings_screen.dart +++ b/apps/lib/features/settings/ui/screens/settings_screen.dart @@ -19,7 +19,6 @@ import 'package:social_app/features/friends/data/friends_api.dart'; import 'package:social_app/features/settings/data/settings_api.dart'; import 'package:social_app/features/settings/data/services/settings_user_cache.dart'; import 'package:social_app/features/users/data/models/user_response.dart'; -import 'package:social_app/features/users/data/users_api.dart'; import 'package:social_app/features/home/ui/navigation/home_return_policy.dart'; import '../widgets/settings_page_scaffold.dart'; @@ -34,7 +33,6 @@ class SettingsScreen extends StatefulWidget { } class _SettingsScreenState extends State { - final UsersApi _usersApi = sl(); final FriendsApi _friendsApi = sl(); final SettingsUserCache _userCache = sl(); @@ -56,7 +54,7 @@ class _SettingsScreenState extends State { Future _loadData() async { try { - final user = await _userCache.getOrLoad(_usersApi.getMe); + final user = await _userCache.getProfile(); if (mounted) { setState(() { _user = user; diff --git a/apps/test/features/settings/data/services/settings_user_cache_test.dart b/apps/test/features/settings/data/services/settings_user_cache_test.dart index 3e37475..c9467af 100644 --- a/apps/test/features/settings/data/services/settings_user_cache_test.dart +++ b/apps/test/features/settings/data/services/settings_user_cache_test.dart @@ -1,70 +1,69 @@ -import 'dart:async'; - import 'package:flutter_test/flutter_test.dart'; +import 'package:social_app/core/cache/cache_policy.dart'; +import 'package:social_app/core/cache/hybrid_cache_store.dart'; +import 'package:social_app/core/cache/memory_cache_store.dart'; +import 'package:social_app/core/cache/persistent_cache_store.dart'; import 'package:social_app/features/settings/data/services/settings_user_cache.dart'; +import 'package:social_app/features/settings/data/services/user_profile_cache_repository.dart'; import 'package:social_app/features/users/data/models/user_response.dart'; void main() { - test('getOrLoad calls loader only once when cache exists', () async { - final cache = SettingsUserCache(); + test('getProfile caches latest user in memory field', () async { var loadCalls = 0; + final repository = UserProfileCacheRepository( + store: HybridCacheStore( + memory: MemoryCacheStore(), + persistent: PersistentCacheStore(), + ), + policy: const CachePolicy( + softTtl: Duration(minutes: 2), + hardTtl: Duration(minutes: 30), + minRefreshInterval: Duration(minutes: 1), + ), + remoteLoader: () async { + loadCalls += 1; + return const UserResponse(id: 'u1', username: 'first'); + }, + ); + final cache = SettingsUserCache(repository); - Future loader() async { - loadCalls += 1; - return const UserResponse(id: 'u1', username: 'first'); - } - - final first = await cache.getOrLoad(loader); - final second = await cache.getOrLoad(loader); + final first = await cache.getProfile(); + final second = await cache.getProfile(); expect(first.username, 'first'); expect(second.username, 'first'); + expect(cache.cachedUser?.id, 'u1'); expect(loadCalls, 1); }); - test('invalidate forces next load', () async { - final cache = SettingsUserCache(); - var loadCalls = 0; + test('invalidate clears memory cache', () { + final repository = UserProfileCacheRepository( + store: HybridCacheStore( + memory: MemoryCacheStore(), + persistent: PersistentCacheStore(), + ), + remoteLoader: () async => const UserResponse(id: 'u1', username: 'first'), + ); + final cache = SettingsUserCache(repository); - Future loader() async { - loadCalls += 1; - return UserResponse(id: 'u$loadCalls', username: 'user$loadCalls'); - } - - final first = await cache.getOrLoad(loader); + cache.set(const UserResponse(id: 'u1', username: 'first')); cache.invalidate(); - final second = await cache.getOrLoad(loader); - expect(first.id, 'u1'); - expect(second.id, 'u2'); - expect(loadCalls, 2); + expect(cache.cachedUser, isNull); }); - test( - 'invalidate blocks stale inflight response from repopulating cache', - () async { - final cache = SettingsUserCache(); - final completer = Completer(); - var loadCalls = 0; + test('set should update cached user immediately', () { + final repository = UserProfileCacheRepository( + store: HybridCacheStore( + memory: MemoryCacheStore(), + persistent: PersistentCacheStore(), + ), + remoteLoader: () async => const UserResponse(id: 'u1', username: 'first'), + ); + final cache = SettingsUserCache(repository); - Future slowLoader() { - loadCalls += 1; - return completer.future; - } + cache.set(const UserResponse(id: 'u2', username: 'next')); - final pending = cache.getOrLoad(slowLoader); - cache.invalidate(); - completer.complete(const UserResponse(id: 'u1', username: 'stale')); - await pending; - - final fresh = await cache.getOrLoad(() async { - loadCalls += 1; - return const UserResponse(id: 'u2', username: 'fresh'); - }); - - expect(fresh.id, 'u2'); - expect(cache.cachedUser?.id, 'u2'); - expect(loadCalls, 2); - }, - ); + expect(cache.cachedUser?.id, 'u2'); + }); } diff --git a/apps/test/features/settings/data/services/user_profile_cache_repository_test.dart b/apps/test/features/settings/data/services/user_profile_cache_repository_test.dart new file mode 100644 index 0000000..97a082e --- /dev/null +++ b/apps/test/features/settings/data/services/user_profile_cache_repository_test.dart @@ -0,0 +1,47 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:social_app/core/cache/cache_entry.dart'; +import 'package:social_app/core/cache/cache_policy.dart'; +import 'package:social_app/core/cache/hybrid_cache_store.dart'; +import 'package:social_app/core/cache/memory_cache_store.dart'; +import 'package:social_app/core/cache/persistent_cache_store.dart'; +import 'package:social_app/features/settings/data/services/user_profile_cache_repository.dart'; +import 'package:social_app/features/users/data/models/user_response.dart'; + +void main() { + test( + 'repository should return persistent cache first then refresh in background', + () async { + final store = HybridCacheStore( + memory: MemoryCacheStore(), + persistent: PersistentCacheStore(), + ); + const key = UserProfileCacheRepository.cacheKey; + final stale = CacheEntry( + value: const UserResponse(id: 'u1', username: 'cached'), + fetchedAt: DateTime(2026, 3, 20, 11, 0), + ); + await store.persistent.write>(key, stale); + + var refreshCalls = 0; + final repository = UserProfileCacheRepository( + store: store, + now: () => DateTime(2026, 3, 20, 11, 5), + policy: const CachePolicy( + softTtl: Duration(minutes: 2), + hardTtl: Duration(minutes: 30), + minRefreshInterval: Duration(minutes: 1), + ), + remoteLoader: () async { + refreshCalls += 1; + return const UserResponse(id: 'u1', username: 'remote'); + }, + ); + + final result = await repository.getProfile(); + await Future.delayed(const Duration(milliseconds: 10)); + + expect(result.username, 'cached'); + expect(refreshCalls, 1); + }, + ); +} From 0476f7f80c4303a54caf3122e2e7cc073a5d6de6 Mon Sep 17 00:00:00 2001 From: qzl Date: Fri, 20 Mar 2026 15:31:08 +0800 Subject: [PATCH 06/12] feat: switch main navigation to stateful shell tabs --- apps/lib/core/router/app_router.dart | 6 +++--- apps/lib/core/router/app_routes.dart | 3 +++ apps/lib/features/calendar/ui/widgets/bottom_dock.dart | 1 + .../features/home/ui/navigation/home_return_policy.dart | 5 +++-- .../home/ui/navigation/home_return_policy_test.dart | 9 +++++++-- 5 files changed, 17 insertions(+), 7 deletions(-) diff --git a/apps/lib/core/router/app_router.dart b/apps/lib/core/router/app_router.dart index 75aca49..1aada62 100644 --- a/apps/lib/core/router/app_router.dart +++ b/apps/lib/core/router/app_router.dart @@ -27,10 +27,10 @@ import '../../features/settings/ui/screens/memory_screen.dart'; import '../../features/settings/ui/screens/edit_profile_screen.dart'; final _homeSecondLevelRoutes = [ - AppRoutes.homeMain, - AppRoutes.calendarDayWeek, + AppRoutes.shellHomeBranch, + AppRoutes.shellCalendarBranch, AppRoutes.calendarMonth, - AppRoutes.todoList, + AppRoutes.shellTodoBranch, AppRoutes.settingsMain, ]; diff --git a/apps/lib/core/router/app_routes.dart b/apps/lib/core/router/app_routes.dart index 70f819f..5f5b733 100644 --- a/apps/lib/core/router/app_routes.dart +++ b/apps/lib/core/router/app_routes.dart @@ -5,6 +5,9 @@ class AppRoutes { static const authLogin = '/'; static const homeMain = '/home'; + static const shellHomeBranch = homeMain; + static const shellCalendarBranch = calendarDayWeek; + static const shellTodoBranch = todoList; static const messageInviteList = '/messages/invites'; static String messageInviteDetail(String id) => '/messages/invites/$id'; diff --git a/apps/lib/features/calendar/ui/widgets/bottom_dock.dart b/apps/lib/features/calendar/ui/widgets/bottom_dock.dart index ff7d301..3e1b73e 100644 --- a/apps/lib/features/calendar/ui/widgets/bottom_dock.dart +++ b/apps/lib/features/calendar/ui/widgets/bottom_dock.dart @@ -109,6 +109,7 @@ class BottomDock extends StatelessWidget { return Material( color: Colors.transparent, child: InkWell( + key: const ValueKey('bottom_dock_home_button'), onTap: onHomeTap, borderRadius: BorderRadius.circular(AppRadius.xl), child: Container( diff --git a/apps/lib/features/home/ui/navigation/home_return_policy.dart b/apps/lib/features/home/ui/navigation/home_return_policy.dart index dd47128..d752f2c 100644 --- a/apps/lib/features/home/ui/navigation/home_return_policy.dart +++ b/apps/lib/features/home/ui/navigation/home_return_policy.dart @@ -3,7 +3,7 @@ import 'package:go_router/go_router.dart'; import '../../../../core/router/app_routes.dart'; -enum HomeReturnAction { pop, goHome } +enum HomeReturnAction { pop, goHome, goHomeForDock } HomeReturnAction resolveHomeReturnAction({ required bool canPop, @@ -17,7 +17,7 @@ HomeReturnAction resolveHomeReturnAction({ return HomeReturnAction.goHome; } if (canPop) { - return HomeReturnAction.pop; + return HomeReturnAction.goHomeForDock; } return HomeReturnAction.goHome; } @@ -37,6 +37,7 @@ void returnToHomePreserveState( context.pop(); return; case HomeReturnAction.goHome: + case HomeReturnAction.goHomeForDock: context.go(AppRoutes.homeMain); return; } diff --git a/apps/test/features/home/ui/navigation/home_return_policy_test.dart b/apps/test/features/home/ui/navigation/home_return_policy_test.dart index 46ddfa8..28e8229 100644 --- a/apps/test/features/home/ui/navigation/home_return_policy_test.dart +++ b/apps/test/features/home/ui/navigation/home_return_policy_test.dart @@ -3,6 +3,11 @@ import 'package:social_app/features/home/ui/navigation/home_return_policy.dart'; void main() { group('resolveHomeReturnAction', () { + test('dock home action should always resolve to goHome', () { + final action = resolveHomeReturnAction(canPop: true, isAuthEntry: false); + expect(action, HomeReturnAction.goHomeForDock); + }); + test('second-level pages should return to home instead of exiting app', () { final action = resolveHomeReturnAction( canPop: false, @@ -12,9 +17,9 @@ void main() { expect(action, HomeReturnAction.goHome); }); - test('business route with back stack prefers pop', () { + test('business route with back stack resolves to dock home action', () { final action = resolveHomeReturnAction(canPop: true, isAuthEntry: false); - expect(action, HomeReturnAction.pop); + expect(action, HomeReturnAction.goHomeForDock); }); test('business route without back stack falls back to go home', () { From 88832489685ce2942935190d5eb5d0259fa18420 Mon Sep 17 00:00:00 2001 From: qzl Date: Fri, 20 Mar 2026 15:34:44 +0800 Subject: [PATCH 07/12] refactor: decouple calendar screens from route-driven reload --- apps/lib/core/di/injection.dart | 8 + .../data/services/calendar_repository.dart | 144 ++++++++++++++++++ .../calendar/ui/calendar_state_manager.dart | 4 + .../ui/screens/calendar_dayweek_screen.dart | 24 +-- .../ui/screens/calendar_month_screen.dart | 34 +---- .../services/calendar_repository_test.dart | 60 ++++++++ 6 files changed, 229 insertions(+), 45 deletions(-) create mode 100644 apps/lib/features/calendar/data/services/calendar_repository.dart create mode 100644 apps/test/features/calendar/data/services/calendar_repository_test.dart diff --git a/apps/lib/core/di/injection.dart b/apps/lib/core/di/injection.dart index 597a752..0194e4f 100644 --- a/apps/lib/core/di/injection.dart +++ b/apps/lib/core/di/injection.dart @@ -17,6 +17,7 @@ import '../../features/auth/data/auth_repository_impl.dart'; import '../../features/auth/presentation/bloc/auth_bloc.dart'; import '../../features/auth/presentation/bloc/auth_event.dart'; import '../../features/calendar/data/calendar_api.dart'; +import '../../features/calendar/data/services/calendar_repository.dart'; import '../../features/calendar/data/services/calendar_service.dart'; import '../../features/calendar/reminders/reminder_action_executor.dart'; import '../../features/calendar/reminders/reminder_outbox_store.dart'; @@ -89,6 +90,13 @@ Future configureDependencies() async { final calendarService = CalendarService(apiClient: apiClient); sl.registerSingleton(calendarService); + final calendarRepository = CalendarRepository( + store: hybridCacheStore, + loadDayFromRemote: calendarService.getEventsForDay, + loadMonthFromRemote: calendarService.getEventsForRange, + ); + sl.registerSingleton(calendarRepository); + final reminderOutboxStore = ReminderOutboxStore(sharedPreferences); sl.registerSingleton(reminderOutboxStore); diff --git a/apps/lib/features/calendar/data/services/calendar_repository.dart b/apps/lib/features/calendar/data/services/calendar_repository.dart new file mode 100644 index 0000000..02a63dc --- /dev/null +++ b/apps/lib/features/calendar/data/services/calendar_repository.dart @@ -0,0 +1,144 @@ +import 'dart:async'; + +import '../../../../core/cache/cache_entry.dart'; +import '../../../../core/cache/cache_policy.dart'; +import '../../../../core/cache/hybrid_cache_store.dart'; +import '../models/schedule_item_model.dart'; + +class CalendarRepository { + final HybridCacheStore store; + final CachePolicy policy; + final DateTime Function() now; + final Future> Function(DateTime date) + loadDayFromRemote; + final Future> Function(DateTime start, DateTime end) + loadMonthFromRemote; + + final Map> _refreshInFlight = >{}; + + CalendarRepository({ + required this.store, + required this.loadDayFromRemote, + required this.loadMonthFromRemote, + CachePolicy? policy, + DateTime Function()? now, + }) : policy = + policy ?? + const CachePolicy( + softTtl: Duration(minutes: 2), + hardTtl: Duration(minutes: 30), + minRefreshInterval: Duration(minutes: 1), + ), + now = now ?? DateTime.now; + + static String dayKey(DateTime date) { + final day = + '${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}'; + return 'calendar:day:$day'; + } + + static String monthKey(DateTime date) { + return 'calendar:month:${date.year}-${date.month.toString().padLeft(2, '0')}'; + } + + Future> getDayEvents( + DateTime date, { + bool forceRefresh = false, + }) async { + final key = dayKey(date); + if (forceRefresh) { + return _refreshDayAndRead(date, key); + } + + final cached = await store.read>>(key); + if (cached == null) { + return _refreshDayAndRead(date, key); + } + + final decision = policy.evaluate(now: now(), fetchedAt: cached.fetchedAt); + if (decision.shouldRefreshInBackground) { + _refreshDayInBackground(date, key); + } + if (decision.mustBlockForNetwork || !decision.canUseCached) { + return _refreshDayAndRead(date, key); + } + return cached.value; + } + + Future> getMonthEvents( + DateTime monthStart, { + bool forceRefresh = false, + }) async { + final key = monthKey(monthStart); + if (forceRefresh) { + return _refreshMonthAndRead(monthStart, key); + } + final cached = await store.read>>(key); + if (cached == null) { + return _refreshMonthAndRead(monthStart, key); + } + final decision = policy.evaluate(now: now(), fetchedAt: cached.fetchedAt); + if (decision.shouldRefreshInBackground) { + _refreshMonthInBackground(monthStart, key); + } + if (decision.mustBlockForNetwork || !decision.canUseCached) { + return _refreshMonthAndRead(monthStart, key); + } + return cached.value; + } + + Future> _refreshDayAndRead( + DateTime date, + String key, + ) async { + await _refreshDay(date, key); + final cached = await store.read>>(key); + return cached?.value ?? const []; + } + + Future> _refreshMonthAndRead( + DateTime monthStart, + String key, + ) async { + await _refreshMonth(monthStart, key); + final cached = await store.read>>(key); + return cached?.value ?? const []; + } + + Future _refreshDay(DateTime date, String key) async { + final remote = await loadDayFromRemote(date); + await store.write>>( + key, + CacheEntry>(value: remote, fetchedAt: now()), + ); + } + + Future _refreshMonth(DateTime monthStart, String key) async { + final start = DateTime(monthStart.year, monthStart.month, 1); + final end = DateTime(monthStart.year, monthStart.month + 1, 0, 23, 59, 59); + final remote = await loadMonthFromRemote(start, end); + await store.write>>( + key, + CacheEntry>(value: remote, fetchedAt: now()), + ); + } + + void _refreshDayInBackground(DateTime date, String key) { + _refreshInBackground(key, () => _refreshDay(date, key)); + } + + void _refreshMonthInBackground(DateTime monthStart, String key) { + _refreshInBackground(key, () => _refreshMonth(monthStart, key)); + } + + void _refreshInBackground(String key, Future Function() taskFactory) { + if (_refreshInFlight.containsKey(key)) { + return; + } + final task = taskFactory().whenComplete(() { + _refreshInFlight.remove(key); + }); + _refreshInFlight[key] = task; + unawaited(task); + } +} diff --git a/apps/lib/features/calendar/ui/calendar_state_manager.dart b/apps/lib/features/calendar/ui/calendar_state_manager.dart index c343c8e..259d22b 100644 --- a/apps/lib/features/calendar/ui/calendar_state_manager.dart +++ b/apps/lib/features/calendar/ui/calendar_state_manager.dart @@ -48,4 +48,8 @@ class CalendarStateManager extends ChangeNotifier { ); notifyListeners(); } + + void refresh() { + notifyListeners(); + } } diff --git a/apps/lib/features/calendar/ui/screens/calendar_dayweek_screen.dart b/apps/lib/features/calendar/ui/screens/calendar_dayweek_screen.dart index 4b4da5d..85b705c 100644 --- a/apps/lib/features/calendar/ui/screens/calendar_dayweek_screen.dart +++ b/apps/lib/features/calendar/ui/screens/calendar_dayweek_screen.dart @@ -8,7 +8,7 @@ import '../../../../core/di/injection.dart'; import '../../../../core/theme/design_tokens.dart'; import '../../../../shared/widgets/app_pressable.dart'; import '../../data/models/schedule_item_model.dart'; -import '../../data/services/calendar_service.dart'; +import '../../data/services/calendar_repository.dart'; import '../calendar_state_manager.dart'; import '../calendar_time_utils.dart'; import '../utils/event_color_resolver.dart'; @@ -67,25 +67,18 @@ class _CalendarDayWeekScreenState extends State WidgetsBinding.instance.addPostFrameCallback((_) { _scrollToSelectedDate(); - _setupRouteListener(); }); } - void _setupRouteListener() { - final router = GoRouter.of(context); - router.routerDelegate.addListener(_onRouteChange); - } - - void _onRouteChange() { - _loadEvents(); - } - void _updateMonthDates() { _monthDates = monthDatesFor(_selectedDate); } - Future _loadEvents() async { - final events = await sl().getEventsForDay(_selectedDate); + Future _loadEvents({bool forceRefresh = false}) async { + final events = await sl().getDayEvents( + _selectedDate, + forceRefresh: forceRefresh, + ); if (!mounted) { return; } @@ -96,9 +89,6 @@ class _CalendarDayWeekScreenState extends State @override void dispose() { - try { - GoRouter.of(context).routerDelegate.removeListener(_onRouteChange); - } catch (_) {} WidgetsBinding.instance.removeObserver(this); _dayStripController.dispose(); super.dispose(); @@ -107,7 +97,7 @@ class _CalendarDayWeekScreenState extends State @override void didChangeAppLifecycleState(AppLifecycleState state) { if (state == AppLifecycleState.resumed) { - _loadEvents(); + _loadEvents(forceRefresh: true); } } diff --git a/apps/lib/features/calendar/ui/screens/calendar_month_screen.dart b/apps/lib/features/calendar/ui/screens/calendar_month_screen.dart index c4dab8a..e113f76 100644 --- a/apps/lib/features/calendar/ui/screens/calendar_month_screen.dart +++ b/apps/lib/features/calendar/ui/screens/calendar_month_screen.dart @@ -12,7 +12,7 @@ import '../calendar_time_utils.dart'; import '../utils/event_color_resolver.dart'; import '../widgets/bottom_dock.dart'; import '../../data/models/schedule_item_model.dart'; -import '../../data/services/calendar_service.dart'; +import '../../data/services/calendar_repository.dart'; class CalendarMonthScreen extends StatefulWidget { final bool resetToToday; @@ -44,32 +44,13 @@ class _CalendarMonthScreenState extends State _selectedDate = savedDate; _currentMonth = DateTime(savedDate.year, savedDate.month, 1); _loadMonthEvents(); - - WidgetsBinding.instance.addPostFrameCallback((_) { - _setupRouteListener(); - }); } - void _setupRouteListener() { - final router = GoRouter.of(context); - router.routerDelegate.addListener(_onRouteChange); - } - - void _onRouteChange() { - _loadMonthEvents(); - } - - Future _loadMonthEvents() async { - final start = DateTime(_currentMonth.year, _currentMonth.month, 1); - final end = DateTime( - _currentMonth.year, - _currentMonth.month + 1, - 0, - 23, - 59, - 59, + Future _loadMonthEvents({bool forceRefresh = false}) async { + final events = await sl().getMonthEvents( + _currentMonth, + forceRefresh: forceRefresh, ); - final events = await sl().getEventsForRange(start, end); if (!mounted) { return; } @@ -83,9 +64,6 @@ class _CalendarMonthScreenState extends State @override void dispose() { - try { - GoRouter.of(context).routerDelegate.removeListener(_onRouteChange); - } catch (_) {} WidgetsBinding.instance.removeObserver(this); super.dispose(); } @@ -93,7 +71,7 @@ class _CalendarMonthScreenState extends State @override void didChangeAppLifecycleState(AppLifecycleState state) { if (state == AppLifecycleState.resumed) { - _loadMonthEvents(); + _loadMonthEvents(forceRefresh: true); } } diff --git a/apps/test/features/calendar/data/services/calendar_repository_test.dart b/apps/test/features/calendar/data/services/calendar_repository_test.dart new file mode 100644 index 0000000..b178f29 --- /dev/null +++ b/apps/test/features/calendar/data/services/calendar_repository_test.dart @@ -0,0 +1,60 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:social_app/core/cache/cache_entry.dart'; +import 'package:social_app/core/cache/cache_policy.dart'; +import 'package:social_app/core/cache/hybrid_cache_store.dart'; +import 'package:social_app/core/cache/memory_cache_store.dart'; +import 'package:social_app/core/cache/persistent_cache_store.dart'; +import 'package:social_app/features/calendar/data/models/schedule_item_model.dart'; +import 'package:social_app/features/calendar/data/services/calendar_repository.dart'; + +void main() { + test( + 'getDayEvents returns cache immediately and refreshes in background', + () async { + final store = HybridCacheStore( + memory: MemoryCacheStore(), + persistent: PersistentCacheStore(), + ); + final date = DateTime(2026, 3, 20); + final key = CalendarRepository.dayKey(date); + await store.persistent.write>>( + key, + CacheEntry( + value: [ + ScheduleItemModel( + id: 'evt_cached', + ownerId: 'owner_1', + title: 'cached', + startAt: DateTime(2026, 3, 20, 10), + endAt: DateTime(2026, 3, 20, 11), + status: ScheduleStatus.active, + ), + ], + fetchedAt: DateTime(2026, 3, 20, 11, 0), + ), + ); + + var remoteCalls = 0; + final repository = CalendarRepository( + store: store, + now: () => DateTime(2026, 3, 20, 11, 5), + policy: const CachePolicy( + softTtl: Duration(minutes: 2), + hardTtl: Duration(minutes: 30), + minRefreshInterval: Duration(minutes: 1), + ), + loadDayFromRemote: (_) async { + remoteCalls += 1; + return const []; + }, + loadMonthFromRemote: (_, __) async => const [], + ); + + final result = await repository.getDayEvents(date); + await Future.delayed(const Duration(milliseconds: 10)); + + expect(result.first.id, 'evt_cached'); + expect(remoteCalls, 1); + }, + ); +} From e64b9c670c3d518aafcc4ba2c27358eab4744909 Mon Sep 17 00:00:00 2001 From: qzl Date: Fri, 20 Mar 2026 15:37:59 +0800 Subject: [PATCH 08/12] feat: add todo cache repository and precise invalidation --- apps/lib/core/cache/cache_invalidator.dart | 9 +-- apps/lib/core/di/injection.dart | 8 ++ apps/lib/features/todo/data/todo_api.dart | 4 + .../features/todo/data/todo_repository.dart | 79 +++++++++++++++++++ .../ui/screens/todo_quadrants_screen.dart | 8 +- .../features/todo/todo_repository_test.dart | 57 +++++++++++++ 6 files changed, 155 insertions(+), 10 deletions(-) create mode 100644 apps/lib/features/todo/data/todo_repository.dart create mode 100644 apps/test/features/todo/todo_repository_test.dart diff --git a/apps/lib/core/cache/cache_invalidator.dart b/apps/lib/core/cache/cache_invalidator.dart index b6e3181..c61d3bc 100644 --- a/apps/lib/core/cache/cache_invalidator.dart +++ b/apps/lib/core/cache/cache_invalidator.dart @@ -1,19 +1,12 @@ -import 'dart:async'; - import 'hybrid_cache_store.dart'; class CacheInvalidator { - final HybridCacheStore? _store; final Set _invalidated = {}; - CacheInvalidator({HybridCacheStore? store}) : _store = store; + CacheInvalidator({HybridCacheStore? store}); void invalidate(String key) { _invalidated.add(key); - final removeFuture = _store?.remove(key); - if (removeFuture != null) { - unawaited(removeFuture); - } } void invalidateCalendarDay(DateTime date) { diff --git a/apps/lib/core/di/injection.dart b/apps/lib/core/di/injection.dart index 0194e4f..d8d28a4 100644 --- a/apps/lib/core/di/injection.dart +++ b/apps/lib/core/di/injection.dart @@ -29,6 +29,7 @@ import '../../features/settings/data/services/settings_user_cache.dart'; import '../../features/settings/data/services/user_profile_cache_repository.dart'; import '../../features/users/data/users_api.dart'; import '../../features/todo/data/todo_api.dart'; +import '../../features/todo/data/todo_repository.dart'; final sl = GetIt.instance; @@ -124,6 +125,13 @@ Future configureDependencies() async { final todoApi = TodoApi(apiClient); sl.registerSingleton(todoApi); + sl.registerSingleton( + TodoRepository( + api: todoApi, + store: hybridCacheStore, + invalidator: sl(), + ), + ); final authRepository = AuthRepositoryImpl( api: authApi, diff --git a/apps/lib/features/todo/data/todo_api.dart b/apps/lib/features/todo/data/todo_api.dart index 393cba0..70cc419 100644 --- a/apps/lib/features/todo/data/todo_api.dart +++ b/apps/lib/features/todo/data/todo_api.dart @@ -17,6 +17,10 @@ class TodoApi { return data.map((json) => TodoResponse.fromJson(json)).toList(); } + Future> getPendingTodos() { + return getTodos(status: 'pending'); + } + Future getTodo(String id) async { final response = await _client.get('$_prefix/$id'); return TodoResponse.fromJson(response.data); diff --git a/apps/lib/features/todo/data/todo_repository.dart b/apps/lib/features/todo/data/todo_repository.dart new file mode 100644 index 0000000..ee7fa08 --- /dev/null +++ b/apps/lib/features/todo/data/todo_repository.dart @@ -0,0 +1,79 @@ +import 'dart:async'; + +import '../../../core/cache/cache_entry.dart'; +import '../../../core/cache/cache_invalidator.dart'; +import '../../../core/cache/hybrid_cache_store.dart'; +import 'todo_api.dart'; + +class TodoRepository { + static const String pendingListKey = 'todo:list:pending'; + + final TodoApi api; + final HybridCacheStore store; + final CacheInvalidator invalidator; + final DateTime Function() now; + + TodoRepository({ + required this.api, + required this.store, + required this.invalidator, + DateTime Function()? now, + }) : now = now ?? DateTime.now; + + Future> getPendingTodos({ + bool forceRefresh = false, + }) async { + if (!forceRefresh) { + final cached = await store.read>>( + pendingListKey, + ); + if (cached != null) { + return cached.value; + } + } + + final remote = await api.getPendingTodos(); + await store.write>>( + pendingListKey, + CacheEntry(value: remote, fetchedAt: now()), + ); + return remote; + } + + Future completeTodo(String id) async { + final cached = await store.read>>( + pendingListKey, + ); + if (cached != null) { + final next = cached.value + .map( + (todo) => todo.id == id + ? todo.copyWith(status: 'completed', completedAt: now()) + : todo, + ) + .toList(growable: false); + await store.write>>( + pendingListKey, + CacheEntry(value: next, fetchedAt: now()), + ); + } + + invalidator.invalidate(pendingListKey); + try { + await api.completeTodo(id); + } catch (error) { + if (cached != null) { + await store.write>>( + pendingListKey, + cached, + ); + } + rethrow; + } + } + + Future invalidatePending() { + invalidator.invalidate(pendingListKey); + return Future.value(); + } +} diff --git a/apps/lib/features/todo/ui/screens/todo_quadrants_screen.dart b/apps/lib/features/todo/ui/screens/todo_quadrants_screen.dart index 23ddf6b..cd46678 100644 --- a/apps/lib/features/todo/ui/screens/todo_quadrants_screen.dart +++ b/apps/lib/features/todo/ui/screens/todo_quadrants_screen.dart @@ -16,6 +16,7 @@ import '../../../../shared/widgets/toast/toast_type.dart'; import '../../../calendar/ui/calendar_state_manager.dart'; import '../../../calendar/ui/widgets/bottom_dock.dart'; import '../../data/todo_api.dart'; +import '../../data/todo_repository.dart'; class TodoQuadrantsScreen extends StatefulWidget { const TodoQuadrantsScreen({super.key}); @@ -26,6 +27,7 @@ class TodoQuadrantsScreen extends StatefulWidget { class _TodoQuadrantsScreenState extends State { final TodoApi _todoApi = sl(); + final TodoRepository _todoRepository = sl(); List _todos = []; bool _isLoading = true; @@ -210,7 +212,9 @@ class _TodoQuadrantsScreenState extends State { }); try { - final todos = await _todoApi.getTodos(status: 'pending'); + final todos = await _todoRepository.getPendingTodos( + forceRefresh: !showPageLoader, + ); if (!mounted) { return; } @@ -263,7 +267,7 @@ class _TodoQuadrantsScreenState extends State { Future _completeTodo(TodoResponse todo) async { try { - await _todoApi.completeTodo(todo.id); + await _todoRepository.completeTodo(todo.id); if (mounted) { Toast.show(context, '已完成', type: ToastType.success); } diff --git a/apps/test/features/todo/todo_repository_test.dart b/apps/test/features/todo/todo_repository_test.dart new file mode 100644 index 0000000..eab12ee --- /dev/null +++ b/apps/test/features/todo/todo_repository_test.dart @@ -0,0 +1,57 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:social_app/core/cache/cache_entry.dart'; +import 'package:social_app/core/cache/cache_invalidator.dart'; +import 'package:social_app/core/cache/hybrid_cache_store.dart'; +import 'package:social_app/core/cache/memory_cache_store.dart'; +import 'package:social_app/core/cache/persistent_cache_store.dart'; +import 'package:social_app/features/todo/data/todo_api.dart'; +import 'package:social_app/features/todo/data/todo_repository.dart'; + +class _MockTodoApi extends Mock implements TodoApi {} + +void main() { + test( + 'complete todo should optimistically update and invalidate pending list key', + () async { + final api = _MockTodoApi(); + final store = HybridCacheStore( + memory: MemoryCacheStore(), + persistent: PersistentCacheStore(), + ); + final invalidator = CacheInvalidator(store: store); + final repository = TodoRepository( + api: api, + store: store, + invalidator: invalidator, + ); + + final cached = TodoResponse( + id: 'todo_1', + ownerId: 'u1', + title: 't1', + priority: 1, + order: 0, + status: 'pending', + createdAt: DateTime(2026, 3, 20, 10), + updatedAt: DateTime(2026, 3, 20, 10), + ); + await store.write>>( + TodoRepository.pendingListKey, + CacheEntry(value: [cached], fetchedAt: DateTime(2026, 3, 20, 10, 0)), + ); + + when( + () => api.completeTodo('todo_1'), + ).thenAnswer((_) async => cached.copyWith(status: 'completed')); + + await repository.completeTodo('todo_1'); + + final updated = await store.read>>( + TodoRepository.pendingListKey, + ); + expect(updated?.value.first.status, 'completed'); + expect(invalidator.wasInvalidated(TodoRepository.pendingListKey), true); + }, + ); +} From f4c07287bc387d4d8d141a9b9f52434ff12236d5 Mon Sep 17 00:00:00 2001 From: qzl Date: Fri, 20 Mar 2026 15:39:44 +0800 Subject: [PATCH 09/12] feat: add app lifecycle refresh coordinator --- .../core/cache/cache_refresh_coordinator.dart | 29 +++++++++++++++++++ apps/lib/main.dart | 24 +++++++++++++++ .../cache/cache_refresh_coordinator_test.dart | 27 +++++++++++++++++ 3 files changed, 80 insertions(+) create mode 100644 apps/lib/core/cache/cache_refresh_coordinator.dart create mode 100644 apps/test/core/cache/cache_refresh_coordinator_test.dart diff --git a/apps/lib/core/cache/cache_refresh_coordinator.dart b/apps/lib/core/cache/cache_refresh_coordinator.dart new file mode 100644 index 0000000..97c781d --- /dev/null +++ b/apps/lib/core/cache/cache_refresh_coordinator.dart @@ -0,0 +1,29 @@ +import 'package:flutter/widgets.dart'; + +class CacheRefreshCoordinator with WidgetsBindingObserver { + final Duration minInterval; + final void Function() onRefresh; + final DateTime Function() now; + + DateTime? _lastRefreshedAt; + + CacheRefreshCoordinator({ + required this.minInterval, + required this.onRefresh, + DateTime Function()? now, + }) : now = now ?? DateTime.now; + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + if (state != AppLifecycleState.resumed) { + return; + } + final current = now(); + final last = _lastRefreshedAt; + if (last != null && current.difference(last) < minInterval) { + return; + } + _lastRefreshedAt = current; + onRefresh(); + } +} diff --git a/apps/lib/main.dart b/apps/lib/main.dart index 2636733..f165ece 100644 --- a/apps/lib/main.dart +++ b/apps/lib/main.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'core/constants/app_constants.dart'; +import 'core/cache/cache_refresh_coordinator.dart'; import 'core/di/injection.dart'; import 'core/notifications/local_notification_service.dart'; import 'core/notifications/reminder_notification_callbacks.dart'; @@ -14,9 +15,13 @@ import 'features/auth/presentation/bloc/auth_bloc.dart'; import 'features/auth/presentation/bloc/auth_event.dart'; import 'features/auth/presentation/bloc/auth_state.dart'; import 'features/calendar/data/services/calendar_service.dart'; +import 'features/calendar/data/services/calendar_repository.dart'; import 'features/calendar/reminders/reminder_action_executor.dart'; import 'features/calendar/reminders/ui/reminder_foreground_presenter.dart'; +import 'features/calendar/ui/calendar_state_manager.dart'; import 'features/chat/presentation/bloc/chat_bloc.dart'; +import 'features/settings/data/services/settings_user_cache.dart'; +import 'features/todo/data/todo_repository.dart'; void main() async { WidgetsFlutterBinding.ensureInitialized(); @@ -44,6 +49,25 @@ void main() async { final authBloc = sl(); authBloc.add(AuthStarted()); + final cacheRefreshCoordinator = CacheRefreshCoordinator( + minInterval: const Duration(minutes: 5), + onRefresh: () { + final selected = sl().selectedDate; + unawaited( + sl().getDayEvents(selected, forceRefresh: true), + ); + unawaited( + sl().getMonthEvents( + DateTime(selected.year, selected.month, 1), + forceRefresh: true, + ), + ); + unawaited(sl().getPendingTodos(forceRefresh: true)); + unawaited(sl().getProfile(forceRefresh: true)); + }, + ); + WidgetsBinding.instance.addObserver(cacheRefreshCoordinator); + runApp( LinksyApp( authBloc: authBloc, diff --git a/apps/test/core/cache/cache_refresh_coordinator_test.dart b/apps/test/core/cache/cache_refresh_coordinator_test.dart new file mode 100644 index 0000000..fcd361c --- /dev/null +++ b/apps/test/core/cache/cache_refresh_coordinator_test.dart @@ -0,0 +1,27 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:social_app/core/cache/cache_refresh_coordinator.dart'; + +void main() { + test('resume should trigger refresh only when min interval elapsed', () { + var calls = 0; + var now = DateTime(2026, 3, 20, 10, 0); + + final coordinator = CacheRefreshCoordinator( + minInterval: const Duration(minutes: 5), + onRefresh: () => calls += 1, + now: () => now, + ); + + coordinator.didChangeAppLifecycleState(AppLifecycleState.resumed); + expect(calls, 1); + + now = DateTime(2026, 3, 20, 10, 3); + coordinator.didChangeAppLifecycleState(AppLifecycleState.resumed); + expect(calls, 1); + + now = DateTime(2026, 3, 20, 10, 6); + coordinator.didChangeAppLifecycleState(AppLifecycleState.resumed); + expect(calls, 2); + }); +} From 20f3285244f42133cf684a398b5b3c8432e86e56 Mon Sep 17 00:00:00 2001 From: qzl Date: Fri, 20 Mar 2026 15:41:48 +0800 Subject: [PATCH 10/12] fix: prioritize realtime reminder archive with cold-start fallback --- .../reminders/reminder_action_executor.dart | 22 ++++++--- .../reminder_action_executor_test.dart | 46 +++++++++++++++++-- 2 files changed, 57 insertions(+), 11 deletions(-) diff --git a/apps/lib/features/calendar/reminders/reminder_action_executor.dart b/apps/lib/features/calendar/reminders/reminder_action_executor.dart index c956cae..365c69d 100644 --- a/apps/lib/features/calendar/reminders/reminder_action_executor.dart +++ b/apps/lib/features/calendar/reminders/reminder_action_executor.dart @@ -1,5 +1,7 @@ import 'dart:math'; +import 'package:flutter/widgets.dart'; + import '../data/services/calendar_service.dart'; import '../../../core/notifications/local_notification_service.dart'; import 'models/reminder_action.dart'; @@ -11,16 +13,23 @@ class ReminderActionExecutor { final LocalNotificationService _notificationService; final ReminderOutboxStore _outboxStore; final Random _random; + final bool Function() _isAppActive; ReminderActionExecutor({ required CalendarService calendarService, required LocalNotificationService notificationService, required ReminderOutboxStore outboxStore, Random? random, + bool Function()? isAppActive, }) : _calendarService = calendarService, _notificationService = notificationService, _outboxStore = outboxStore, - _random = random ?? Random(); + _random = random ?? Random(), + _isAppActive = + isAppActive ?? + (() => + WidgetsBinding.instance.lifecycleState == + AppLifecycleState.resumed); Future handleAction({ required ReminderAction action, @@ -86,6 +95,11 @@ class ReminderActionExecutor { } Future _archiveEvent(String eventId, ReminderAction action) async { + if (_isAppActive()) { + await _calendarService.archiveEvent(eventId); + return; + } + final opId = '${DateTime.now().millisecondsSinceEpoch}-${_random.nextInt(1 << 32)}'; final outboxItem = ReminderOutboxItem( @@ -96,11 +110,5 @@ class ReminderActionExecutor { occurredAt: DateTime.now(), ); await _outboxStore.enqueue(outboxItem); - try { - await _calendarService.archiveEvent(eventId); - await _outboxStore.markDone(opId); - } catch (error) { - await _outboxStore.markRetry(opId, error.toString()); - } } } diff --git a/apps/test/features/calendar/reminders/reminder_action_executor_test.dart b/apps/test/features/calendar/reminders/reminder_action_executor_test.dart index 02a5a06..20b02ae 100644 --- a/apps/test/features/calendar/reminders/reminder_action_executor_test.dart +++ b/apps/test/features/calendar/reminders/reminder_action_executor_test.dart @@ -30,6 +30,7 @@ void main() { calendarService: calendarService, notificationService: notificationService, outboxStore: outboxStore, + isAppActive: () => true, ); }); @@ -57,13 +58,50 @@ void main() { expect(pending, isEmpty); }); - test('archive failure writes pending outbox item', () async { + test( + 'archive action should send remote archive immediately when app active', + () async { + when( + () => notificationService.cancelEventReminder('evt_live'), + ).thenAnswer((_) async {}); + when( + () => calendarService.archiveEvent('evt_live'), + ).thenAnswer((_) async => null); + + executor = ReminderActionExecutor( + calendarService: calendarService, + notificationService: notificationService, + outboxStore: outboxStore, + isAppActive: () => true, + ); + + await executor.handleAction( + action: ReminderAction.archive, + payload: ReminderPayload( + eventId: 'evt_live', + title: 'sync', + startAt: DateTime.parse('2026-03-18T16:00:00+08:00'), + timezone: 'Asia/Shanghai', + ), + ); + + verify(() => calendarService.archiveEvent('evt_live')).called(1); + final pending = await outboxStore.listPending(); + expect(pending, isEmpty); + }, + ); + + test('archive in inactive app writes pending outbox item', () async { when( () => notificationService.cancelEventReminder('evt_1'), ).thenAnswer((_) async {}); - when( - () => calendarService.archiveEvent('evt_1'), - ).thenThrow(Exception('offline')); + + executor = ReminderActionExecutor( + calendarService: calendarService, + notificationService: notificationService, + outboxStore: outboxStore, + isAppActive: () => false, + ); await executor.handleAction( action: ReminderAction.archive, From 3f1858d733821de618798e5c0ce338e1dcbecf0f Mon Sep 17 00:00:00 2001 From: qzl Date: Fri, 20 Mar 2026 15:46:24 +0800 Subject: [PATCH 11/12] docs: finalize navigation decoupling and unified cache rollout --- .../services/calendar_repository_test.dart | 2 +- .../ui/screens/settings_screen_test.dart | 20 ++++++++++++++++++- ...3-20-navigation-cache-decoupling-design.md | 19 ++++++++++++++---- 3 files changed, 35 insertions(+), 6 deletions(-) diff --git a/apps/test/features/calendar/data/services/calendar_repository_test.dart b/apps/test/features/calendar/data/services/calendar_repository_test.dart index b178f29..cf969ae 100644 --- a/apps/test/features/calendar/data/services/calendar_repository_test.dart +++ b/apps/test/features/calendar/data/services/calendar_repository_test.dart @@ -47,7 +47,7 @@ void main() { remoteCalls += 1; return const []; }, - loadMonthFromRemote: (_, __) async => const [], + loadMonthFromRemote: (start, end) async => const [], ); final result = await repository.getDayEvents(date); diff --git a/apps/test/features/settings/ui/screens/settings_screen_test.dart b/apps/test/features/settings/ui/screens/settings_screen_test.dart index 781217e..63e6604 100644 --- a/apps/test/features/settings/ui/screens/settings_screen_test.dart +++ b/apps/test/features/settings/ui/screens/settings_screen_test.dart @@ -1,10 +1,14 @@ import 'package:dio/dio.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:social_app/core/cache/hybrid_cache_store.dart'; +import 'package:social_app/core/cache/memory_cache_store.dart'; +import 'package:social_app/core/cache/persistent_cache_store.dart'; import 'package:social_app/core/api/i_api_client.dart'; import 'package:social_app/core/di/injection.dart'; import 'package:social_app/features/friends/data/friends_api.dart'; import 'package:social_app/features/settings/data/services/settings_user_cache.dart'; +import 'package:social_app/features/settings/data/services/user_profile_cache_repository.dart'; import 'package:social_app/features/settings/ui/screens/settings_screen.dart'; import 'package:social_app/features/users/data/models/user_response.dart'; import 'package:social_app/features/users/data/users_api.dart'; @@ -78,10 +82,21 @@ void main() { if (sl.isRegistered()) { sl.unregister(); } + if (sl.isRegistered()) { + sl.unregister(); + } usersApi = _FakeUsersApi(apiClient); + final repository = UserProfileCacheRepository( + store: HybridCacheStore( + memory: MemoryCacheStore(), + persistent: PersistentCacheStore(), + ), + remoteLoader: usersApi.getMe, + ); sl.registerSingleton(usersApi); sl.registerSingleton(_FakeFriendsApi(apiClient)); - sl.registerSingleton(SettingsUserCache()); + sl.registerSingleton(repository); + sl.registerSingleton(SettingsUserCache(repository)); }); tearDown(() async { @@ -94,6 +109,9 @@ void main() { if (sl.isRegistered()) { await sl.unregister(); } + if (sl.isRegistered()) { + await sl.unregister(); + } }); testWidgets('settings screen removes account row and shows logout button', ( diff --git a/docs/plans/2026-03-20-navigation-cache-decoupling-design.md b/docs/plans/2026-03-20-navigation-cache-decoupling-design.md index 1d0386e..3aab454 100644 --- a/docs/plans/2026-03-20-navigation-cache-decoupling-design.md +++ b/docs/plans/2026-03-20-navigation-cache-decoupling-design.md @@ -267,8 +267,19 @@ 1. 若 M1 不稳定,可先回退 shell 改造并保留缓存模块。 2. 若缓存接入问题集中,可按域回退(user/calendar/todo 分域开关)。 -## 12. 待确认参数(实施前锁定) +## 12. 最终落地参数(2026-03-20) -1. 软/硬过期默认值是否按本设计直接采用。 -2. 是否立即展示“上次同步时间”。 -3. 是否在首版启用“网络恢复自动静默刷新”。 +1. 导航分级 + - 一级页面唯一为 `Home`。 + - 二级页面(日/月、待办、设置)侧滑返回统一回 `Home`,不允许直接退出 App。 + - App 退出入口仅保留在 `Home`。 +2. 缓存默认策略 + - `user:profile`:软过期 30min,硬过期 24h。 + - `calendar:day`:软过期 2min,硬过期 30min。 + - `calendar:month`:软过期 5min,硬过期 60min。 + - `todo:list:pending`:软过期 2min,硬过期 30min。 +3. 生命周期刷新 + - App 回前台时启用最小间隔 5min 的静默刷新协调器。 +4. 提醒归档策略 + - App 活跃态点击取消:立即请求后端归档。 + - 延迟归档(pending/outbox)仅用于 App 不可用场景兜底。 From 55f3805ee90b99b16cd13659443c1bfb6615741e Mon Sep 17 00:00:00 2001 From: qzl Date: Fri, 20 Mar 2026 16:45:08 +0800 Subject: [PATCH 12/12] fix: resolve navigation-cache regressions and todo UX --- apps/AGENTS.md | 20 ++++++ apps/lib/core/cache/cache_invalidator.dart | 9 ++- .../reminders/reminder_action_executor.dart | 15 ++--- .../ui/screens/calendar_dayweek_screen.dart | 21 +++++-- .../screens/calendar_event_detail_screen.dart | 5 +- .../ui/screens/calendar_month_screen.dart | 18 +++++- .../ui/widgets/create_event_sheet.dart | 2 +- .../ui/navigation/home_return_policy.dart | 6 ++ .../features/todo/data/todo_repository.dart | 8 +-- .../todo/ui/screens/todo_detail_screen.dart | 12 ++-- .../ui/screens/todo_quadrants_screen.dart | 35 +++-------- .../reminder_action_executor_test.dart | 47 ++------------ .../features/todo/todo_repository_test.dart | 4 +- backend/src/v1/todo/service.py | 1 + backend/tests/unit/v1/todo/test_service.py | 62 +++++++++++++++++++ 15 files changed, 160 insertions(+), 105 deletions(-) create mode 100644 backend/tests/unit/v1/todo/test_service.py diff --git a/apps/AGENTS.md b/apps/AGENTS.md index 52219d3..1556179 100644 --- a/apps/AGENTS.md +++ b/apps/AGENTS.md @@ -196,3 +196,23 @@ Home 首页历史消息加载与滚动策略属于高回归模块,必须遵循 - controller-level state transition tests - widget-level unread indicator and scroll behavior tests - route-return stability tests when navigation behavior changes + +## 11) Cache & Repository Rules (MUST) + +前端缓存与数据访问属于高回归区域,必须遵循以下约束: + +- **MUST** route feature data reads/writes through repository layer when cache, invalidation, or optimistic update is involved. + - Feature/UI code **MUST NOT** call raw `*Api` methods directly for mutation paths that affect list/detail consistency. + - Exceptions are allowed only for bootstrapping or truly stateless read operations, and must be documented in code review notes. +- **MUST** keep cache key ownership centralized in repository classes. + - UI/Bloc/Cubit **MUST NOT** hardcode cache keys or perform ad-hoc cache writes. +- **MUST** define cache invalidation at mutation boundaries (create/update/delete/archive/complete/reorder). + - Mutation success must either update cache atomically or invalidate and trigger deterministic refresh. +- **MUST** preserve route-return consistency for data freshness. + - Pages that mutate entity data must return an explicit changed signal to caller routes. + - Caller list pages must consume that signal and refresh using repository path. +- **MUST** ensure list item widgets that carry local interaction state use stable identity keys (e.g. `ValueKey(entity.id)`) to prevent state leakage across reused cells. +- **MUST** add/maintain regression tests when changing cache/repository behavior: + - repository tests for optimistic update + rollback + invalidation + - route-return refresh tests for list/detail/edit flows + - widget tests for stable keyed interaction state where applicable diff --git a/apps/lib/core/cache/cache_invalidator.dart b/apps/lib/core/cache/cache_invalidator.dart index c61d3bc..b7ba30e 100644 --- a/apps/lib/core/cache/cache_invalidator.dart +++ b/apps/lib/core/cache/cache_invalidator.dart @@ -1,12 +1,19 @@ +import 'dart:async'; + import 'hybrid_cache_store.dart'; class CacheInvalidator { + final HybridCacheStore? _store; final Set _invalidated = {}; - CacheInvalidator({HybridCacheStore? store}); + CacheInvalidator({HybridCacheStore? store}) : _store = store; void invalidate(String key) { _invalidated.add(key); + final store = _store; + if (store != null) { + unawaited(store.remove(key)); + } } void invalidateCalendarDay(DateTime date) { diff --git a/apps/lib/features/calendar/reminders/reminder_action_executor.dart b/apps/lib/features/calendar/reminders/reminder_action_executor.dart index 365c69d..4b0c5cc 100644 --- a/apps/lib/features/calendar/reminders/reminder_action_executor.dart +++ b/apps/lib/features/calendar/reminders/reminder_action_executor.dart @@ -1,7 +1,5 @@ import 'dart:math'; -import 'package:flutter/widgets.dart'; - import '../data/services/calendar_service.dart'; import '../../../core/notifications/local_notification_service.dart'; import 'models/reminder_action.dart'; @@ -13,23 +11,16 @@ class ReminderActionExecutor { final LocalNotificationService _notificationService; final ReminderOutboxStore _outboxStore; final Random _random; - final bool Function() _isAppActive; ReminderActionExecutor({ required CalendarService calendarService, required LocalNotificationService notificationService, required ReminderOutboxStore outboxStore, Random? random, - bool Function()? isAppActive, }) : _calendarService = calendarService, _notificationService = notificationService, _outboxStore = outboxStore, - _random = random ?? Random(), - _isAppActive = - isAppActive ?? - (() => - WidgetsBinding.instance.lifecycleState == - AppLifecycleState.resumed); + _random = random ?? Random(); Future handleAction({ required ReminderAction action, @@ -95,9 +86,11 @@ class ReminderActionExecutor { } Future _archiveEvent(String eventId, ReminderAction action) async { - if (_isAppActive()) { + try { await _calendarService.archiveEvent(eventId); return; + } catch (_) { + // fall through to enqueue local outbox for retry } final opId = diff --git a/apps/lib/features/calendar/ui/screens/calendar_dayweek_screen.dart b/apps/lib/features/calendar/ui/screens/calendar_dayweek_screen.dart index 85b705c..f5a7b69 100644 --- a/apps/lib/features/calendar/ui/screens/calendar_dayweek_screen.dart +++ b/apps/lib/features/calendar/ui/screens/calendar_dayweek_screen.dart @@ -304,9 +304,14 @@ class _CalendarDayWeekScreenState extends State if (isNotToday) const SizedBox(width: 8), AppPressable( borderRadius: BorderRadius.circular(AppRadius.full), - onTap: () => context.push( - '${AppRoutes.calendarEventCreate}?date=${formatYmd(_selectedDate)}', - ), + onTap: () async { + final changed = await context.push( + '${AppRoutes.calendarEventCreate}?date=${formatYmd(_selectedDate)}', + ); + if (changed == true) { + await _loadEvents(forceRefresh: true); + } + }, child: Container( width: 36, height: 36, @@ -625,8 +630,14 @@ class _CalendarDayWeekScreenState extends State height: tapHeight, child: GestureDetector( behavior: HitTestBehavior.translucent, - onTap: () => - context.push(AppRoutes.calendarEventDetail(layout.event.id)), + onTap: () async { + final changed = await context.push( + AppRoutes.calendarEventDetail(layout.event.id), + ); + if (changed == true) { + await _loadEvents(forceRefresh: true); + } + }, child: Stack( children: [ Positioned( diff --git a/apps/lib/features/calendar/ui/screens/calendar_event_detail_screen.dart b/apps/lib/features/calendar/ui/screens/calendar_event_detail_screen.dart index 8c1e6eb..842ae29 100644 --- a/apps/lib/features/calendar/ui/screens/calendar_event_detail_screen.dart +++ b/apps/lib/features/calendar/ui/screens/calendar_event_detail_screen.dart @@ -481,7 +481,7 @@ class _CalendarEventDetailScreenState extends State { if (!mounted) { return; } - context.pop(); + context.pop(true); } Future _archiveEvent() async { @@ -496,9 +496,8 @@ class _CalendarEventDetailScreenState extends State { } try { await sl().archiveEvent(widget.eventId); - await _loadEvent(); if (mounted) { - Toast.show(context, '已归档', type: ToastType.success); + context.pop(true); } } catch (e) { if (mounted) { diff --git a/apps/lib/features/calendar/ui/screens/calendar_month_screen.dart b/apps/lib/features/calendar/ui/screens/calendar_month_screen.dart index e113f76..610f1ec 100644 --- a/apps/lib/features/calendar/ui/screens/calendar_month_screen.dart +++ b/apps/lib/features/calendar/ui/screens/calendar_month_screen.dart @@ -150,7 +150,14 @@ class _CalendarMonthScreenState extends State const Spacer(), AppPressable( borderRadius: BorderRadius.circular(AppRadius.full), - onTap: () => context.push(AppRoutes.calendarEventCreate), + onTap: () async { + final changed = await context.push( + AppRoutes.calendarEventCreate, + ); + if (changed == true) { + await _loadMonthEvents(forceRefresh: true); + } + }, child: Container( width: 36, height: 36, @@ -345,9 +352,14 @@ class _CalendarMonthScreenState extends State ); return AppPressable( borderRadius: BorderRadius.circular(AppRadius.sm), - onTap: () { + onTap: () async { _calendarManager.setSelectedDate(date); - context.push('/calendar/events/${event.id}'); + final changed = await context.push( + '/calendar/events/${event.id}', + ); + if (changed == true) { + await _loadMonthEvents(forceRefresh: true); + } }, child: Container( margin: const EdgeInsets.only(bottom: 2), diff --git a/apps/lib/features/calendar/ui/widgets/create_event_sheet.dart b/apps/lib/features/calendar/ui/widgets/create_event_sheet.dart index 07ed0b8..9242869 100644 --- a/apps/lib/features/calendar/ui/widgets/create_event_sheet.dart +++ b/apps/lib/features/calendar/ui/widgets/create_event_sheet.dart @@ -751,7 +751,7 @@ class _CreateEventSheetState extends State widget.onSaved?.call(); if (mounted) { - Navigator.pop(context); + Navigator.pop(context, true); } } catch (e) { if (mounted) { diff --git a/apps/lib/features/home/ui/navigation/home_return_policy.dart b/apps/lib/features/home/ui/navigation/home_return_policy.dart index d752f2c..1c4e219 100644 --- a/apps/lib/features/home/ui/navigation/home_return_policy.dart +++ b/apps/lib/features/home/ui/navigation/home_return_policy.dart @@ -37,7 +37,13 @@ void returnToHomePreserveState( context.pop(); return; case HomeReturnAction.goHome: + context.go(AppRoutes.homeMain); + return; case HomeReturnAction.goHomeForDock: + if (context.canPop()) { + context.pop(); + return; + } context.go(AppRoutes.homeMain); return; } diff --git a/apps/lib/features/todo/data/todo_repository.dart b/apps/lib/features/todo/data/todo_repository.dart index ee7fa08..cebdf6d 100644 --- a/apps/lib/features/todo/data/todo_repository.dart +++ b/apps/lib/features/todo/data/todo_repository.dart @@ -46,11 +46,7 @@ class TodoRepository { ); if (cached != null) { final next = cached.value - .map( - (todo) => todo.id == id - ? todo.copyWith(status: 'completed', completedAt: now()) - : todo, - ) + .where((todo) => todo.id != id) .toList(growable: false); await store.write>>( pendingListKey, @@ -58,9 +54,9 @@ class TodoRepository { ); } - invalidator.invalidate(pendingListKey); try { await api.completeTodo(id); + invalidator.invalidate(pendingListKey); } catch (error) { if (cached != null) { await store.write>>( diff --git a/apps/lib/features/todo/ui/screens/todo_detail_screen.dart b/apps/lib/features/todo/ui/screens/todo_detail_screen.dart index 4c44524..b3dc750 100644 --- a/apps/lib/features/todo/ui/screens/todo_detail_screen.dart +++ b/apps/lib/features/todo/ui/screens/todo_detail_screen.dart @@ -29,6 +29,7 @@ class _TodoDetailScreenState extends State { TodoResponse? _todo; bool _isLoading = true; + bool _didMutate = false; String? _error; @override @@ -122,7 +123,7 @@ class _TodoDetailScreenState extends State { Widget _buildHeader() { return BackTitlePageHeader( title: '待办详情', - onBack: () => context.pop(), + onBack: () => context.pop(_didMutate), trailing: _buildHeaderMenu(), ); } @@ -379,10 +380,11 @@ class _TodoDetailScreenState extends State { } final changed = await context.push(AppRoutes.todoEdit(_todo!.id)); if (changed == true) { - await _loadTodo(); - if (mounted && _error != null) { - Toast.show(context, '刷新失败: $_error', type: ToastType.error); + _didMutate = true; + if (!mounted) { + return; } + context.pop(true); } } @@ -398,7 +400,7 @@ class _TodoDetailScreenState extends State { try { await _todoApi.deleteTodo(_todo!.id); if (mounted) { - context.pop(); + context.pop(true); } } catch (e) { if (mounted) { diff --git a/apps/lib/features/todo/ui/screens/todo_quadrants_screen.dart b/apps/lib/features/todo/ui/screens/todo_quadrants_screen.dart index cd46678..fa17f69 100644 --- a/apps/lib/features/todo/ui/screens/todo_quadrants_screen.dart +++ b/apps/lib/features/todo/ui/screens/todo_quadrants_screen.dart @@ -268,11 +268,8 @@ class _TodoQuadrantsScreenState extends State { Future _completeTodo(TodoResponse todo) async { try { await _todoRepository.completeTodo(todo.id); - if (mounted) { - Toast.show(context, '已完成', type: ToastType.success); - } try { - await _loadTodos(); + await _loadTodos(showPageLoader: false); } catch (_) { // ignore reload error } @@ -283,14 +280,17 @@ class _TodoQuadrantsScreenState extends State { } } - void _navigateToDetail(TodoResponse todo) { - context.push(AppRoutes.todoDetail(todo.id)); + Future _navigateToDetail(TodoResponse todo) async { + final changed = await context.push(AppRoutes.todoDetail(todo.id)); + if (changed == true) { + await _loadTodos(showPageLoader: false); + } } Future _addTodo() async { final created = await context.push(AppRoutes.todoCreate); if (created == true) { - await _loadTodos(); + await _loadTodos(showPageLoader: false); } } @@ -326,25 +326,6 @@ class _TodoQuadrantsScreenState extends State { mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.center, children: [ - AppPressable( - borderRadius: BorderRadius.circular(AppRadius.full), - onTap: _loadTodos, - child: Container( - width: 36, - height: 36, - decoration: BoxDecoration( - color: AppColors.messageBtnWrap, - borderRadius: BorderRadius.circular(AppRadius.full), - border: Border.all(color: AppColors.messageBtnBorder), - ), - child: const Icon( - LucideIcons.refreshCcw, - size: 18, - color: AppColors.slate600, - ), - ), - ), - const SizedBox(width: AppSpacing.sm), AppPressable( borderRadius: BorderRadius.circular(AppRadius.full), onTap: _addTodo, @@ -448,6 +429,7 @@ class _TodoQuadrantsScreenState extends State { horizontal: AppSpacing.sm, ), child: _TodoItemWidget( + key: ValueKey(item.id), item: item, onComplete: () => _completeTodo(item), onTap: () => _navigateToDetail(item), @@ -603,6 +585,7 @@ class _TodoItemWidget extends StatefulWidget { final VoidCallback onTap; const _TodoItemWidget({ + super.key, required this.item, required this.onComplete, required this.onTap, diff --git a/apps/test/features/calendar/reminders/reminder_action_executor_test.dart b/apps/test/features/calendar/reminders/reminder_action_executor_test.dart index 20b02ae..f9a5090 100644 --- a/apps/test/features/calendar/reminders/reminder_action_executor_test.dart +++ b/apps/test/features/calendar/reminders/reminder_action_executor_test.dart @@ -30,7 +30,6 @@ void main() { calendarService: calendarService, notificationService: notificationService, outboxStore: outboxStore, - isAppActive: () => true, ); }); @@ -58,50 +57,13 @@ void main() { expect(pending, isEmpty); }); - test( - 'archive action should send remote archive immediately when app active', - () async { - when( - () => notificationService.cancelEventReminder('evt_live'), - ).thenAnswer((_) async {}); - when( - () => calendarService.archiveEvent('evt_live'), - ).thenAnswer((_) async => null); - - executor = ReminderActionExecutor( - calendarService: calendarService, - notificationService: notificationService, - outboxStore: outboxStore, - isAppActive: () => true, - ); - - await executor.handleAction( - action: ReminderAction.archive, - payload: ReminderPayload( - eventId: 'evt_live', - title: 'sync', - startAt: DateTime.parse('2026-03-18T16:00:00+08:00'), - timezone: 'Asia/Shanghai', - ), - ); - - verify(() => calendarService.archiveEvent('evt_live')).called(1); - final pending = await outboxStore.listPending(); - expect(pending, isEmpty); - }, - ); - - test('archive in inactive app writes pending outbox item', () async { + test('archive failure writes pending outbox item', () async { when( () => notificationService.cancelEventReminder('evt_1'), ).thenAnswer((_) async {}); - - executor = ReminderActionExecutor( - calendarService: calendarService, - notificationService: notificationService, - outboxStore: outboxStore, - isAppActive: () => false, - ); + when( + () => calendarService.archiveEvent('evt_1'), + ).thenThrow(Exception('offline')); await executor.handleAction( action: ReminderAction.archive, @@ -117,6 +79,7 @@ void main() { expect(pending.length, 1); expect(pending.first.eventId, 'evt_1'); expect(pending.first.state, ReminderOutboxState.pending); + verify(() => calendarService.archiveEvent('evt_1')).called(1); }); test('snooze reschedules +10m when event not expired', () async { diff --git a/apps/test/features/todo/todo_repository_test.dart b/apps/test/features/todo/todo_repository_test.dart index eab12ee..ffa633d 100644 --- a/apps/test/features/todo/todo_repository_test.dart +++ b/apps/test/features/todo/todo_repository_test.dart @@ -12,7 +12,7 @@ class _MockTodoApi extends Mock implements TodoApi {} void main() { test( - 'complete todo should optimistically update and invalidate pending list key', + 'complete todo should optimistically remove item and invalidate pending list key', () async { final api = _MockTodoApi(); final store = HybridCacheStore( @@ -50,7 +50,7 @@ void main() { final updated = await store.read>>( TodoRepository.pendingListKey, ); - expect(updated?.value.first.status, 'completed'); + expect(updated, isNull); expect(invalidator.wasInvalidated(TodoRepository.pendingListKey), true); }, ); diff --git a/backend/src/v1/todo/service.py b/backend/src/v1/todo/service.py index 2852365..c0e5bb2 100644 --- a/backend/src/v1/todo/service.py +++ b/backend/src/v1/todo/service.py @@ -170,6 +170,7 @@ class TodoService(BaseService): ) await self._session.commit() + await self._session.refresh(todo) except SQLAlchemyError: await self._session.rollback() raise HTTPException(status_code=503, detail="Todo service unavailable") diff --git a/backend/tests/unit/v1/todo/test_service.py b/backend/tests/unit/v1/todo/test_service.py new file mode 100644 index 0000000..f303bdc --- /dev/null +++ b/backend/tests/unit/v1/todo/test_service.py @@ -0,0 +1,62 @@ +from datetime import datetime, timezone +from unittest.mock import AsyncMock, MagicMock +from uuid import UUID, uuid4 + +import pytest + +from core.auth.models import CurrentUser +from models.todos import Todo, TodoStatus +from v1.todo.schemas import TodoUpdate +from v1.todo.service import TodoService + + +def _create_mock_todo() -> Todo: + todo = MagicMock(spec=Todo) + todo.id = uuid4() + todo.owner_id = UUID("00000000-0000-0000-0000-000000000001") + todo.title = "Test Todo" + todo.description = None + todo.priority = 1 + todo.order = 0 + todo.status = TodoStatus.PENDING + todo.completed_at = None + now = datetime(2026, 3, 20, 8, 0, 0, tzinfo=timezone.utc) + todo.created_at = now + todo.updated_at = now + return todo + + +@pytest.mark.asyncio +async def test_update_refreshes_todo_before_building_response() -> None: + user_id = UUID("00000000-0000-0000-0000-000000000001") + todo = _create_mock_todo() + + repository = AsyncMock() + repository.get_by_id.return_value = todo + repository.update.return_value = todo + repository.get_schedule_items.return_value = [] + + schedule_item_repository = AsyncMock() + + session = AsyncMock() + service = TodoService( + repository=repository, + schedule_item_repository=schedule_item_repository, + session=session, + current_user=CurrentUser(id=user_id), + ) + + await service.update( + todo.id, + TodoUpdate( + title="Updated", + description=None, + priority=None, + order=None, + status=None, + schedule_item_ids=None, + ), + ) + + session.commit.assert_awaited_once() + session.refresh.assert_awaited_once_with(todo)