merge: integrate navigation cache decoupling feature
This commit is contained in:
@@ -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
|
||||||
|
|||||||
Vendored
+6
@@ -0,0 +1,6 @@
|
|||||||
|
class CacheEntry<T> {
|
||||||
|
final T value;
|
||||||
|
final DateTime fetchedAt;
|
||||||
|
|
||||||
|
const CacheEntry({required this.value, required this.fetchedAt});
|
||||||
|
}
|
||||||
+27
@@ -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);
|
||||||
|
}
|
||||||
Vendored
+17
@@ -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
@@ -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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
Vendored
+5
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
@@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 不可用场景兜底。
|
||||||
|
|||||||
Reference in New Issue
Block a user