From e64b9c670c3d518aafcc4ba2c27358eab4744909 Mon Sep 17 00:00:00 2001 From: qzl Date: Fri, 20 Mar 2026 15:37:59 +0800 Subject: [PATCH] feat: add todo cache repository and precise invalidation --- apps/lib/core/cache/cache_invalidator.dart | 9 +-- apps/lib/core/di/injection.dart | 8 ++ apps/lib/features/todo/data/todo_api.dart | 4 + .../features/todo/data/todo_repository.dart | 79 +++++++++++++++++++ .../ui/screens/todo_quadrants_screen.dart | 8 +- .../features/todo/todo_repository_test.dart | 57 +++++++++++++ 6 files changed, 155 insertions(+), 10 deletions(-) create mode 100644 apps/lib/features/todo/data/todo_repository.dart create mode 100644 apps/test/features/todo/todo_repository_test.dart diff --git a/apps/lib/core/cache/cache_invalidator.dart b/apps/lib/core/cache/cache_invalidator.dart index b6e3181..c61d3bc 100644 --- a/apps/lib/core/cache/cache_invalidator.dart +++ b/apps/lib/core/cache/cache_invalidator.dart @@ -1,19 +1,12 @@ -import 'dart:async'; - import 'hybrid_cache_store.dart'; class CacheInvalidator { - final HybridCacheStore? _store; final Set _invalidated = {}; - CacheInvalidator({HybridCacheStore? store}) : _store = store; + CacheInvalidator({HybridCacheStore? store}); void invalidate(String key) { _invalidated.add(key); - final removeFuture = _store?.remove(key); - if (removeFuture != null) { - unawaited(removeFuture); - } } void invalidateCalendarDay(DateTime date) { diff --git a/apps/lib/core/di/injection.dart b/apps/lib/core/di/injection.dart index 0194e4f..d8d28a4 100644 --- a/apps/lib/core/di/injection.dart +++ b/apps/lib/core/di/injection.dart @@ -29,6 +29,7 @@ import '../../features/settings/data/services/settings_user_cache.dart'; import '../../features/settings/data/services/user_profile_cache_repository.dart'; import '../../features/users/data/users_api.dart'; import '../../features/todo/data/todo_api.dart'; +import '../../features/todo/data/todo_repository.dart'; final sl = GetIt.instance; @@ -124,6 +125,13 @@ Future configureDependencies() async { final todoApi = TodoApi(apiClient); sl.registerSingleton(todoApi); + sl.registerSingleton( + TodoRepository( + api: todoApi, + store: hybridCacheStore, + invalidator: sl(), + ), + ); final authRepository = AuthRepositoryImpl( api: authApi, diff --git a/apps/lib/features/todo/data/todo_api.dart b/apps/lib/features/todo/data/todo_api.dart index 393cba0..70cc419 100644 --- a/apps/lib/features/todo/data/todo_api.dart +++ b/apps/lib/features/todo/data/todo_api.dart @@ -17,6 +17,10 @@ class TodoApi { return data.map((json) => TodoResponse.fromJson(json)).toList(); } + Future> getPendingTodos() { + return getTodos(status: 'pending'); + } + Future getTodo(String id) async { final response = await _client.get('$_prefix/$id'); return TodoResponse.fromJson(response.data); diff --git a/apps/lib/features/todo/data/todo_repository.dart b/apps/lib/features/todo/data/todo_repository.dart new file mode 100644 index 0000000..ee7fa08 --- /dev/null +++ b/apps/lib/features/todo/data/todo_repository.dart @@ -0,0 +1,79 @@ +import 'dart:async'; + +import '../../../core/cache/cache_entry.dart'; +import '../../../core/cache/cache_invalidator.dart'; +import '../../../core/cache/hybrid_cache_store.dart'; +import 'todo_api.dart'; + +class TodoRepository { + static const String pendingListKey = 'todo:list:pending'; + + final TodoApi api; + final HybridCacheStore store; + final CacheInvalidator invalidator; + final DateTime Function() now; + + TodoRepository({ + required this.api, + required this.store, + required this.invalidator, + DateTime Function()? now, + }) : now = now ?? DateTime.now; + + Future> getPendingTodos({ + bool forceRefresh = false, + }) async { + if (!forceRefresh) { + final cached = await store.read>>( + pendingListKey, + ); + if (cached != null) { + return cached.value; + } + } + + final remote = await api.getPendingTodos(); + await store.write>>( + pendingListKey, + CacheEntry(value: remote, fetchedAt: now()), + ); + return remote; + } + + Future completeTodo(String id) async { + final cached = await store.read>>( + pendingListKey, + ); + if (cached != null) { + final next = cached.value + .map( + (todo) => todo.id == id + ? todo.copyWith(status: 'completed', completedAt: now()) + : todo, + ) + .toList(growable: false); + await store.write>>( + pendingListKey, + CacheEntry(value: next, fetchedAt: now()), + ); + } + + invalidator.invalidate(pendingListKey); + try { + await api.completeTodo(id); + } catch (error) { + if (cached != null) { + await store.write>>( + pendingListKey, + cached, + ); + } + rethrow; + } + } + + Future invalidatePending() { + invalidator.invalidate(pendingListKey); + return Future.value(); + } +} diff --git a/apps/lib/features/todo/ui/screens/todo_quadrants_screen.dart b/apps/lib/features/todo/ui/screens/todo_quadrants_screen.dart index 23ddf6b..cd46678 100644 --- a/apps/lib/features/todo/ui/screens/todo_quadrants_screen.dart +++ b/apps/lib/features/todo/ui/screens/todo_quadrants_screen.dart @@ -16,6 +16,7 @@ import '../../../../shared/widgets/toast/toast_type.dart'; import '../../../calendar/ui/calendar_state_manager.dart'; import '../../../calendar/ui/widgets/bottom_dock.dart'; import '../../data/todo_api.dart'; +import '../../data/todo_repository.dart'; class TodoQuadrantsScreen extends StatefulWidget { const TodoQuadrantsScreen({super.key}); @@ -26,6 +27,7 @@ class TodoQuadrantsScreen extends StatefulWidget { class _TodoQuadrantsScreenState extends State { final TodoApi _todoApi = sl(); + final TodoRepository _todoRepository = sl(); List _todos = []; bool _isLoading = true; @@ -210,7 +212,9 @@ class _TodoQuadrantsScreenState extends State { }); try { - final todos = await _todoApi.getTodos(status: 'pending'); + final todos = await _todoRepository.getPendingTodos( + forceRefresh: !showPageLoader, + ); if (!mounted) { return; } @@ -263,7 +267,7 @@ class _TodoQuadrantsScreenState extends State { Future _completeTodo(TodoResponse todo) async { try { - await _todoApi.completeTodo(todo.id); + await _todoRepository.completeTodo(todo.id); if (mounted) { Toast.show(context, '已完成', type: ToastType.success); } diff --git a/apps/test/features/todo/todo_repository_test.dart b/apps/test/features/todo/todo_repository_test.dart new file mode 100644 index 0000000..eab12ee --- /dev/null +++ b/apps/test/features/todo/todo_repository_test.dart @@ -0,0 +1,57 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:social_app/core/cache/cache_entry.dart'; +import 'package:social_app/core/cache/cache_invalidator.dart'; +import 'package:social_app/core/cache/hybrid_cache_store.dart'; +import 'package:social_app/core/cache/memory_cache_store.dart'; +import 'package:social_app/core/cache/persistent_cache_store.dart'; +import 'package:social_app/features/todo/data/todo_api.dart'; +import 'package:social_app/features/todo/data/todo_repository.dart'; + +class _MockTodoApi extends Mock implements TodoApi {} + +void main() { + test( + 'complete todo should optimistically update and invalidate pending list key', + () async { + final api = _MockTodoApi(); + final store = HybridCacheStore( + memory: MemoryCacheStore(), + persistent: PersistentCacheStore(), + ); + final invalidator = CacheInvalidator(store: store); + final repository = TodoRepository( + api: api, + store: store, + invalidator: invalidator, + ); + + final cached = TodoResponse( + id: 'todo_1', + ownerId: 'u1', + title: 't1', + priority: 1, + order: 0, + status: 'pending', + createdAt: DateTime(2026, 3, 20, 10), + updatedAt: DateTime(2026, 3, 20, 10), + ); + await store.write>>( + TodoRepository.pendingListKey, + CacheEntry(value: [cached], fetchedAt: DateTime(2026, 3, 20, 10, 0)), + ); + + when( + () => api.completeTodo('todo_1'), + ).thenAnswer((_) async => cached.copyWith(status: 'completed')); + + await repository.completeTodo('todo_1'); + + final updated = await store.read>>( + TodoRepository.pendingListKey, + ); + expect(updated?.value.first.status, 'completed'); + expect(invalidator.wasInvalidated(TodoRepository.pendingListKey), true); + }, + ); +}