fix: resolve navigation-cache regressions and todo UX

This commit is contained in:
qzl
2026-03-20 16:45:08 +08:00
parent 3f1858d733
commit 55f3805ee9
15 changed files with 160 additions and 105 deletions
+20
View File
@@ -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
+8 -1
View File
@@ -1,12 +1,19 @@
import 'dart:async';
import 'hybrid_cache_store.dart';
class CacheInvalidator {
final HybridCacheStore? _store;
final Set<String> _invalidated = <String>{};
CacheInvalidator({HybridCacheStore? store});
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) {
@@ -1,7 +1,5 @@
import 'dart:math';
import 'package:flutter/widgets.dart';
import '../data/services/calendar_service.dart';
import '../../../core/notifications/local_notification_service.dart';
import 'models/reminder_action.dart';
@@ -13,23 +11,16 @@ class ReminderActionExecutor {
final LocalNotificationService _notificationService;
final ReminderOutboxStore _outboxStore;
final Random _random;
final bool Function() _isAppActive;
ReminderActionExecutor({
required CalendarService calendarService,
required LocalNotificationService notificationService,
required ReminderOutboxStore outboxStore,
Random? random,
bool Function()? isAppActive,
}) : _calendarService = calendarService,
_notificationService = notificationService,
_outboxStore = outboxStore,
_random = random ?? Random(),
_isAppActive =
isAppActive ??
(() =>
WidgetsBinding.instance.lifecycleState ==
AppLifecycleState.resumed);
_random = random ?? Random();
Future<void> handleAction({
required ReminderAction action,
@@ -95,9 +86,11 @@ class ReminderActionExecutor {
}
Future<void> _archiveEvent(String eventId, ReminderAction action) async {
if (_isAppActive()) {
try {
await _calendarService.archiveEvent(eventId);
return;
} catch (_) {
// fall through to enqueue local outbox for retry
}
final opId =
@@ -304,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,
@@ -625,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(
@@ -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) {
@@ -150,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,
@@ -345,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),
@@ -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) {
@@ -37,7 +37,13 @@ void returnToHomePreserveState(
context.pop();
return;
case HomeReturnAction.goHome:
context.go(AppRoutes.homeMain);
return;
case HomeReturnAction.goHomeForDock:
if (context.canPop()) {
context.pop();
return;
}
context.go(AppRoutes.homeMain);
return;
}
@@ -46,11 +46,7 @@ class TodoRepository {
);
if (cached != null) {
final next = cached.value
.map(
(todo) => todo.id == id
? todo.copyWith(status: 'completed', completedAt: now())
: todo,
)
.where((todo) => todo.id != id)
.toList(growable: false);
await store.write<CacheEntry<List<TodoResponse>>>(
pendingListKey,
@@ -58,9 +54,9 @@ class TodoRepository {
);
}
invalidator.invalidate(pendingListKey);
try {
await api.completeTodo(id);
invalidator.invalidate(pendingListKey);
} catch (error) {
if (cached != null) {
await store.write<CacheEntry<List<TodoResponse>>>(
@@ -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) {
@@ -268,11 +268,8 @@ class _TodoQuadrantsScreenState extends State<TodoQuadrantsScreen> {
Future<void> _completeTodo(TodoResponse todo) async {
try {
await _todoRepository.completeTodo(todo.id);
if (mounted) {
Toast.show(context, '已完成', type: ToastType.success);
}
try {
await _loadTodos();
await _loadTodos(showPageLoader: false);
} catch (_) {
// ignore reload error
}
@@ -283,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);
}
}
@@ -326,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,
@@ -448,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),
@@ -603,6 +585,7 @@ class _TodoItemWidget extends StatefulWidget {
final VoidCallback onTap;
const _TodoItemWidget({
super.key,
required this.item,
required this.onComplete,
required this.onTap,
@@ -30,7 +30,6 @@ void main() {
calendarService: calendarService,
notificationService: notificationService,
outboxStore: outboxStore,
isAppActive: () => true,
);
});
@@ -58,50 +57,13 @@ void main() {
expect(pending, isEmpty);
});
test(
'archive action should send remote archive immediately when app active',
() async {
when(
() => notificationService.cancelEventReminder('evt_live'),
).thenAnswer((_) async {});
when(
() => calendarService.archiveEvent('evt_live'),
).thenAnswer((_) async => null);
executor = ReminderActionExecutor(
calendarService: calendarService,
notificationService: notificationService,
outboxStore: outboxStore,
isAppActive: () => true,
);
await executor.handleAction(
action: ReminderAction.archive,
payload: ReminderPayload(
eventId: 'evt_live',
title: 'sync',
startAt: DateTime.parse('2026-03-18T16:00:00+08:00'),
timezone: 'Asia/Shanghai',
),
);
verify(() => calendarService.archiveEvent('evt_live')).called(1);
final pending = await outboxStore.listPending();
expect(pending, isEmpty);
},
);
test('archive in inactive app writes pending outbox item', () async {
test('archive failure writes pending outbox item', () async {
when(
() => notificationService.cancelEventReminder('evt_1'),
).thenAnswer((_) async {});
executor = ReminderActionExecutor(
calendarService: calendarService,
notificationService: notificationService,
outboxStore: outboxStore,
isAppActive: () => false,
);
when(
() => calendarService.archiveEvent('evt_1'),
).thenThrow(Exception('offline'));
await executor.handleAction(
action: ReminderAction.archive,
@@ -117,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 {
@@ -12,7 +12,7 @@ class _MockTodoApi extends Mock implements TodoApi {}
void main() {
test(
'complete todo should optimistically update and invalidate pending list key',
'complete todo should optimistically remove item and invalidate pending list key',
() async {
final api = _MockTodoApi();
final store = HybridCacheStore(
@@ -50,7 +50,7 @@ void main() {
final updated = await store.read<CacheEntry<List<TodoResponse>>>(
TodoRepository.pendingListKey,
);
expect(updated?.value.first.status, 'completed');
expect(updated, isNull);
expect(invalidator.wasInvalidated(TodoRepository.pendingListKey), true);
},
);
+1
View File
@@ -170,6 +170,7 @@ class TodoService(BaseService):
)
await self._session.commit()
await self._session.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)