merge: integrate navigation cache decoupling feature
This commit is contained in:
@@ -196,3 +196,23 @@ Home 首页历史消息加载与滚动策略属于高回归模块,必须遵循
|
||||
- controller-level state transition tests
|
||||
- widget-level unread indicator and scroll behavior tests
|
||||
- route-return stability tests when navigation behavior changes
|
||||
|
||||
## 11) Cache & Repository Rules (MUST)
|
||||
|
||||
前端缓存与数据访问属于高回归区域,必须遵循以下约束:
|
||||
|
||||
- **MUST** route feature data reads/writes through repository layer when cache, invalidation, or optimistic update is involved.
|
||||
- Feature/UI code **MUST NOT** call raw `*Api` methods directly for mutation paths that affect list/detail consistency.
|
||||
- Exceptions are allowed only for bootstrapping or truly stateless read operations, and must be documented in code review notes.
|
||||
- **MUST** keep cache key ownership centralized in repository classes.
|
||||
- UI/Bloc/Cubit **MUST NOT** hardcode cache keys or perform ad-hoc cache writes.
|
||||
- **MUST** define cache invalidation at mutation boundaries (create/update/delete/archive/complete/reorder).
|
||||
- Mutation success must either update cache atomically or invalidate and trigger deterministic refresh.
|
||||
- **MUST** preserve route-return consistency for data freshness.
|
||||
- Pages that mutate entity data must return an explicit changed signal to caller routes.
|
||||
- Caller list pages must consume that signal and refresh using repository path.
|
||||
- **MUST** ensure list item widgets that carry local interaction state use stable identity keys (e.g. `ValueKey(entity.id)`) to prevent state leakage across reused cells.
|
||||
- **MUST** add/maintain regression tests when changing cache/repository behavior:
|
||||
- repository tests for optimistic update + rollback + invalidation
|
||||
- route-return refresh tests for list/detail/edit flows
|
||||
- widget tests for stable keyed interaction state where applicable
|
||||
|
||||
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:get_it/get_it.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import '../cache/cache_invalidator.dart';
|
||||
import '../cache/hybrid_cache_store.dart';
|
||||
import '../cache/memory_cache_store.dart';
|
||||
import '../cache/persistent_cache_store.dart';
|
||||
import '../api/api_client.dart';
|
||||
import '../api/i_api_client.dart';
|
||||
import '../storage/token_storage.dart';
|
||||
@@ -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_event.dart';
|
||||
import '../../features/calendar/data/calendar_api.dart';
|
||||
import '../../features/calendar/data/services/calendar_repository.dart';
|
||||
import '../../features/calendar/data/services/calendar_service.dart';
|
||||
import '../../features/calendar/reminders/reminder_action_executor.dart';
|
||||
import '../../features/calendar/reminders/reminder_outbox_store.dart';
|
||||
@@ -21,8 +26,10 @@ import '../../features/friends/data/friends_api.dart';
|
||||
import '../../features/messages/data/inbox_api.dart';
|
||||
import '../../features/settings/data/settings_api.dart';
|
||||
import '../../features/settings/data/services/settings_user_cache.dart';
|
||||
import '../../features/settings/data/services/user_profile_cache_repository.dart';
|
||||
import '../../features/users/data/users_api.dart';
|
||||
import '../../features/todo/data/todo_api.dart';
|
||||
import '../../features/todo/data/todo_repository.dart';
|
||||
|
||||
final sl = GetIt.instance;
|
||||
|
||||
@@ -56,15 +63,41 @@ Future<void> configureDependencies() async {
|
||||
final sharedPreferences = await SharedPreferences.getInstance();
|
||||
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);
|
||||
sl.registerSingleton<UsersApi>(usersApi);
|
||||
|
||||
final userProfileCacheRepository = UserProfileCacheRepository(
|
||||
store: hybridCacheStore,
|
||||
remoteLoader: usersApi.getMe,
|
||||
);
|
||||
sl.registerSingleton<UserProfileCacheRepository>(userProfileCacheRepository);
|
||||
|
||||
final calendarApi = CalendarApi(apiClient);
|
||||
sl.registerSingleton<CalendarApi>(calendarApi);
|
||||
|
||||
final calendarService = CalendarService(apiClient: apiClient);
|
||||
sl.registerSingleton<CalendarService>(calendarService);
|
||||
|
||||
final calendarRepository = CalendarRepository(
|
||||
store: hybridCacheStore,
|
||||
loadDayFromRemote: calendarService.getEventsForDay,
|
||||
loadMonthFromRemote: calendarService.getEventsForRange,
|
||||
);
|
||||
sl.registerSingleton<CalendarRepository>(calendarRepository);
|
||||
|
||||
final reminderOutboxStore = ReminderOutboxStore(sharedPreferences);
|
||||
sl.registerSingleton<ReminderOutboxStore>(reminderOutboxStore);
|
||||
|
||||
@@ -83,13 +116,22 @@ Future<void> configureDependencies() async {
|
||||
final settingsApi = SettingsApi(apiClient);
|
||||
sl.registerSingleton<SettingsApi>(settingsApi);
|
||||
|
||||
sl.registerSingleton<SettingsUserCache>(SettingsUserCache());
|
||||
sl.registerSingleton<SettingsUserCache>(
|
||||
SettingsUserCache(userProfileCacheRepository),
|
||||
);
|
||||
|
||||
final inboxApi = InboxApi(apiClient);
|
||||
sl.registerSingleton<InboxApi>(inboxApi);
|
||||
|
||||
final todoApi = TodoApi(apiClient);
|
||||
sl.registerSingleton<TodoApi>(todoApi);
|
||||
sl.registerSingleton<TodoRepository>(
|
||||
TodoRepository(
|
||||
api: todoApi,
|
||||
store: hybridCacheStore,
|
||||
invalidator: sl<CacheInvalidator>(),
|
||||
),
|
||||
);
|
||||
|
||||
final authRepository = AuthRepositoryImpl(
|
||||
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/edit_profile_screen.dart';
|
||||
|
||||
final _homeSecondLevelRoutes = [
|
||||
AppRoutes.shellHomeBranch,
|
||||
AppRoutes.shellCalendarBranch,
|
||||
AppRoutes.calendarMonth,
|
||||
AppRoutes.shellTodoBranch,
|
||||
AppRoutes.settingsMain,
|
||||
];
|
||||
|
||||
final _protectedRoutes = [
|
||||
AppRoutes.homeMain,
|
||||
..._homeSecondLevelRoutes,
|
||||
AppRoutes.contactsList,
|
||||
AppRoutes.contactsAdd,
|
||||
AppRoutes.calendarDayWeek,
|
||||
AppRoutes.calendarMonth,
|
||||
'/calendar/events',
|
||||
AppRoutes.todoList,
|
||||
AppRoutes.settingsMain,
|
||||
AppRoutes.settingsFeatures,
|
||||
AppRoutes.settingsMemory,
|
||||
AppRoutes.settingsEditProfile,
|
||||
|
||||
@@ -5,6 +5,9 @@ class AppRoutes {
|
||||
static const authLogin = '/';
|
||||
|
||||
static const homeMain = '/home';
|
||||
static const shellHomeBranch = homeMain;
|
||||
static const shellCalendarBranch = calendarDayWeek;
|
||||
static const shellTodoBranch = todoList;
|
||||
|
||||
static const messageInviteList = '/messages/invites';
|
||||
static String messageInviteDetail(String id) => '/messages/invites/$id';
|
||||
|
||||
@@ -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 {
|
||||
try {
|
||||
await _calendarService.archiveEvent(eventId);
|
||||
return;
|
||||
} catch (_) {
|
||||
// fall through to enqueue local outbox for retry
|
||||
}
|
||||
|
||||
final opId =
|
||||
'${DateTime.now().millisecondsSinceEpoch}-${_random.nextInt(1 << 32)}';
|
||||
final outboxItem = ReminderOutboxItem(
|
||||
@@ -96,11 +103,5 @@ class ReminderActionExecutor {
|
||||
occurredAt: DateTime.now(),
|
||||
);
|
||||
await _outboxStore.enqueue(outboxItem);
|
||||
try {
|
||||
await _calendarService.archiveEvent(eventId);
|
||||
await _outboxStore.markDone(opId);
|
||||
} catch (error) {
|
||||
await _outboxStore.markRetry(opId, error.toString());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,4 +48,8 @@ class CalendarStateManager extends ChangeNotifier {
|
||||
);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void refresh() {
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import '../../../../core/di/injection.dart';
|
||||
import '../../../../core/theme/design_tokens.dart';
|
||||
import '../../../../shared/widgets/app_pressable.dart';
|
||||
import '../../data/models/schedule_item_model.dart';
|
||||
import '../../data/services/calendar_service.dart';
|
||||
import '../../data/services/calendar_repository.dart';
|
||||
import '../calendar_state_manager.dart';
|
||||
import '../calendar_time_utils.dart';
|
||||
import '../utils/event_color_resolver.dart';
|
||||
@@ -67,25 +67,18 @@ class _CalendarDayWeekScreenState extends State<CalendarDayWeekScreen>
|
||||
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_scrollToSelectedDate();
|
||||
_setupRouteListener();
|
||||
});
|
||||
}
|
||||
|
||||
void _setupRouteListener() {
|
||||
final router = GoRouter.of(context);
|
||||
router.routerDelegate.addListener(_onRouteChange);
|
||||
}
|
||||
|
||||
void _onRouteChange() {
|
||||
_loadEvents();
|
||||
}
|
||||
|
||||
void _updateMonthDates() {
|
||||
_monthDates = monthDatesFor(_selectedDate);
|
||||
}
|
||||
|
||||
Future<void> _loadEvents() async {
|
||||
final events = await sl<CalendarService>().getEventsForDay(_selectedDate);
|
||||
Future<void> _loadEvents({bool forceRefresh = false}) async {
|
||||
final events = await sl<CalendarRepository>().getDayEvents(
|
||||
_selectedDate,
|
||||
forceRefresh: forceRefresh,
|
||||
);
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
@@ -96,9 +89,6 @@ class _CalendarDayWeekScreenState extends State<CalendarDayWeekScreen>
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
try {
|
||||
GoRouter.of(context).routerDelegate.removeListener(_onRouteChange);
|
||||
} catch (_) {}
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
_dayStripController.dispose();
|
||||
super.dispose();
|
||||
@@ -107,7 +97,7 @@ class _CalendarDayWeekScreenState extends State<CalendarDayWeekScreen>
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
if (state == AppLifecycleState.resumed) {
|
||||
_loadEvents();
|
||||
_loadEvents(forceRefresh: true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,7 +109,7 @@ class _CalendarDayWeekScreenState extends State<CalendarDayWeekScreen>
|
||||
canPop: false,
|
||||
onPopInvokedWithResult: (didPop, result) {
|
||||
if (!didPop) {
|
||||
returnToHomePreserveState(context);
|
||||
returnToHomePreserveState(context, forceGoHome: true);
|
||||
}
|
||||
},
|
||||
child: SafeArea(
|
||||
@@ -314,9 +304,14 @@ class _CalendarDayWeekScreenState extends State<CalendarDayWeekScreen>
|
||||
if (isNotToday) const SizedBox(width: 8),
|
||||
AppPressable(
|
||||
borderRadius: BorderRadius.circular(AppRadius.full),
|
||||
onTap: () => context.push(
|
||||
'${AppRoutes.calendarEventCreate}?date=${formatYmd(_selectedDate)}',
|
||||
),
|
||||
onTap: () async {
|
||||
final changed = await context.push<bool>(
|
||||
'${AppRoutes.calendarEventCreate}?date=${formatYmd(_selectedDate)}',
|
||||
);
|
||||
if (changed == true) {
|
||||
await _loadEvents(forceRefresh: true);
|
||||
}
|
||||
},
|
||||
child: Container(
|
||||
width: 36,
|
||||
height: 36,
|
||||
@@ -635,8 +630,14 @@ class _CalendarDayWeekScreenState extends State<CalendarDayWeekScreen>
|
||||
height: tapHeight,
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.translucent,
|
||||
onTap: () =>
|
||||
context.push(AppRoutes.calendarEventDetail(layout.event.id)),
|
||||
onTap: () async {
|
||||
final changed = await context.push<bool>(
|
||||
AppRoutes.calendarEventDetail(layout.event.id),
|
||||
);
|
||||
if (changed == true) {
|
||||
await _loadEvents(forceRefresh: true);
|
||||
}
|
||||
},
|
||||
child: Stack(
|
||||
children: [
|
||||
Positioned(
|
||||
@@ -702,7 +703,7 @@ class _CalendarDayWeekScreenState extends State<CalendarDayWeekScreen>
|
||||
_calendarManager.setViewType(CalendarViewType.day);
|
||||
context.push(AppRoutes.calendarMonth);
|
||||
},
|
||||
onHomeTap: () => returnToHomePreserveState(context),
|
||||
onHomeTap: () => returnToHomePreserveState(context, forceGoHome: true),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -481,7 +481,7 @@ class _CalendarEventDetailScreenState extends State<CalendarEventDetailScreen> {
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
context.pop();
|
||||
context.pop(true);
|
||||
}
|
||||
|
||||
Future<void> _archiveEvent() async {
|
||||
@@ -496,9 +496,8 @@ class _CalendarEventDetailScreenState extends State<CalendarEventDetailScreen> {
|
||||
}
|
||||
try {
|
||||
await sl<CalendarService>().archiveEvent(widget.eventId);
|
||||
await _loadEvent();
|
||||
if (mounted) {
|
||||
Toast.show(context, '已归档', type: ToastType.success);
|
||||
context.pop(true);
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
|
||||
@@ -12,7 +12,7 @@ import '../calendar_time_utils.dart';
|
||||
import '../utils/event_color_resolver.dart';
|
||||
import '../widgets/bottom_dock.dart';
|
||||
import '../../data/models/schedule_item_model.dart';
|
||||
import '../../data/services/calendar_service.dart';
|
||||
import '../../data/services/calendar_repository.dart';
|
||||
|
||||
class CalendarMonthScreen extends StatefulWidget {
|
||||
final bool resetToToday;
|
||||
@@ -44,32 +44,13 @@ class _CalendarMonthScreenState extends State<CalendarMonthScreen>
|
||||
_selectedDate = savedDate;
|
||||
_currentMonth = DateTime(savedDate.year, savedDate.month, 1);
|
||||
_loadMonthEvents();
|
||||
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_setupRouteListener();
|
||||
});
|
||||
}
|
||||
|
||||
void _setupRouteListener() {
|
||||
final router = GoRouter.of(context);
|
||||
router.routerDelegate.addListener(_onRouteChange);
|
||||
}
|
||||
|
||||
void _onRouteChange() {
|
||||
_loadMonthEvents();
|
||||
}
|
||||
|
||||
Future<void> _loadMonthEvents() async {
|
||||
final start = DateTime(_currentMonth.year, _currentMonth.month, 1);
|
||||
final end = DateTime(
|
||||
_currentMonth.year,
|
||||
_currentMonth.month + 1,
|
||||
0,
|
||||
23,
|
||||
59,
|
||||
59,
|
||||
Future<void> _loadMonthEvents({bool forceRefresh = false}) async {
|
||||
final events = await sl<CalendarRepository>().getMonthEvents(
|
||||
_currentMonth,
|
||||
forceRefresh: forceRefresh,
|
||||
);
|
||||
final events = await sl<CalendarService>().getEventsForRange(start, end);
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
@@ -83,9 +64,6 @@ class _CalendarMonthScreenState extends State<CalendarMonthScreen>
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
try {
|
||||
GoRouter.of(context).routerDelegate.removeListener(_onRouteChange);
|
||||
} catch (_) {}
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
super.dispose();
|
||||
}
|
||||
@@ -93,7 +71,7 @@ class _CalendarMonthScreenState extends State<CalendarMonthScreen>
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
if (state == AppLifecycleState.resumed) {
|
||||
_loadMonthEvents();
|
||||
_loadMonthEvents(forceRefresh: true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,7 +83,7 @@ class _CalendarMonthScreenState extends State<CalendarMonthScreen>
|
||||
canPop: false,
|
||||
onPopInvokedWithResult: (didPop, result) {
|
||||
if (!didPop) {
|
||||
returnToHomePreserveState(context);
|
||||
returnToHomePreserveState(context, forceGoHome: true);
|
||||
}
|
||||
},
|
||||
child: SafeArea(
|
||||
@@ -172,7 +150,14 @@ class _CalendarMonthScreenState extends State<CalendarMonthScreen>
|
||||
const Spacer(),
|
||||
AppPressable(
|
||||
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(
|
||||
width: 36,
|
||||
height: 36,
|
||||
@@ -367,9 +352,14 @@ class _CalendarMonthScreenState extends State<CalendarMonthScreen>
|
||||
);
|
||||
return AppPressable(
|
||||
borderRadius: BorderRadius.circular(AppRadius.sm),
|
||||
onTap: () {
|
||||
onTap: () async {
|
||||
_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(
|
||||
margin: const EdgeInsets.only(bottom: 2),
|
||||
@@ -522,7 +512,7 @@ class _CalendarMonthScreenState extends State<CalendarMonthScreen>
|
||||
context.push(AppRoutes.todoList);
|
||||
},
|
||||
onCalendarTap: () {},
|
||||
onHomeTap: () => returnToHomePreserveState(context),
|
||||
onHomeTap: () => returnToHomePreserveState(context, forceGoHome: true),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -109,6 +109,7 @@ class BottomDock extends StatelessWidget {
|
||||
return Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
key: const ValueKey('bottom_dock_home_button'),
|
||||
onTap: onHomeTap,
|
||||
borderRadius: BorderRadius.circular(AppRadius.xl),
|
||||
child: Container(
|
||||
|
||||
@@ -751,7 +751,7 @@ class _CreateEventSheetState extends State<CreateEventSheet>
|
||||
|
||||
widget.onSaved?.call();
|
||||
if (mounted) {
|
||||
Navigator.pop(context);
|
||||
Navigator.pop(context, true);
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
|
||||
@@ -3,17 +3,21 @@ import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../../../core/router/app_routes.dart';
|
||||
|
||||
enum HomeReturnAction { pop, goHome }
|
||||
enum HomeReturnAction { pop, goHome, goHomeForDock }
|
||||
|
||||
HomeReturnAction resolveHomeReturnAction({
|
||||
required bool canPop,
|
||||
required bool isAuthEntry,
|
||||
bool forceGoHome = false,
|
||||
}) {
|
||||
if (forceGoHome) {
|
||||
return HomeReturnAction.goHome;
|
||||
}
|
||||
if (isAuthEntry) {
|
||||
return HomeReturnAction.goHome;
|
||||
}
|
||||
if (canPop) {
|
||||
return HomeReturnAction.pop;
|
||||
return HomeReturnAction.goHomeForDock;
|
||||
}
|
||||
return HomeReturnAction.goHome;
|
||||
}
|
||||
@@ -21,10 +25,12 @@ HomeReturnAction resolveHomeReturnAction({
|
||||
void returnToHomePreserveState(
|
||||
BuildContext context, {
|
||||
bool isAuthEntry = false,
|
||||
bool forceGoHome = false,
|
||||
}) {
|
||||
final action = resolveHomeReturnAction(
|
||||
canPop: context.canPop(),
|
||||
isAuthEntry: isAuthEntry,
|
||||
forceGoHome: forceGoHome,
|
||||
);
|
||||
switch (action) {
|
||||
case HomeReturnAction.pop:
|
||||
@@ -33,5 +39,12 @@ void returnToHomePreserveState(
|
||||
case HomeReturnAction.goHome:
|
||||
context.go(AppRoutes.homeMain);
|
||||
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 'user_profile_cache_repository.dart';
|
||||
|
||||
class SettingsUserCache {
|
||||
final UserProfileCacheRepository _repository;
|
||||
|
||||
SettingsUserCache(this._repository);
|
||||
|
||||
UserResponse? _cachedUser;
|
||||
Future<UserResponse>? _inflight;
|
||||
int _generation = 0;
|
||||
|
||||
UserResponse? get cachedUser => _cachedUser;
|
||||
|
||||
Future<UserResponse> getOrLoad(Future<UserResponse> Function() loader) {
|
||||
final cached = _cachedUser;
|
||||
if (cached != null) {
|
||||
return Future<UserResponse>.value(cached);
|
||||
}
|
||||
|
||||
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;
|
||||
Future<UserResponse> getProfile({bool forceRefresh = false}) async {
|
||||
final user = await _repository.getProfile(forceRefresh: forceRefresh);
|
||||
_cachedUser = user;
|
||||
return user;
|
||||
}
|
||||
|
||||
void set(UserResponse user) {
|
||||
_cachedUser = user;
|
||||
unawaited(_repository.setCached(user));
|
||||
}
|
||||
|
||||
void invalidate() {
|
||||
_generation += 1;
|
||||
_cachedUser = null;
|
||||
_inflight = null;
|
||||
unawaited(_repository.invalidate());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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/services/settings_user_cache.dart';
|
||||
import 'package:social_app/features/users/data/models/user_response.dart';
|
||||
import 'package:social_app/features/users/data/users_api.dart';
|
||||
import 'package:social_app/features/home/ui/navigation/home_return_policy.dart';
|
||||
import '../widgets/settings_page_scaffold.dart';
|
||||
|
||||
const settingsProfileEditButtonKey = ValueKey('settings_profile_edit_button');
|
||||
@@ -33,7 +33,6 @@ class SettingsScreen extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _SettingsScreenState extends State<SettingsScreen> {
|
||||
final UsersApi _usersApi = sl<UsersApi>();
|
||||
final FriendsApi _friendsApi = sl<FriendsApi>();
|
||||
final SettingsUserCache _userCache = sl<SettingsUserCache>();
|
||||
|
||||
@@ -55,7 +54,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
|
||||
Future<void> _loadData() async {
|
||||
try {
|
||||
final user = await _userCache.getOrLoad(_usersApi.getMe);
|
||||
final user = await _userCache.getProfile();
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_user = user;
|
||||
@@ -90,7 +89,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
Widget build(BuildContext context) {
|
||||
return SettingsPageScaffold(
|
||||
title: '设置',
|
||||
onBack: () => context.pop(),
|
||||
onBack: () => returnToHomePreserveState(context, forceGoHome: true),
|
||||
body: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
|
||||
@@ -17,6 +17,10 @@ class TodoApi {
|
||||
return data.map((json) => TodoResponse.fromJson(json)).toList();
|
||||
}
|
||||
|
||||
Future<List<TodoResponse>> getPendingTodos() {
|
||||
return getTodos(status: 'pending');
|
||||
}
|
||||
|
||||
Future<TodoResponse> getTodo(String id) async {
|
||||
final response = await _client.get('$_prefix/$id');
|
||||
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;
|
||||
bool _isLoading = true;
|
||||
bool _didMutate = false;
|
||||
String? _error;
|
||||
|
||||
@override
|
||||
@@ -122,7 +123,7 @@ class _TodoDetailScreenState extends State<TodoDetailScreen> {
|
||||
Widget _buildHeader() {
|
||||
return BackTitlePageHeader(
|
||||
title: '待办详情',
|
||||
onBack: () => context.pop(),
|
||||
onBack: () => context.pop(_didMutate),
|
||||
trailing: _buildHeaderMenu(),
|
||||
);
|
||||
}
|
||||
@@ -379,10 +380,11 @@ class _TodoDetailScreenState extends State<TodoDetailScreen> {
|
||||
}
|
||||
final changed = await context.push<bool>(AppRoutes.todoEdit(_todo!.id));
|
||||
if (changed == true) {
|
||||
await _loadTodo();
|
||||
if (mounted && _error != null) {
|
||||
Toast.show(context, '刷新失败: $_error', type: ToastType.error);
|
||||
_didMutate = true;
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
context.pop(true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -398,7 +400,7 @@ class _TodoDetailScreenState extends State<TodoDetailScreen> {
|
||||
try {
|
||||
await _todoApi.deleteTodo(_todo!.id);
|
||||
if (mounted) {
|
||||
context.pop();
|
||||
context.pop(true);
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
|
||||
@@ -16,6 +16,7 @@ import '../../../../shared/widgets/toast/toast_type.dart';
|
||||
import '../../../calendar/ui/calendar_state_manager.dart';
|
||||
import '../../../calendar/ui/widgets/bottom_dock.dart';
|
||||
import '../../data/todo_api.dart';
|
||||
import '../../data/todo_repository.dart';
|
||||
|
||||
class TodoQuadrantsScreen extends StatefulWidget {
|
||||
const TodoQuadrantsScreen({super.key});
|
||||
@@ -26,6 +27,7 @@ class TodoQuadrantsScreen extends StatefulWidget {
|
||||
|
||||
class _TodoQuadrantsScreenState extends State<TodoQuadrantsScreen> {
|
||||
final TodoApi _todoApi = sl<TodoApi>();
|
||||
final TodoRepository _todoRepository = sl<TodoRepository>();
|
||||
|
||||
List<TodoResponse> _todos = [];
|
||||
bool _isLoading = true;
|
||||
@@ -210,7 +212,9 @@ class _TodoQuadrantsScreenState extends State<TodoQuadrantsScreen> {
|
||||
});
|
||||
|
||||
try {
|
||||
final todos = await _todoApi.getTodos(status: 'pending');
|
||||
final todos = await _todoRepository.getPendingTodos(
|
||||
forceRefresh: !showPageLoader,
|
||||
);
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
@@ -263,12 +267,9 @@ class _TodoQuadrantsScreenState extends State<TodoQuadrantsScreen> {
|
||||
|
||||
Future<void> _completeTodo(TodoResponse todo) async {
|
||||
try {
|
||||
await _todoApi.completeTodo(todo.id);
|
||||
if (mounted) {
|
||||
Toast.show(context, '已完成', type: ToastType.success);
|
||||
}
|
||||
await _todoRepository.completeTodo(todo.id);
|
||||
try {
|
||||
await _loadTodos();
|
||||
await _loadTodos(showPageLoader: false);
|
||||
} catch (_) {
|
||||
// ignore reload error
|
||||
}
|
||||
@@ -279,14 +280,17 @@ class _TodoQuadrantsScreenState extends State<TodoQuadrantsScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
void _navigateToDetail(TodoResponse todo) {
|
||||
context.push(AppRoutes.todoDetail(todo.id));
|
||||
Future<void> _navigateToDetail(TodoResponse todo) async {
|
||||
final changed = await context.push<bool>(AppRoutes.todoDetail(todo.id));
|
||||
if (changed == true) {
|
||||
await _loadTodos(showPageLoader: false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _addTodo() async {
|
||||
final created = await context.push<bool>(AppRoutes.todoCreate);
|
||||
if (created == true) {
|
||||
await _loadTodos();
|
||||
await _loadTodos(showPageLoader: false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -298,7 +302,7 @@ class _TodoQuadrantsScreenState extends State<TodoQuadrantsScreen> {
|
||||
canPop: false,
|
||||
onPopInvokedWithResult: (didPop, result) {
|
||||
if (!didPop) {
|
||||
returnToHomePreserveState(context);
|
||||
returnToHomePreserveState(context, forceGoHome: true);
|
||||
}
|
||||
},
|
||||
child: SafeArea(
|
||||
@@ -322,25 +326,6 @@ class _TodoQuadrantsScreenState extends State<TodoQuadrantsScreen> {
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
AppPressable(
|
||||
borderRadius: BorderRadius.circular(AppRadius.full),
|
||||
onTap: _loadTodos,
|
||||
child: Container(
|
||||
width: 36,
|
||||
height: 36,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.messageBtnWrap,
|
||||
borderRadius: BorderRadius.circular(AppRadius.full),
|
||||
border: Border.all(color: AppColors.messageBtnBorder),
|
||||
),
|
||||
child: const Icon(
|
||||
LucideIcons.refreshCcw,
|
||||
size: 18,
|
||||
color: AppColors.slate600,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: AppSpacing.sm),
|
||||
AppPressable(
|
||||
borderRadius: BorderRadius.circular(AppRadius.full),
|
||||
onTap: _addTodo,
|
||||
@@ -444,6 +429,7 @@ class _TodoQuadrantsScreenState extends State<TodoQuadrantsScreen> {
|
||||
horizontal: AppSpacing.sm,
|
||||
),
|
||||
child: _TodoItemWidget(
|
||||
key: ValueKey(item.id),
|
||||
item: item,
|
||||
onComplete: () => _completeTodo(item),
|
||||
onTap: () => _navigateToDetail(item),
|
||||
@@ -563,7 +549,7 @@ class _TodoQuadrantsScreenState extends State<TodoQuadrantsScreen> {
|
||||
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;
|
||||
|
||||
const _TodoItemWidget({
|
||||
super.key,
|
||||
required this.item,
|
||||
required this.onComplete,
|
||||
required this.onTap,
|
||||
|
||||
@@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
import 'core/constants/app_constants.dart';
|
||||
import 'core/cache/cache_refresh_coordinator.dart';
|
||||
import 'core/di/injection.dart';
|
||||
import 'core/notifications/local_notification_service.dart';
|
||||
import 'core/notifications/reminder_notification_callbacks.dart';
|
||||
@@ -14,9 +15,13 @@ import 'features/auth/presentation/bloc/auth_bloc.dart';
|
||||
import 'features/auth/presentation/bloc/auth_event.dart';
|
||||
import 'features/auth/presentation/bloc/auth_state.dart';
|
||||
import 'features/calendar/data/services/calendar_service.dart';
|
||||
import 'features/calendar/data/services/calendar_repository.dart';
|
||||
import 'features/calendar/reminders/reminder_action_executor.dart';
|
||||
import 'features/calendar/reminders/ui/reminder_foreground_presenter.dart';
|
||||
import 'features/calendar/ui/calendar_state_manager.dart';
|
||||
import 'features/chat/presentation/bloc/chat_bloc.dart';
|
||||
import 'features/settings/data/services/settings_user_cache.dart';
|
||||
import 'features/todo/data/todo_repository.dart';
|
||||
|
||||
void main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
@@ -44,6 +49,25 @@ void main() async {
|
||||
final authBloc = sl<AuthBloc>();
|
||||
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(
|
||||
LinksyApp(
|
||||
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.first.eventId, 'evt_1');
|
||||
expect(pending.first.state, ReminderOutboxState.pending);
|
||||
verify(() => calendarService.archiveEvent('evt_1')).called(1);
|
||||
});
|
||||
|
||||
test('snooze reschedules +10m when event not expired', () async {
|
||||
|
||||
@@ -3,9 +3,23 @@ import 'package:social_app/features/home/ui/navigation/home_return_policy.dart';
|
||||
|
||||
void main() {
|
||||
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);
|
||||
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', () {
|
||||
|
||||
@@ -1,70 +1,69 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:social_app/core/cache/cache_policy.dart';
|
||||
import 'package:social_app/core/cache/hybrid_cache_store.dart';
|
||||
import 'package:social_app/core/cache/memory_cache_store.dart';
|
||||
import 'package:social_app/core/cache/persistent_cache_store.dart';
|
||||
import 'package:social_app/features/settings/data/services/settings_user_cache.dart';
|
||||
import 'package:social_app/features/settings/data/services/user_profile_cache_repository.dart';
|
||||
import 'package:social_app/features/users/data/models/user_response.dart';
|
||||
|
||||
void main() {
|
||||
test('getOrLoad calls loader only once when cache exists', () async {
|
||||
final cache = SettingsUserCache();
|
||||
test('getProfile caches latest user in memory field', () async {
|
||||
var loadCalls = 0;
|
||||
final repository = UserProfileCacheRepository(
|
||||
store: HybridCacheStore(
|
||||
memory: MemoryCacheStore(),
|
||||
persistent: PersistentCacheStore(),
|
||||
),
|
||||
policy: const CachePolicy(
|
||||
softTtl: Duration(minutes: 2),
|
||||
hardTtl: Duration(minutes: 30),
|
||||
minRefreshInterval: Duration(minutes: 1),
|
||||
),
|
||||
remoteLoader: () async {
|
||||
loadCalls += 1;
|
||||
return const UserResponse(id: 'u1', username: 'first');
|
||||
},
|
||||
);
|
||||
final cache = SettingsUserCache(repository);
|
||||
|
||||
Future<UserResponse> loader() async {
|
||||
loadCalls += 1;
|
||||
return const UserResponse(id: 'u1', username: 'first');
|
||||
}
|
||||
|
||||
final first = await cache.getOrLoad(loader);
|
||||
final second = await cache.getOrLoad(loader);
|
||||
final first = await cache.getProfile();
|
||||
final second = await cache.getProfile();
|
||||
|
||||
expect(first.username, 'first');
|
||||
expect(second.username, 'first');
|
||||
expect(cache.cachedUser?.id, 'u1');
|
||||
expect(loadCalls, 1);
|
||||
});
|
||||
|
||||
test('invalidate forces next load', () async {
|
||||
final cache = SettingsUserCache();
|
||||
var loadCalls = 0;
|
||||
test('invalidate clears memory cache', () {
|
||||
final repository = UserProfileCacheRepository(
|
||||
store: HybridCacheStore(
|
||||
memory: MemoryCacheStore(),
|
||||
persistent: PersistentCacheStore(),
|
||||
),
|
||||
remoteLoader: () async => const UserResponse(id: 'u1', username: 'first'),
|
||||
);
|
||||
final cache = SettingsUserCache(repository);
|
||||
|
||||
Future<UserResponse> loader() async {
|
||||
loadCalls += 1;
|
||||
return UserResponse(id: 'u$loadCalls', username: 'user$loadCalls');
|
||||
}
|
||||
|
||||
final first = await cache.getOrLoad(loader);
|
||||
cache.set(const UserResponse(id: 'u1', username: 'first'));
|
||||
cache.invalidate();
|
||||
final second = await cache.getOrLoad(loader);
|
||||
|
||||
expect(first.id, 'u1');
|
||||
expect(second.id, 'u2');
|
||||
expect(loadCalls, 2);
|
||||
expect(cache.cachedUser, isNull);
|
||||
});
|
||||
|
||||
test(
|
||||
'invalidate blocks stale inflight response from repopulating cache',
|
||||
() async {
|
||||
final cache = SettingsUserCache();
|
||||
final completer = Completer<UserResponse>();
|
||||
var loadCalls = 0;
|
||||
test('set should update cached user immediately', () {
|
||||
final repository = UserProfileCacheRepository(
|
||||
store: HybridCacheStore(
|
||||
memory: MemoryCacheStore(),
|
||||
persistent: PersistentCacheStore(),
|
||||
),
|
||||
remoteLoader: () async => const UserResponse(id: 'u1', username: 'first'),
|
||||
);
|
||||
final cache = SettingsUserCache(repository);
|
||||
|
||||
Future<UserResponse> slowLoader() {
|
||||
loadCalls += 1;
|
||||
return completer.future;
|
||||
}
|
||||
cache.set(const UserResponse(id: 'u2', username: 'next'));
|
||||
|
||||
final pending = cache.getOrLoad(slowLoader);
|
||||
cache.invalidate();
|
||||
completer.complete(const UserResponse(id: 'u1', username: 'stale'));
|
||||
await pending;
|
||||
|
||||
final fresh = await cache.getOrLoad(() async {
|
||||
loadCalls += 1;
|
||||
return const UserResponse(id: 'u2', username: 'fresh');
|
||||
});
|
||||
|
||||
expect(fresh.id, 'u2');
|
||||
expect(cache.cachedUser?.id, 'u2');
|
||||
expect(loadCalls, 2);
|
||||
},
|
||||
);
|
||||
expect(cache.cachedUser?.id, 'u2');
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:social_app/core/cache/hybrid_cache_store.dart';
|
||||
import 'package:social_app/core/cache/memory_cache_store.dart';
|
||||
import 'package:social_app/core/cache/persistent_cache_store.dart';
|
||||
import 'package:social_app/core/api/i_api_client.dart';
|
||||
import 'package:social_app/core/di/injection.dart';
|
||||
import 'package:social_app/features/friends/data/friends_api.dart';
|
||||
import 'package:social_app/features/settings/data/services/settings_user_cache.dart';
|
||||
import 'package:social_app/features/settings/data/services/user_profile_cache_repository.dart';
|
||||
import 'package:social_app/features/settings/ui/screens/settings_screen.dart';
|
||||
import 'package:social_app/features/users/data/models/user_response.dart';
|
||||
import 'package:social_app/features/users/data/users_api.dart';
|
||||
@@ -78,10 +82,21 @@ void main() {
|
||||
if (sl.isRegistered<SettingsUserCache>()) {
|
||||
sl.unregister<SettingsUserCache>();
|
||||
}
|
||||
if (sl.isRegistered<UserProfileCacheRepository>()) {
|
||||
sl.unregister<UserProfileCacheRepository>();
|
||||
}
|
||||
usersApi = _FakeUsersApi(apiClient);
|
||||
final repository = UserProfileCacheRepository(
|
||||
store: HybridCacheStore(
|
||||
memory: MemoryCacheStore(),
|
||||
persistent: PersistentCacheStore(),
|
||||
),
|
||||
remoteLoader: usersApi.getMe,
|
||||
);
|
||||
sl.registerSingleton<UsersApi>(usersApi);
|
||||
sl.registerSingleton<FriendsApi>(_FakeFriendsApi(apiClient));
|
||||
sl.registerSingleton<SettingsUserCache>(SettingsUserCache());
|
||||
sl.registerSingleton<UserProfileCacheRepository>(repository);
|
||||
sl.registerSingleton<SettingsUserCache>(SettingsUserCache(repository));
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
@@ -94,6 +109,9 @@ void main() {
|
||||
if (sl.isRegistered<SettingsUserCache>()) {
|
||||
await sl.unregister<SettingsUserCache>();
|
||||
}
|
||||
if (sl.isRegistered<UserProfileCacheRepository>()) {
|
||||
await sl.unregister<UserProfileCacheRepository>();
|
||||
}
|
||||
});
|
||||
|
||||
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.refresh(todo)
|
||||
except SQLAlchemyError:
|
||||
await self._session.rollback()
|
||||
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 改造并保留缓存模块。
|
||||
2. 若缓存接入问题集中,可按域回退(user/calendar/todo 分域开关)。
|
||||
|
||||
## 12. 待确认参数(实施前锁定)
|
||||
## 12. 最终落地参数(2026-03-20)
|
||||
|
||||
1. 软/硬过期默认值是否按本设计直接采用。
|
||||
2. 是否立即展示“上次同步时间”。
|
||||
3. 是否在首版启用“网络恢复自动静默刷新”。
|
||||
1. 导航分级
|
||||
- 一级页面唯一为 `Home`。
|
||||
- 二级页面(日/月、待办、设置)侧滑返回统一回 `Home`,不允许直接退出 App。
|
||||
- App 退出入口仅保留在 `Home`。
|
||||
2. 缓存默认策略
|
||||
- `user:profile`:软过期 30min,硬过期 24h。
|
||||
- `calendar:day`:软过期 2min,硬过期 30min。
|
||||
- `calendar:month`:软过期 5min,硬过期 60min。
|
||||
- `todo:list:pending`:软过期 2min,硬过期 30min。
|
||||
3. 生命周期刷新
|
||||
- App 回前台时启用最小间隔 5min 的静默刷新协调器。
|
||||
4. 提醒归档策略
|
||||
- App 活跃态点击取消:立即请求后端归档。
|
||||
- 延迟归档(pending/outbox)仅用于 App 不可用场景兜底。
|
||||
|
||||
Reference in New Issue
Block a user