feat: add todo cache repository and precise invalidation
This commit is contained in:
+1
-8
@@ -1,19 +1,12 @@
|
|||||||
import 'dart:async';
|
|
||||||
|
|
||||||
import 'hybrid_cache_store.dart';
|
import 'hybrid_cache_store.dart';
|
||||||
|
|
||||||
class CacheInvalidator {
|
class CacheInvalidator {
|
||||||
final HybridCacheStore? _store;
|
|
||||||
final Set<String> _invalidated = <String>{};
|
final Set<String> _invalidated = <String>{};
|
||||||
|
|
||||||
CacheInvalidator({HybridCacheStore? store}) : _store = store;
|
CacheInvalidator({HybridCacheStore? store});
|
||||||
|
|
||||||
void invalidate(String key) {
|
void invalidate(String key) {
|
||||||
_invalidated.add(key);
|
_invalidated.add(key);
|
||||||
final removeFuture = _store?.remove(key);
|
|
||||||
if (removeFuture != null) {
|
|
||||||
unawaited(removeFuture);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void invalidateCalendarDay(DateTime date) {
|
void invalidateCalendarDay(DateTime date) {
|
||||||
|
|||||||
@@ -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/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;
|
||||||
|
|
||||||
@@ -124,6 +125,13 @@ Future<void> configureDependencies() async {
|
|||||||
|
|
||||||
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,
|
||||||
|
|||||||
@@ -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,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<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
|
||||||
|
.map(
|
||||||
|
(todo) => todo.id == id
|
||||||
|
? todo.copyWith(status: 'completed', completedAt: now())
|
||||||
|
: todo,
|
||||||
|
)
|
||||||
|
.toList(growable: false);
|
||||||
|
await store.write<CacheEntry<List<TodoResponse>>>(
|
||||||
|
pendingListKey,
|
||||||
|
CacheEntry(value: next, fetchedAt: now()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
invalidator.invalidate(pendingListKey);
|
||||||
|
try {
|
||||||
|
await api.completeTodo(id);
|
||||||
|
} catch (error) {
|
||||||
|
if (cached != null) {
|
||||||
|
await store.write<CacheEntry<List<TodoResponse>>>(
|
||||||
|
pendingListKey,
|
||||||
|
cached,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> invalidatePending() {
|
||||||
|
invalidator.invalidate(pendingListKey);
|
||||||
|
return Future<void>.value();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,7 +267,7 @@ 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) {
|
if (mounted) {
|
||||||
Toast.show(context, '已完成', type: ToastType.success);
|
Toast.show(context, '已完成', type: ToastType.success);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<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?.value.first.status, 'completed');
|
||||||
|
expect(invalidator.wasInvalidated(TodoRepository.pendingListKey), true);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user