merge: integrate navigation cache decoupling feature

This commit is contained in:
qzl
2026-03-20 16:45:52 +08:00
44 changed files with 1169 additions and 202 deletions
+20
View File
@@ -196,3 +196,23 @@ Home 首页历史消息加载与滚动策略属于高回归模块,必须遵循
- controller-level state transition tests - controller-level state transition tests
- widget-level unread indicator and scroll behavior tests - widget-level unread indicator and scroll behavior tests
- route-return stability tests when navigation behavior changes - 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
+6
View File
@@ -0,0 +1,6 @@
class CacheEntry<T> {
final T value;
final DateTime fetchedAt;
const CacheEntry({required this.value, required this.fetchedAt});
}
+27
View File
@@ -0,0 +1,27 @@
import 'dart:async';
import 'hybrid_cache_store.dart';
class CacheInvalidator {
final HybridCacheStore? _store;
final Set<String> _invalidated = <String>{};
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) {
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);
}
+17
View File
@@ -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;
}
+49
View File
@@ -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,
);
}
}
+29
View File
@@ -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();
}
}
+5
View File
@@ -0,0 +1,5 @@
abstract class CacheStore {
Future<T?> read<T>(String key);
Future<void> write<T>(String key, T value);
Future<void> remove(String key);
}
+55
View File
@@ -0,0 +1,55 @@
import 'memory_cache_store.dart';
import 'persistent_cache_store.dart';
class HybridCacheStore {
final MemoryCacheStore memory;
final PersistentCacheStore persistent;
final Map<String, Future<dynamic>> _inflight = <String, Future<dynamic>>{};
HybridCacheStore({required this.memory, required this.persistent});
Future<T?> read<T>(String key) async {
final memoryValue = await memory.read<T>(key);
if (memoryValue != null) {
return memoryValue;
}
final persistentValue = await persistent.read<T>(key);
if (persistentValue != null) {
await memory.write(key, persistentValue);
}
return persistentValue;
}
Future<void> write<T>(String key, T value) async {
await memory.write<T>(key, value);
await persistent.write<T>(key, value);
}
Future<void> remove(String key) async {
await memory.remove(key);
await persistent.remove(key);
}
Future<T> getOrLoad<T>(String key, {required Future<T> Function() loader}) {
final running = _inflight[key];
if (running != null) {
return running.then((value) => value as T);
}
final future = () async {
final cached = await read<T>(key);
if (cached != null) {
return cached;
}
final loaded = await loader();
await write<T>(key, loaded);
return loaded;
}();
_inflight[key] = future;
return future.whenComplete(() {
_inflight.remove(key);
});
}
}
+24
View File
@@ -0,0 +1,24 @@
import 'cache_store.dart';
class MemoryCacheStore implements CacheStore {
final Map<String, Object?> _values = <String, Object?>{};
@override
Future<T?> read<T>(String key) async {
final value = _values[key];
if (value is T) {
return value;
}
return null;
}
@override
Future<void> write<T>(String key, T value) async {
_values[key] = value;
}
@override
Future<void> remove(String key) async {
_values.remove(key);
}
}
+24
View File
@@ -0,0 +1,24 @@
import 'cache_store.dart';
class PersistentCacheStore implements CacheStore {
final Map<String, Object?> _values = <String, Object?>{};
@override
Future<T?> read<T>(String key) async {
final value = _values[key];
if (value is T) {
return value;
}
return null;
}
@override
Future<void> write<T>(String key, T value) async {
_values[key] = value;
}
@override
Future<void> remove(String key) async {
_values.remove(key);
}
}
+43 -1
View File
@@ -2,6 +2,10 @@ import 'package:dio/dio.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:get_it/get_it.dart'; import 'package:get_it/get_it.dart';
import 'package:shared_preferences/shared_preferences.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/api_client.dart';
import '../api/i_api_client.dart'; import '../api/i_api_client.dart';
import '../storage/token_storage.dart'; import '../storage/token_storage.dart';
@@ -13,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_bloc.dart';
import '../../features/auth/presentation/bloc/auth_event.dart'; import '../../features/auth/presentation/bloc/auth_event.dart';
import '../../features/calendar/data/calendar_api.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/data/services/calendar_service.dart';
import '../../features/calendar/reminders/reminder_action_executor.dart'; import '../../features/calendar/reminders/reminder_action_executor.dart';
import '../../features/calendar/reminders/reminder_outbox_store.dart'; import '../../features/calendar/reminders/reminder_outbox_store.dart';
@@ -21,8 +26,10 @@ import '../../features/friends/data/friends_api.dart';
import '../../features/messages/data/inbox_api.dart'; import '../../features/messages/data/inbox_api.dart';
import '../../features/settings/data/settings_api.dart'; import '../../features/settings/data/settings_api.dart';
import '../../features/settings/data/services/settings_user_cache.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/users/data/users_api.dart';
import '../../features/todo/data/todo_api.dart'; import '../../features/todo/data/todo_api.dart';
import '../../features/todo/data/todo_repository.dart';
final sl = GetIt.instance; final sl = GetIt.instance;
@@ -56,15 +63,41 @@ Future<void> configureDependencies() async {
final sharedPreferences = await SharedPreferences.getInstance(); final sharedPreferences = await SharedPreferences.getInstance();
sl.registerSingleton<SharedPreferences>(sharedPreferences); sl.registerSingleton<SharedPreferences>(sharedPreferences);
final memoryCacheStore = MemoryCacheStore();
final persistentCacheStore = PersistentCacheStore();
final hybridCacheStore = HybridCacheStore(
memory: memoryCacheStore,
persistent: persistentCacheStore,
);
sl.registerSingleton<MemoryCacheStore>(memoryCacheStore);
sl.registerSingleton<PersistentCacheStore>(persistentCacheStore);
sl.registerSingleton<HybridCacheStore>(hybridCacheStore);
sl.registerSingleton<CacheInvalidator>(
CacheInvalidator(store: hybridCacheStore),
);
final usersApi = UsersApi(apiClient); final usersApi = UsersApi(apiClient);
sl.registerSingleton<UsersApi>(usersApi); sl.registerSingleton<UsersApi>(usersApi);
final userProfileCacheRepository = UserProfileCacheRepository(
store: hybridCacheStore,
remoteLoader: usersApi.getMe,
);
sl.registerSingleton<UserProfileCacheRepository>(userProfileCacheRepository);
final calendarApi = CalendarApi(apiClient); final calendarApi = CalendarApi(apiClient);
sl.registerSingleton<CalendarApi>(calendarApi); sl.registerSingleton<CalendarApi>(calendarApi);
final calendarService = CalendarService(apiClient: apiClient); final calendarService = CalendarService(apiClient: apiClient);
sl.registerSingleton<CalendarService>(calendarService); sl.registerSingleton<CalendarService>(calendarService);
final calendarRepository = CalendarRepository(
store: hybridCacheStore,
loadDayFromRemote: calendarService.getEventsForDay,
loadMonthFromRemote: calendarService.getEventsForRange,
);
sl.registerSingleton<CalendarRepository>(calendarRepository);
final reminderOutboxStore = ReminderOutboxStore(sharedPreferences); final reminderOutboxStore = ReminderOutboxStore(sharedPreferences);
sl.registerSingleton<ReminderOutboxStore>(reminderOutboxStore); sl.registerSingleton<ReminderOutboxStore>(reminderOutboxStore);
@@ -83,13 +116,22 @@ Future<void> configureDependencies() async {
final settingsApi = SettingsApi(apiClient); final settingsApi = SettingsApi(apiClient);
sl.registerSingleton<SettingsApi>(settingsApi); sl.registerSingleton<SettingsApi>(settingsApi);
sl.registerSingleton<SettingsUserCache>(SettingsUserCache()); sl.registerSingleton<SettingsUserCache>(
SettingsUserCache(userProfileCacheRepository),
);
final inboxApi = InboxApi(apiClient); final inboxApi = InboxApi(apiClient);
sl.registerSingleton<InboxApi>(inboxApi); sl.registerSingleton<InboxApi>(inboxApi);
final todoApi = TodoApi(apiClient); final todoApi = TodoApi(apiClient);
sl.registerSingleton<TodoApi>(todoApi); sl.registerSingleton<TodoApi>(todoApi);
sl.registerSingleton<TodoRepository>(
TodoRepository(
api: todoApi,
store: hybridCacheStore,
invalidator: sl<CacheInvalidator>(),
),
);
final authRepository = AuthRepositoryImpl( final authRepository = AuthRepositoryImpl(
api: authApi, api: authApi,
+9 -5
View File
@@ -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/memory_screen.dart';
import '../../features/settings/ui/screens/edit_profile_screen.dart'; import '../../features/settings/ui/screens/edit_profile_screen.dart';
final _homeSecondLevelRoutes = [
AppRoutes.shellHomeBranch,
AppRoutes.shellCalendarBranch,
AppRoutes.calendarMonth,
AppRoutes.shellTodoBranch,
AppRoutes.settingsMain,
];
final _protectedRoutes = [ final _protectedRoutes = [
AppRoutes.homeMain, ..._homeSecondLevelRoutes,
AppRoutes.contactsList, AppRoutes.contactsList,
AppRoutes.contactsAdd, AppRoutes.contactsAdd,
AppRoutes.calendarDayWeek,
AppRoutes.calendarMonth,
'/calendar/events', '/calendar/events',
AppRoutes.todoList,
AppRoutes.settingsMain,
AppRoutes.settingsFeatures, AppRoutes.settingsFeatures,
AppRoutes.settingsMemory, AppRoutes.settingsMemory,
AppRoutes.settingsEditProfile, AppRoutes.settingsEditProfile,
+3
View File
@@ -5,6 +5,9 @@ class AppRoutes {
static const authLogin = '/'; static const authLogin = '/';
static const homeMain = '/home'; static const homeMain = '/home';
static const shellHomeBranch = homeMain;
static const shellCalendarBranch = calendarDayWeek;
static const shellTodoBranch = todoList;
static const messageInviteList = '/messages/invites'; static const messageInviteList = '/messages/invites';
static String messageInviteDetail(String id) => '/messages/invites/$id'; static String messageInviteDetail(String id) => '/messages/invites/$id';
@@ -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<List<ScheduleItemModel>> Function(DateTime date)
loadDayFromRemote;
final Future<List<ScheduleItemModel>> Function(DateTime start, DateTime end)
loadMonthFromRemote;
final Map<String, Future<void>> _refreshInFlight = <String, Future<void>>{};
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<List<ScheduleItemModel>> getDayEvents(
DateTime date, {
bool forceRefresh = false,
}) async {
final key = dayKey(date);
if (forceRefresh) {
return _refreshDayAndRead(date, key);
}
final cached = await store.read<CacheEntry<List<ScheduleItemModel>>>(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<List<ScheduleItemModel>> getMonthEvents(
DateTime monthStart, {
bool forceRefresh = false,
}) async {
final key = monthKey(monthStart);
if (forceRefresh) {
return _refreshMonthAndRead(monthStart, key);
}
final cached = await store.read<CacheEntry<List<ScheduleItemModel>>>(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<List<ScheduleItemModel>> _refreshDayAndRead(
DateTime date,
String key,
) async {
await _refreshDay(date, key);
final cached = await store.read<CacheEntry<List<ScheduleItemModel>>>(key);
return cached?.value ?? const <ScheduleItemModel>[];
}
Future<List<ScheduleItemModel>> _refreshMonthAndRead(
DateTime monthStart,
String key,
) async {
await _refreshMonth(monthStart, key);
final cached = await store.read<CacheEntry<List<ScheduleItemModel>>>(key);
return cached?.value ?? const <ScheduleItemModel>[];
}
Future<void> _refreshDay(DateTime date, String key) async {
final remote = await loadDayFromRemote(date);
await store.write<CacheEntry<List<ScheduleItemModel>>>(
key,
CacheEntry<List<ScheduleItemModel>>(value: remote, fetchedAt: now()),
);
}
Future<void> _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<CacheEntry<List<ScheduleItemModel>>>(
key,
CacheEntry<List<ScheduleItemModel>>(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<void> Function() taskFactory) {
if (_refreshInFlight.containsKey(key)) {
return;
}
final task = taskFactory().whenComplete(() {
_refreshInFlight.remove(key);
});
_refreshInFlight[key] = task;
unawaited(task);
}
}
@@ -86,6 +86,13 @@ class ReminderActionExecutor {
} }
Future<void> _archiveEvent(String eventId, ReminderAction action) async { Future<void> _archiveEvent(String eventId, ReminderAction action) async {
try {
await _calendarService.archiveEvent(eventId);
return;
} catch (_) {
// fall through to enqueue local outbox for retry
}
final opId = final opId =
'${DateTime.now().millisecondsSinceEpoch}-${_random.nextInt(1 << 32)}'; '${DateTime.now().millisecondsSinceEpoch}-${_random.nextInt(1 << 32)}';
final outboxItem = ReminderOutboxItem( final outboxItem = ReminderOutboxItem(
@@ -96,11 +103,5 @@ class ReminderActionExecutor {
occurredAt: DateTime.now(), occurredAt: DateTime.now(),
); );
await _outboxStore.enqueue(outboxItem); await _outboxStore.enqueue(outboxItem);
try {
await _calendarService.archiveEvent(eventId);
await _outboxStore.markDone(opId);
} catch (error) {
await _outboxStore.markRetry(opId, error.toString());
}
} }
} }
@@ -48,4 +48,8 @@ class CalendarStateManager extends ChangeNotifier {
); );
notifyListeners(); notifyListeners();
} }
void refresh() {
notifyListeners();
}
} }
@@ -8,7 +8,7 @@ import '../../../../core/di/injection.dart';
import '../../../../core/theme/design_tokens.dart'; import '../../../../core/theme/design_tokens.dart';
import '../../../../shared/widgets/app_pressable.dart'; import '../../../../shared/widgets/app_pressable.dart';
import '../../data/models/schedule_item_model.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_state_manager.dart';
import '../calendar_time_utils.dart'; import '../calendar_time_utils.dart';
import '../utils/event_color_resolver.dart'; import '../utils/event_color_resolver.dart';
@@ -67,25 +67,18 @@ class _CalendarDayWeekScreenState extends State<CalendarDayWeekScreen>
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
_scrollToSelectedDate(); _scrollToSelectedDate();
_setupRouteListener();
}); });
} }
void _setupRouteListener() {
final router = GoRouter.of(context);
router.routerDelegate.addListener(_onRouteChange);
}
void _onRouteChange() {
_loadEvents();
}
void _updateMonthDates() { void _updateMonthDates() {
_monthDates = monthDatesFor(_selectedDate); _monthDates = monthDatesFor(_selectedDate);
} }
Future<void> _loadEvents() async { Future<void> _loadEvents({bool forceRefresh = false}) async {
final events = await sl<CalendarService>().getEventsForDay(_selectedDate); final events = await sl<CalendarRepository>().getDayEvents(
_selectedDate,
forceRefresh: forceRefresh,
);
if (!mounted) { if (!mounted) {
return; return;
} }
@@ -96,9 +89,6 @@ class _CalendarDayWeekScreenState extends State<CalendarDayWeekScreen>
@override @override
void dispose() { void dispose() {
try {
GoRouter.of(context).routerDelegate.removeListener(_onRouteChange);
} catch (_) {}
WidgetsBinding.instance.removeObserver(this); WidgetsBinding.instance.removeObserver(this);
_dayStripController.dispose(); _dayStripController.dispose();
super.dispose(); super.dispose();
@@ -107,7 +97,7 @@ class _CalendarDayWeekScreenState extends State<CalendarDayWeekScreen>
@override @override
void didChangeAppLifecycleState(AppLifecycleState state) { void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.resumed) { if (state == AppLifecycleState.resumed) {
_loadEvents(); _loadEvents(forceRefresh: true);
} }
} }
@@ -119,7 +109,7 @@ class _CalendarDayWeekScreenState extends State<CalendarDayWeekScreen>
canPop: false, canPop: false,
onPopInvokedWithResult: (didPop, result) { onPopInvokedWithResult: (didPop, result) {
if (!didPop) { if (!didPop) {
returnToHomePreserveState(context); returnToHomePreserveState(context, forceGoHome: true);
} }
}, },
child: SafeArea( child: SafeArea(
@@ -314,9 +304,14 @@ class _CalendarDayWeekScreenState extends State<CalendarDayWeekScreen>
if (isNotToday) const SizedBox(width: 8), if (isNotToday) const SizedBox(width: 8),
AppPressable( AppPressable(
borderRadius: BorderRadius.circular(AppRadius.full), borderRadius: BorderRadius.circular(AppRadius.full),
onTap: () => context.push( onTap: () async {
'${AppRoutes.calendarEventCreate}?date=${formatYmd(_selectedDate)}', final changed = await context.push<bool>(
), '${AppRoutes.calendarEventCreate}?date=${formatYmd(_selectedDate)}',
);
if (changed == true) {
await _loadEvents(forceRefresh: true);
}
},
child: Container( child: Container(
width: 36, width: 36,
height: 36, height: 36,
@@ -635,8 +630,14 @@ class _CalendarDayWeekScreenState extends State<CalendarDayWeekScreen>
height: tapHeight, height: tapHeight,
child: GestureDetector( child: GestureDetector(
behavior: HitTestBehavior.translucent, behavior: HitTestBehavior.translucent,
onTap: () => onTap: () async {
context.push(AppRoutes.calendarEventDetail(layout.event.id)), final changed = await context.push<bool>(
AppRoutes.calendarEventDetail(layout.event.id),
);
if (changed == true) {
await _loadEvents(forceRefresh: true);
}
},
child: Stack( child: Stack(
children: [ children: [
Positioned( Positioned(
@@ -702,7 +703,7 @@ class _CalendarDayWeekScreenState extends State<CalendarDayWeekScreen>
_calendarManager.setViewType(CalendarViewType.day); _calendarManager.setViewType(CalendarViewType.day);
context.push(AppRoutes.calendarMonth); context.push(AppRoutes.calendarMonth);
}, },
onHomeTap: () => returnToHomePreserveState(context), onHomeTap: () => returnToHomePreserveState(context, forceGoHome: true),
); );
} }
} }
@@ -481,7 +481,7 @@ class _CalendarEventDetailScreenState extends State<CalendarEventDetailScreen> {
if (!mounted) { if (!mounted) {
return; return;
} }
context.pop(); context.pop(true);
} }
Future<void> _archiveEvent() async { Future<void> _archiveEvent() async {
@@ -496,9 +496,8 @@ class _CalendarEventDetailScreenState extends State<CalendarEventDetailScreen> {
} }
try { try {
await sl<CalendarService>().archiveEvent(widget.eventId); await sl<CalendarService>().archiveEvent(widget.eventId);
await _loadEvent();
if (mounted) { if (mounted) {
Toast.show(context, '已归档', type: ToastType.success); context.pop(true);
} }
} catch (e) { } catch (e) {
if (mounted) { if (mounted) {
@@ -12,7 +12,7 @@ import '../calendar_time_utils.dart';
import '../utils/event_color_resolver.dart'; import '../utils/event_color_resolver.dart';
import '../widgets/bottom_dock.dart'; import '../widgets/bottom_dock.dart';
import '../../data/models/schedule_item_model.dart'; import '../../data/models/schedule_item_model.dart';
import '../../data/services/calendar_service.dart'; import '../../data/services/calendar_repository.dart';
class CalendarMonthScreen extends StatefulWidget { class CalendarMonthScreen extends StatefulWidget {
final bool resetToToday; final bool resetToToday;
@@ -44,32 +44,13 @@ class _CalendarMonthScreenState extends State<CalendarMonthScreen>
_selectedDate = savedDate; _selectedDate = savedDate;
_currentMonth = DateTime(savedDate.year, savedDate.month, 1); _currentMonth = DateTime(savedDate.year, savedDate.month, 1);
_loadMonthEvents(); _loadMonthEvents();
WidgetsBinding.instance.addPostFrameCallback((_) {
_setupRouteListener();
});
} }
void _setupRouteListener() { Future<void> _loadMonthEvents({bool forceRefresh = false}) async {
final router = GoRouter.of(context); final events = await sl<CalendarRepository>().getMonthEvents(
router.routerDelegate.addListener(_onRouteChange); _currentMonth,
} forceRefresh: forceRefresh,
void _onRouteChange() {
_loadMonthEvents();
}
Future<void> _loadMonthEvents() async {
final start = DateTime(_currentMonth.year, _currentMonth.month, 1);
final end = DateTime(
_currentMonth.year,
_currentMonth.month + 1,
0,
23,
59,
59,
); );
final events = await sl<CalendarService>().getEventsForRange(start, end);
if (!mounted) { if (!mounted) {
return; return;
} }
@@ -83,9 +64,6 @@ class _CalendarMonthScreenState extends State<CalendarMonthScreen>
@override @override
void dispose() { void dispose() {
try {
GoRouter.of(context).routerDelegate.removeListener(_onRouteChange);
} catch (_) {}
WidgetsBinding.instance.removeObserver(this); WidgetsBinding.instance.removeObserver(this);
super.dispose(); super.dispose();
} }
@@ -93,7 +71,7 @@ class _CalendarMonthScreenState extends State<CalendarMonthScreen>
@override @override
void didChangeAppLifecycleState(AppLifecycleState state) { void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.resumed) { if (state == AppLifecycleState.resumed) {
_loadMonthEvents(); _loadMonthEvents(forceRefresh: true);
} }
} }
@@ -105,7 +83,7 @@ class _CalendarMonthScreenState extends State<CalendarMonthScreen>
canPop: false, canPop: false,
onPopInvokedWithResult: (didPop, result) { onPopInvokedWithResult: (didPop, result) {
if (!didPop) { if (!didPop) {
returnToHomePreserveState(context); returnToHomePreserveState(context, forceGoHome: true);
} }
}, },
child: SafeArea( child: SafeArea(
@@ -172,7 +150,14 @@ class _CalendarMonthScreenState extends State<CalendarMonthScreen>
const Spacer(), const Spacer(),
AppPressable( AppPressable(
borderRadius: BorderRadius.circular(AppRadius.full), borderRadius: BorderRadius.circular(AppRadius.full),
onTap: () => context.push(AppRoutes.calendarEventCreate), onTap: () async {
final changed = await context.push<bool>(
AppRoutes.calendarEventCreate,
);
if (changed == true) {
await _loadMonthEvents(forceRefresh: true);
}
},
child: Container( child: Container(
width: 36, width: 36,
height: 36, height: 36,
@@ -367,9 +352,14 @@ class _CalendarMonthScreenState extends State<CalendarMonthScreen>
); );
return AppPressable( return AppPressable(
borderRadius: BorderRadius.circular(AppRadius.sm), borderRadius: BorderRadius.circular(AppRadius.sm),
onTap: () { onTap: () async {
_calendarManager.setSelectedDate(date); _calendarManager.setSelectedDate(date);
context.push('/calendar/events/${event.id}'); final changed = await context.push<bool>(
'/calendar/events/${event.id}',
);
if (changed == true) {
await _loadMonthEvents(forceRefresh: true);
}
}, },
child: Container( child: Container(
margin: const EdgeInsets.only(bottom: 2), margin: const EdgeInsets.only(bottom: 2),
@@ -522,7 +512,7 @@ class _CalendarMonthScreenState extends State<CalendarMonthScreen>
context.push(AppRoutes.todoList); context.push(AppRoutes.todoList);
}, },
onCalendarTap: () {}, onCalendarTap: () {},
onHomeTap: () => returnToHomePreserveState(context), onHomeTap: () => returnToHomePreserveState(context, forceGoHome: true),
); );
} }
} }
@@ -109,6 +109,7 @@ class BottomDock extends StatelessWidget {
return Material( return Material(
color: Colors.transparent, color: Colors.transparent,
child: InkWell( child: InkWell(
key: const ValueKey('bottom_dock_home_button'),
onTap: onHomeTap, onTap: onHomeTap,
borderRadius: BorderRadius.circular(AppRadius.xl), borderRadius: BorderRadius.circular(AppRadius.xl),
child: Container( child: Container(
@@ -751,7 +751,7 @@ class _CreateEventSheetState extends State<CreateEventSheet>
widget.onSaved?.call(); widget.onSaved?.call();
if (mounted) { if (mounted) {
Navigator.pop(context); Navigator.pop(context, true);
} }
} catch (e) { } catch (e) {
if (mounted) { if (mounted) {
@@ -3,17 +3,21 @@ import 'package:go_router/go_router.dart';
import '../../../../core/router/app_routes.dart'; import '../../../../core/router/app_routes.dart';
enum HomeReturnAction { pop, goHome } enum HomeReturnAction { pop, goHome, goHomeForDock }
HomeReturnAction resolveHomeReturnAction({ HomeReturnAction resolveHomeReturnAction({
required bool canPop, required bool canPop,
required bool isAuthEntry, required bool isAuthEntry,
bool forceGoHome = false,
}) { }) {
if (forceGoHome) {
return HomeReturnAction.goHome;
}
if (isAuthEntry) { if (isAuthEntry) {
return HomeReturnAction.goHome; return HomeReturnAction.goHome;
} }
if (canPop) { if (canPop) {
return HomeReturnAction.pop; return HomeReturnAction.goHomeForDock;
} }
return HomeReturnAction.goHome; return HomeReturnAction.goHome;
} }
@@ -21,10 +25,12 @@ HomeReturnAction resolveHomeReturnAction({
void returnToHomePreserveState( void returnToHomePreserveState(
BuildContext context, { BuildContext context, {
bool isAuthEntry = false, bool isAuthEntry = false,
bool forceGoHome = false,
}) { }) {
final action = resolveHomeReturnAction( final action = resolveHomeReturnAction(
canPop: context.canPop(), canPop: context.canPop(),
isAuthEntry: isAuthEntry, isAuthEntry: isAuthEntry,
forceGoHome: forceGoHome,
); );
switch (action) { switch (action) {
case HomeReturnAction.pop: case HomeReturnAction.pop:
@@ -33,5 +39,12 @@ void returnToHomePreserveState(
case HomeReturnAction.goHome: case HomeReturnAction.goHome:
context.go(AppRoutes.homeMain); context.go(AppRoutes.homeMain);
return; return;
case HomeReturnAction.goHomeForDock:
if (context.canPop()) {
context.pop();
return;
}
context.go(AppRoutes.homeMain);
return;
} }
} }
@@ -1,49 +1,30 @@
import 'dart:async';
import '../../../users/data/models/user_response.dart'; import '../../../users/data/models/user_response.dart';
import 'user_profile_cache_repository.dart';
class SettingsUserCache { class SettingsUserCache {
final UserProfileCacheRepository _repository;
SettingsUserCache(this._repository);
UserResponse? _cachedUser; UserResponse? _cachedUser;
Future<UserResponse>? _inflight;
int _generation = 0;
UserResponse? get cachedUser => _cachedUser; UserResponse? get cachedUser => _cachedUser;
Future<UserResponse> getOrLoad(Future<UserResponse> Function() loader) { Future<UserResponse> getProfile({bool forceRefresh = false}) async {
final cached = _cachedUser; final user = await _repository.getProfile(forceRefresh: forceRefresh);
if (cached != null) { _cachedUser = user;
return Future<UserResponse>.value(cached); return user;
}
final inflight = _inflight;
if (inflight != null) {
return inflight;
}
final generation = _generation;
late final Future<UserResponse> request;
request = loader()
.then((user) {
if (generation == _generation) {
_cachedUser = user;
}
return user;
})
.whenComplete(() {
if (identical(_inflight, request)) {
_inflight = null;
}
});
_inflight = request;
return request;
} }
void set(UserResponse user) { void set(UserResponse user) {
_cachedUser = user; _cachedUser = user;
unawaited(_repository.setCached(user));
} }
void invalidate() { void invalidate() {
_generation += 1;
_cachedUser = null; _cachedUser = null;
_inflight = null; unawaited(_repository.invalidate());
} }
} }
@@ -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<UserResponse> Function() remoteLoader;
Future<void>? _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<UserResponse> getProfile({bool forceRefresh = false}) async {
if (forceRefresh) {
return _refreshAndRead();
}
final cached = await store.read<CacheEntry<UserResponse>>(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<void> setCached(UserResponse user) {
return store.write<CacheEntry<UserResponse>>(
cacheKey,
CacheEntry<UserResponse>(value: user, fetchedAt: now()),
);
}
Future<void> invalidate() => store.remove(cacheKey);
void _refreshInBackground() {
final running = _refreshInFlight;
if (running != null) {
return;
}
final task = _refreshAndWrite().whenComplete(() {
_refreshInFlight = null;
});
_refreshInFlight = task;
unawaited(task);
}
Future<UserResponse> _refreshAndRead() async {
await _refreshAndWrite();
final cached = await store.read<CacheEntry<UserResponse>>(cacheKey);
return cached!.value;
}
Future<void> _refreshAndWrite() async {
final remote = await remoteLoader();
await setCached(remote);
}
}
@@ -19,7 +19,7 @@ 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/settings_api.dart';
import 'package:social_app/features/settings/data/services/settings_user_cache.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/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'; import '../widgets/settings_page_scaffold.dart';
const settingsProfileEditButtonKey = ValueKey('settings_profile_edit_button'); const settingsProfileEditButtonKey = ValueKey('settings_profile_edit_button');
@@ -33,7 +33,6 @@ class SettingsScreen extends StatefulWidget {
} }
class _SettingsScreenState extends State<SettingsScreen> { class _SettingsScreenState extends State<SettingsScreen> {
final UsersApi _usersApi = sl<UsersApi>();
final FriendsApi _friendsApi = sl<FriendsApi>(); final FriendsApi _friendsApi = sl<FriendsApi>();
final SettingsUserCache _userCache = sl<SettingsUserCache>(); final SettingsUserCache _userCache = sl<SettingsUserCache>();
@@ -55,7 +54,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
Future<void> _loadData() async { Future<void> _loadData() async {
try { try {
final user = await _userCache.getOrLoad(_usersApi.getMe); final user = await _userCache.getProfile();
if (mounted) { if (mounted) {
setState(() { setState(() {
_user = user; _user = user;
@@ -90,7 +89,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return SettingsPageScaffold( return SettingsPageScaffold(
title: '设置', title: '设置',
onBack: () => context.pop(), onBack: () => returnToHomePreserveState(context, forceGoHome: true),
body: Column( body: Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
@@ -17,6 +17,10 @@ class TodoApi {
return data.map((json) => TodoResponse.fromJson(json)).toList(); return data.map((json) => TodoResponse.fromJson(json)).toList();
} }
Future<List<TodoResponse>> getPendingTodos() {
return getTodos(status: 'pending');
}
Future<TodoResponse> getTodo(String id) async { Future<TodoResponse> getTodo(String id) async {
final response = await _client.get('$_prefix/$id'); final response = await _client.get('$_prefix/$id');
return TodoResponse.fromJson(response.data); return TodoResponse.fromJson(response.data);
@@ -0,0 +1,75 @@
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<List<TodoResponse>> getPendingTodos({
bool forceRefresh = false,
}) async {
if (!forceRefresh) {
final cached = await store.read<CacheEntry<List<TodoResponse>>>(
pendingListKey,
);
if (cached != null) {
return cached.value;
}
}
final remote = await api.getPendingTodos();
await store.write<CacheEntry<List<TodoResponse>>>(
pendingListKey,
CacheEntry(value: remote, fetchedAt: now()),
);
return remote;
}
Future<void> completeTodo(String id) async {
final cached = await store.read<CacheEntry<List<TodoResponse>>>(
pendingListKey,
);
if (cached != null) {
final next = cached.value
.where((todo) => todo.id != id)
.toList(growable: false);
await store.write<CacheEntry<List<TodoResponse>>>(
pendingListKey,
CacheEntry(value: next, fetchedAt: now()),
);
}
try {
await api.completeTodo(id);
invalidator.invalidate(pendingListKey);
} catch (error) {
if (cached != null) {
await store.write<CacheEntry<List<TodoResponse>>>(
pendingListKey,
cached,
);
}
rethrow;
}
}
Future<void> invalidatePending() {
invalidator.invalidate(pendingListKey);
return Future<void>.value();
}
}
@@ -29,6 +29,7 @@ class _TodoDetailScreenState extends State<TodoDetailScreen> {
TodoResponse? _todo; TodoResponse? _todo;
bool _isLoading = true; bool _isLoading = true;
bool _didMutate = false;
String? _error; String? _error;
@override @override
@@ -122,7 +123,7 @@ class _TodoDetailScreenState extends State<TodoDetailScreen> {
Widget _buildHeader() { Widget _buildHeader() {
return BackTitlePageHeader( return BackTitlePageHeader(
title: '待办详情', title: '待办详情',
onBack: () => context.pop(), onBack: () => context.pop(_didMutate),
trailing: _buildHeaderMenu(), trailing: _buildHeaderMenu(),
); );
} }
@@ -379,10 +380,11 @@ class _TodoDetailScreenState extends State<TodoDetailScreen> {
} }
final changed = await context.push<bool>(AppRoutes.todoEdit(_todo!.id)); final changed = await context.push<bool>(AppRoutes.todoEdit(_todo!.id));
if (changed == true) { if (changed == true) {
await _loadTodo(); _didMutate = true;
if (mounted && _error != null) { if (!mounted) {
Toast.show(context, '刷新失败: $_error', type: ToastType.error); return;
} }
context.pop(true);
} }
} }
@@ -398,7 +400,7 @@ class _TodoDetailScreenState extends State<TodoDetailScreen> {
try { try {
await _todoApi.deleteTodo(_todo!.id); await _todoApi.deleteTodo(_todo!.id);
if (mounted) { if (mounted) {
context.pop(); context.pop(true);
} }
} catch (e) { } catch (e) {
if (mounted) { if (mounted) {
@@ -16,6 +16,7 @@ import '../../../../shared/widgets/toast/toast_type.dart';
import '../../../calendar/ui/calendar_state_manager.dart'; import '../../../calendar/ui/calendar_state_manager.dart';
import '../../../calendar/ui/widgets/bottom_dock.dart'; import '../../../calendar/ui/widgets/bottom_dock.dart';
import '../../data/todo_api.dart'; import '../../data/todo_api.dart';
import '../../data/todo_repository.dart';
class TodoQuadrantsScreen extends StatefulWidget { class TodoQuadrantsScreen extends StatefulWidget {
const TodoQuadrantsScreen({super.key}); const TodoQuadrantsScreen({super.key});
@@ -26,6 +27,7 @@ class TodoQuadrantsScreen extends StatefulWidget {
class _TodoQuadrantsScreenState extends State<TodoQuadrantsScreen> { class _TodoQuadrantsScreenState extends State<TodoQuadrantsScreen> {
final TodoApi _todoApi = sl<TodoApi>(); final TodoApi _todoApi = sl<TodoApi>();
final TodoRepository _todoRepository = sl<TodoRepository>();
List<TodoResponse> _todos = []; List<TodoResponse> _todos = [];
bool _isLoading = true; bool _isLoading = true;
@@ -210,7 +212,9 @@ class _TodoQuadrantsScreenState extends State<TodoQuadrantsScreen> {
}); });
try { try {
final todos = await _todoApi.getTodos(status: 'pending'); final todos = await _todoRepository.getPendingTodos(
forceRefresh: !showPageLoader,
);
if (!mounted) { if (!mounted) {
return; return;
} }
@@ -263,12 +267,9 @@ class _TodoQuadrantsScreenState extends State<TodoQuadrantsScreen> {
Future<void> _completeTodo(TodoResponse todo) async { Future<void> _completeTodo(TodoResponse todo) async {
try { try {
await _todoApi.completeTodo(todo.id); await _todoRepository.completeTodo(todo.id);
if (mounted) {
Toast.show(context, '已完成', type: ToastType.success);
}
try { try {
await _loadTodos(); await _loadTodos(showPageLoader: false);
} catch (_) { } catch (_) {
// ignore reload error // ignore reload error
} }
@@ -279,14 +280,17 @@ class _TodoQuadrantsScreenState extends State<TodoQuadrantsScreen> {
} }
} }
void _navigateToDetail(TodoResponse todo) { Future<void> _navigateToDetail(TodoResponse todo) async {
context.push(AppRoutes.todoDetail(todo.id)); final changed = await context.push<bool>(AppRoutes.todoDetail(todo.id));
if (changed == true) {
await _loadTodos(showPageLoader: false);
}
} }
Future<void> _addTodo() async { Future<void> _addTodo() async {
final created = await context.push<bool>(AppRoutes.todoCreate); final created = await context.push<bool>(AppRoutes.todoCreate);
if (created == true) { if (created == true) {
await _loadTodos(); await _loadTodos(showPageLoader: false);
} }
} }
@@ -298,7 +302,7 @@ class _TodoQuadrantsScreenState extends State<TodoQuadrantsScreen> {
canPop: false, canPop: false,
onPopInvokedWithResult: (didPop, result) { onPopInvokedWithResult: (didPop, result) {
if (!didPop) { if (!didPop) {
returnToHomePreserveState(context); returnToHomePreserveState(context, forceGoHome: true);
} }
}, },
child: SafeArea( child: SafeArea(
@@ -322,25 +326,6 @@ class _TodoQuadrantsScreenState extends State<TodoQuadrantsScreen> {
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center,
children: [ 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( AppPressable(
borderRadius: BorderRadius.circular(AppRadius.full), borderRadius: BorderRadius.circular(AppRadius.full),
onTap: _addTodo, onTap: _addTodo,
@@ -444,6 +429,7 @@ class _TodoQuadrantsScreenState extends State<TodoQuadrantsScreen> {
horizontal: AppSpacing.sm, horizontal: AppSpacing.sm,
), ),
child: _TodoItemWidget( child: _TodoItemWidget(
key: ValueKey(item.id),
item: item, item: item,
onComplete: () => _completeTodo(item), onComplete: () => _completeTodo(item),
onTap: () => _navigateToDetail(item), onTap: () => _navigateToDetail(item),
@@ -563,7 +549,7 @@ class _TodoQuadrantsScreenState extends State<TodoQuadrantsScreen> {
context.push('${AppRoutes.calendarDayWeek}?date=$dateStr'); context.push('${AppRoutes.calendarDayWeek}?date=$dateStr');
} }
}, },
onHomeTap: () => returnToHomePreserveState(context), onHomeTap: () => returnToHomePreserveState(context, forceGoHome: true),
); );
} }
} }
@@ -599,6 +585,7 @@ class _TodoItemWidget extends StatefulWidget {
final VoidCallback onTap; final VoidCallback onTap;
const _TodoItemWidget({ const _TodoItemWidget({
super.key,
required this.item, required this.item,
required this.onComplete, required this.onComplete,
required this.onTap, required this.onTap,
+24
View File
@@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'core/constants/app_constants.dart'; import 'core/constants/app_constants.dart';
import 'core/cache/cache_refresh_coordinator.dart';
import 'core/di/injection.dart'; import 'core/di/injection.dart';
import 'core/notifications/local_notification_service.dart'; import 'core/notifications/local_notification_service.dart';
import 'core/notifications/reminder_notification_callbacks.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_event.dart';
import 'features/auth/presentation/bloc/auth_state.dart'; import 'features/auth/presentation/bloc/auth_state.dart';
import 'features/calendar/data/services/calendar_service.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/reminder_action_executor.dart';
import 'features/calendar/reminders/ui/reminder_foreground_presenter.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/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 { void main() async {
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();
@@ -44,6 +49,25 @@ void main() async {
final authBloc = sl<AuthBloc>(); final authBloc = sl<AuthBloc>();
authBloc.add(AuthStarted()); authBloc.add(AuthStarted());
final cacheRefreshCoordinator = CacheRefreshCoordinator(
minInterval: const Duration(minutes: 5),
onRefresh: () {
final selected = sl<CalendarStateManager>().selectedDate;
unawaited(
sl<CalendarRepository>().getDayEvents(selected, forceRefresh: true),
);
unawaited(
sl<CalendarRepository>().getMonthEvents(
DateTime(selected.year, selected.month, 1),
forceRefresh: true,
),
);
unawaited(sl<TodoRepository>().getPendingTodos(forceRefresh: true));
unawaited(sl<SettingsUserCache>().getProfile(forceRefresh: true));
},
);
WidgetsBinding.instance.addObserver(cacheRefreshCoordinator);
runApp( runApp(
LinksyApp( LinksyApp(
authBloc: authBloc, authBloc: authBloc,
+11
View File
@@ -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);
});
}
+19
View File
@@ -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);
});
}
@@ -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);
});
}
+27
View File
@@ -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<String> loader() async {
calls += 1;
await Future<void>.delayed(const Duration(milliseconds: 20));
return 'ok';
}
await Future.wait([
store.getOrLoad<String>('k', loader: loader),
store.getOrLoad<String>('k', loader: loader),
]);
expect(calls, 1);
});
}
@@ -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<CacheEntry<List<ScheduleItemModel>>>(
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 <ScheduleItemModel>[];
},
loadMonthFromRemote: (start, end) async => const <ScheduleItemModel>[],
);
final result = await repository.getDayEvents(date);
await Future<void>.delayed(const Duration(milliseconds: 10));
expect(result.first.id, 'evt_cached');
expect(remoteCalls, 1);
},
);
}
@@ -79,6 +79,7 @@ void main() {
expect(pending.length, 1); expect(pending.length, 1);
expect(pending.first.eventId, 'evt_1'); expect(pending.first.eventId, 'evt_1');
expect(pending.first.state, ReminderOutboxState.pending); expect(pending.first.state, ReminderOutboxState.pending);
verify(() => calendarService.archiveEvent('evt_1')).called(1);
}); });
test('snooze reschedules +10m when event not expired', () async { test('snooze reschedules +10m when event not expired', () async {
@@ -3,9 +3,23 @@ import 'package:social_app/features/home/ui/navigation/home_return_policy.dart';
void main() { void main() {
group('resolveHomeReturnAction', () { group('resolveHomeReturnAction', () {
test('business route with back stack prefers pop', () { test('dock home action should always resolve to goHome', () {
final action = resolveHomeReturnAction(canPop: true, isAuthEntry: false); final action = resolveHomeReturnAction(canPop: true, isAuthEntry: false);
expect(action, HomeReturnAction.pop); expect(action, HomeReturnAction.goHomeForDock);
});
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 resolves to dock home action', () {
final action = resolveHomeReturnAction(canPop: true, isAuthEntry: false);
expect(action, HomeReturnAction.goHomeForDock);
}); });
test('business route without back stack falls back to go home', () { test('business route without back stack falls back to go home', () {
@@ -1,70 +1,69 @@
import 'dart:async';
import 'package:flutter_test/flutter_test.dart'; 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/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'; import 'package:social_app/features/users/data/models/user_response.dart';
void main() { void main() {
test('getOrLoad calls loader only once when cache exists', () async { test('getProfile caches latest user in memory field', () async {
final cache = SettingsUserCache();
var loadCalls = 0; 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<UserResponse> loader() async { final first = await cache.getProfile();
loadCalls += 1; final second = await cache.getProfile();
return const UserResponse(id: 'u1', username: 'first');
}
final first = await cache.getOrLoad(loader);
final second = await cache.getOrLoad(loader);
expect(first.username, 'first'); expect(first.username, 'first');
expect(second.username, 'first'); expect(second.username, 'first');
expect(cache.cachedUser?.id, 'u1');
expect(loadCalls, 1); expect(loadCalls, 1);
}); });
test('invalidate forces next load', () async { test('invalidate clears memory cache', () {
final cache = SettingsUserCache(); final repository = UserProfileCacheRepository(
var loadCalls = 0; store: HybridCacheStore(
memory: MemoryCacheStore(),
persistent: PersistentCacheStore(),
),
remoteLoader: () async => const UserResponse(id: 'u1', username: 'first'),
);
final cache = SettingsUserCache(repository);
Future<UserResponse> loader() async { cache.set(const UserResponse(id: 'u1', username: 'first'));
loadCalls += 1;
return UserResponse(id: 'u$loadCalls', username: 'user$loadCalls');
}
final first = await cache.getOrLoad(loader);
cache.invalidate(); cache.invalidate();
final second = await cache.getOrLoad(loader);
expect(first.id, 'u1'); expect(cache.cachedUser, isNull);
expect(second.id, 'u2');
expect(loadCalls, 2);
}); });
test( test('set should update cached user immediately', () {
'invalidate blocks stale inflight response from repopulating cache', final repository = UserProfileCacheRepository(
() async { store: HybridCacheStore(
final cache = SettingsUserCache(); memory: MemoryCacheStore(),
final completer = Completer<UserResponse>(); persistent: PersistentCacheStore(),
var loadCalls = 0; ),
remoteLoader: () async => const UserResponse(id: 'u1', username: 'first'),
);
final cache = SettingsUserCache(repository);
Future<UserResponse> slowLoader() { cache.set(const UserResponse(id: 'u2', username: 'next'));
loadCalls += 1;
return completer.future;
}
final pending = cache.getOrLoad(slowLoader); expect(cache.cachedUser?.id, 'u2');
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);
},
);
} }
@@ -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<UserResponse>(
value: const UserResponse(id: 'u1', username: 'cached'),
fetchedAt: DateTime(2026, 3, 20, 11, 0),
);
await store.persistent.write<CacheEntry<UserResponse>>(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<void>.delayed(const Duration(milliseconds: 10));
expect(result.username, 'cached');
expect(refreshCalls, 1);
},
);
}
@@ -1,10 +1,14 @@
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.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/api/i_api_client.dart';
import 'package:social_app/core/di/injection.dart'; import 'package:social_app/core/di/injection.dart';
import 'package:social_app/features/friends/data/friends_api.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/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/settings/ui/screens/settings_screen.dart';
import 'package:social_app/features/users/data/models/user_response.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/users/data/users_api.dart';
@@ -78,10 +82,21 @@ void main() {
if (sl.isRegistered<SettingsUserCache>()) { if (sl.isRegistered<SettingsUserCache>()) {
sl.unregister<SettingsUserCache>(); sl.unregister<SettingsUserCache>();
} }
if (sl.isRegistered<UserProfileCacheRepository>()) {
sl.unregister<UserProfileCacheRepository>();
}
usersApi = _FakeUsersApi(apiClient); usersApi = _FakeUsersApi(apiClient);
final repository = UserProfileCacheRepository(
store: HybridCacheStore(
memory: MemoryCacheStore(),
persistent: PersistentCacheStore(),
),
remoteLoader: usersApi.getMe,
);
sl.registerSingleton<UsersApi>(usersApi); sl.registerSingleton<UsersApi>(usersApi);
sl.registerSingleton<FriendsApi>(_FakeFriendsApi(apiClient)); sl.registerSingleton<FriendsApi>(_FakeFriendsApi(apiClient));
sl.registerSingleton<SettingsUserCache>(SettingsUserCache()); sl.registerSingleton<UserProfileCacheRepository>(repository);
sl.registerSingleton<SettingsUserCache>(SettingsUserCache(repository));
}); });
tearDown(() async { tearDown(() async {
@@ -94,6 +109,9 @@ void main() {
if (sl.isRegistered<SettingsUserCache>()) { if (sl.isRegistered<SettingsUserCache>()) {
await sl.unregister<SettingsUserCache>(); await sl.unregister<SettingsUserCache>();
} }
if (sl.isRegistered<UserProfileCacheRepository>()) {
await sl.unregister<UserProfileCacheRepository>();
}
}); });
testWidgets('settings screen removes account row and shows logout button', ( testWidgets('settings screen removes account row and shows logout button', (
@@ -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 remove item 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<CacheEntry<List<TodoResponse>>>(
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<CacheEntry<List<TodoResponse>>>(
TodoRepository.pendingListKey,
);
expect(updated, isNull);
expect(invalidator.wasInvalidated(TodoRepository.pendingListKey), true);
},
);
}
+1
View File
@@ -170,6 +170,7 @@ class TodoService(BaseService):
) )
await self._session.commit() await self._session.commit()
await self._session.refresh(todo)
except SQLAlchemyError: except SQLAlchemyError:
await self._session.rollback() await self._session.rollback()
raise HTTPException(status_code=503, detail="Todo service unavailable") raise HTTPException(status_code=503, detail="Todo service unavailable")
@@ -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)
@@ -267,8 +267,19 @@
1. 若 M1 不稳定,可先回退 shell 改造并保留缓存模块。 1. 若 M1 不稳定,可先回退 shell 改造并保留缓存模块。
2. 若缓存接入问题集中,可按域回退(user/calendar/todo 分域开关)。 2. 若缓存接入问题集中,可按域回退(user/calendar/todo 分域开关)。
## 12. 待确认参数(实施前锁定 ## 12. 最终落地参数(2026-03-20
1. 软/硬过期默认值是否按本设计直接采用。 1. 导航分级
2. 是否立即展示“上次同步时间” - 一级页面唯一为 `Home`
3. 是否在首版启用“网络恢复自动静默刷新” - 二级页面(日/月、待办、设置)侧滑返回统一回 `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 不可用场景兜底。