From fbf15bc937dba27be5da2e10d820505450bf5fe5 Mon Sep 17 00:00:00 2001 From: qzl Date: Fri, 20 Mar 2026 11:09:38 +0800 Subject: [PATCH] =?UTF-8?q?refactor(todo):=20=E7=A7=BB=E9=99=A4=20due=5Fat?= =?UTF-8?q?=20=E5=AD=97=E6=AE=B5=EF=BC=8C=E6=94=B9=E7=94=A8=20order=20?= =?UTF-8?q?=E5=AD=97=E6=AE=B5=E7=AE=A1=E7=90=86=E8=B1=A1=E9=99=90=E5=86=85?= =?UTF-8?q?=E9=A1=BA=E5=BA=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/lib/features/todo/data/todo_api.dart | 51 +- .../todo/ui/screens/todo_detail_screen.dart | 7 +- .../ui/screens/todo_quadrants_screen.dart | 548 ++++++++++-------- .../todo/ui/widgets/todo_drag_item.dart | 4 +- apps/pubspec.yaml | 1 + .../features/todo/quadrant_drag_test.dart | 51 +- ...20260320_0001_todo_order_replace_due_at.py | 108 ++++ backend/src/models/todos.py | 9 +- backend/src/schemas/todo/__init__.py | 3 + backend/src/schemas/todo/contracts.py | 7 + backend/src/v1/todo/repository.py | 18 +- backend/src/v1/todo/router.py | 19 +- backend/src/v1/todo/schemas.py | 22 +- backend/src/v1/todo/service.py | 71 ++- backend/tests/unit/v1/todo/test_schemas.py | 65 +++ ...3-20-navigation-cache-decoupling-design.md | 274 +++++++++ ...on-cache-decoupling-implementation-plan.md | 438 ++++++++++++++ docs/protocols/models/todo.md | 43 ++ ...nder-unified-interaction-implementation.md | 391 ------------- ...03-20-todo-quadrant-drag-implementation.md | 524 ----------------- ...dar-reminder-unified-interaction-design.md | 215 ------- .../2026-03-20-todo-quadrant-drag-design.md | 113 ---- 22 files changed, 1458 insertions(+), 1524 deletions(-) create mode 100644 backend/alembic/versions/20260320_0001_todo_order_replace_due_at.py create mode 100644 backend/src/schemas/todo/__init__.py create mode 100644 backend/src/schemas/todo/contracts.py create mode 100644 backend/tests/unit/v1/todo/test_schemas.py create mode 100644 docs/plans/2026-03-20-navigation-cache-decoupling-design.md create mode 100644 docs/plans/2026-03-20-navigation-cache-decoupling-implementation-plan.md create mode 100644 docs/protocols/models/todo.md delete mode 100644 docs/superpowers/plans/2026-03-19-calendar-reminder-unified-interaction-implementation.md delete mode 100644 docs/superpowers/plans/2026-03-20-todo-quadrant-drag-implementation.md delete mode 100644 docs/superpowers/specs/2026-03-19-calendar-reminder-unified-interaction-design.md delete mode 100644 docs/superpowers/specs/2026-03-20-todo-quadrant-drag-design.md diff --git a/apps/lib/features/todo/data/todo_api.dart b/apps/lib/features/todo/data/todo_api.dart index 49e9ef9..393cba0 100644 --- a/apps/lib/features/todo/data/todo_api.dart +++ b/apps/lib/features/todo/data/todo_api.dart @@ -25,13 +25,13 @@ class TodoApi { Future createTodo({ required String title, String? description, - DateTime? dueAt, int priority = 1, + int? order, List scheduleItemIds = const [], }) async { final data = {'title': title, 'priority': priority}; if (description != null) data['description'] = description; - if (dueAt != null) data['due_at'] = dueAt.toIso8601String(); + if (order != null) data['order'] = order; if (scheduleItemIds.isNotEmpty) data['schedule_item_ids'] = scheduleItemIds; final response = await _client.post(_prefix, data: data); @@ -42,16 +42,16 @@ class TodoApi { String id, { String? title, String? description, - DateTime? dueAt, int? priority, + int? order, String? status, List? scheduleItemIds, }) async { final data = {}; if (title != null) data['title'] = title; if (description != null) data['description'] = description; - if (dueAt != null) data['due_at'] = dueAt.toIso8601String(); if (priority != null) data['priority'] = priority; + if (order != null) data['order'] = order; if (status != null) data['status'] = status; if (scheduleItemIds != null) data['schedule_item_ids'] = scheduleItemIds; @@ -59,12 +59,19 @@ class TodoApi { return TodoResponse.fromJson(response.data); } - Future updateTodoPriority(String id, int priority) async { - try { - await _client.patch('$_prefix/$id', data: {'priority': priority}); - } catch (_) { - // Ignore response parsing errors, just need to know if request succeeded - } + Future reorderTodos(List items) async { + final data = { + 'items': items + .map( + (item) => { + 'id': item.id, + 'priority': item.priority, + 'order': item.order, + }, + ) + .toList(), + }; + await _client.patch('$_prefix/reorder', data: data); } Future completeTodo(String id) async { @@ -77,6 +84,18 @@ class TodoApi { } } +class TodoReorderItemPayload { + final String id; + final int priority; + final int order; + + const TodoReorderItemPayload({ + required this.id, + required this.priority, + required this.order, + }); +} + class ScheduleItemBasic { final String id; final String title; @@ -107,7 +126,7 @@ class TodoResponse { final String ownerId; final String title; final String? description; - final DateTime? dueAt; + final int order; final int priority; final String status; final DateTime? completedAt; @@ -120,8 +139,8 @@ class TodoResponse { required this.ownerId, required this.title, this.description, - this.dueAt, required this.priority, + required this.order, required this.status, this.completedAt, required this.createdAt, @@ -136,10 +155,8 @@ class TodoResponse { ownerId: json['owner_id'] as String, title: json['title'] as String, description: json['description'] as String?, - dueAt: json['due_at'] != null - ? DateTime.parse(json['due_at'] as String) - : null, priority: json['priority'] as int, + order: json['order'] as int, status: json['status'] as String, completedAt: json['completed_at'] != null ? DateTime.parse(json['completed_at'] as String) @@ -157,8 +174,8 @@ class TodoResponse { String? ownerId, String? title, String? description, - DateTime? dueAt, int? priority, + int? order, String? status, DateTime? completedAt, DateTime? createdAt, @@ -170,8 +187,8 @@ class TodoResponse { ownerId: ownerId ?? this.ownerId, title: title ?? this.title, description: description ?? this.description, - dueAt: dueAt ?? this.dueAt, priority: priority ?? this.priority, + order: order ?? this.order, status: status ?? this.status, completedAt: completedAt ?? this.completedAt, createdAt: createdAt ?? this.createdAt, diff --git a/apps/lib/features/todo/ui/screens/todo_detail_screen.dart b/apps/lib/features/todo/ui/screens/todo_detail_screen.dart index 669132e..4c44524 100644 --- a/apps/lib/features/todo/ui/screens/todo_detail_screen.dart +++ b/apps/lib/features/todo/ui/screens/todo_detail_screen.dart @@ -288,12 +288,7 @@ class _TodoDetailScreenState extends State { String _buildSubtitle() { final parts = []; - if (_todo!.dueAt != null) { - final due = _todo!.dueAt!; - parts.add( - '截止 ${due.month}月${due.day}日 ${due.hour.toString().padLeft(2, '0')}:${due.minute.toString().padLeft(2, '0')}', - ); - } + parts.add('象限内顺序 #${_todo!.order + 1}'); if (_todo!.scheduleItems.isNotEmpty) { parts.add('已拆分为${_todo!.scheduleItems.length}个日历事件'); } else { 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 aaecf4a..165418d 100644 --- a/apps/lib/features/todo/ui/screens/todo_quadrants_screen.dart +++ b/apps/lib/features/todo/ui/screens/todo_quadrants_screen.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:drag_and_drop_lists/drag_and_drop_lists.dart'; import 'package:go_router/go_router.dart'; import 'package:lucide_icons/lucide_icons.dart'; import '../../../../core/di/injection.dart'; @@ -15,7 +16,6 @@ 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 '../widgets/todo_drag_item.dart'; class TodoQuadrantsScreen extends StatefulWidget { const TodoQuadrantsScreen({super.key}); @@ -31,65 +31,56 @@ class _TodoQuadrantsScreenState extends State { bool _isLoading = true; bool _isPullRefreshing = false; bool _loadingTodosRequest = false; + bool _isReordering = false; String? _error; - String? _draggingTodoId; - int? _dragTargetQuadrant; - int? _dragInsertIndex; - - bool get _isDragging => _draggingTodoId != null; - - void _onDragStart(String todoId) { - setState(() { - _draggingTodoId = todoId; - _dragTargetQuadrant = null; - _dragInsertIndex = null; - }); - } - - void _onDragEnd() { - setState(() { - _draggingTodoId = null; - _dragTargetQuadrant = null; - _dragInsertIndex = null; - }); - } - - void _onDragEnterQuadrant(int quadrant) { - setState(() { - _dragTargetQuadrant = quadrant; - }); - } - - void _onDragUpdateInsertIndex(int index) { - setState(() { - _dragInsertIndex = index; - }); - } - - Future _onDrop( - String todoId, - int targetQuadrant, - int insertIndex, + Future _onItemReorder( + int oldItemIndex, + int oldListIndex, + int newItemIndex, + int newListIndex, ) async { + if (_isReordering) { + return; + } + + final sourceQuadrant = _quadrantByListIndex(oldListIndex); + final targetQuadrant = _quadrantByListIndex(newListIndex); + final sourceItems = _sortedQuadrantTodos(sourceQuadrant); + if (oldItemIndex < 0 || oldItemIndex >= sourceItems.length) { + return; + } + + final todoId = sourceItems[oldItemIndex].id; final previousTodos = List.from(_todos); try { - final todo = _todos.firstWhere((t) => t.id == todoId); - final sourceQuadrant = todo.priority; + setState(() { + _isReordering = true; + }); - if (sourceQuadrant == targetQuadrant) { - _onDragEnd(); + final reordered = _reorderTodos( + todoId: todoId, + sourceQuadrant: sourceQuadrant, + targetQuadrant: targetQuadrant, + insertIndex: newItemIndex, + ); + if (reordered == null) { return; } - setState(() { - final index = _todos.indexWhere((t) => t.id == todoId); - if (index != -1) { - _todos[index] = _todos[index].copyWith(priority: targetQuadrant); - } - }); + setState(() => _todos = reordered.todos); - await _todoApi.updateTodoPriority(todoId, targetQuadrant); + await _todoApi.reorderTodos( + reordered.changedTodos + .map( + (updated) => TodoReorderItemPayload( + id: updated.id, + priority: updated.priority, + order: updated.order, + ), + ) + .toList(growable: false), + ); } catch (e) { if (!mounted) return; setState(() { @@ -97,12 +88,104 @@ class _TodoQuadrantsScreenState extends State { }); Toast.show(context, '移动失败', type: ToastType.error); } finally { - if (mounted) _onDragEnd(); + if (mounted) { + setState(() { + _isReordering = false; + }); + } } } - void _onDragLeave() { - // 清除高亮 + int _quadrantByListIndex(int listIndex) { + switch (listIndex) { + case 0: + return 1; + case 1: + return 3; + case 2: + return 2; + default: + return 1; + } + } + + _ReorderResult? _reorderTodos({ + required String todoId, + required int sourceQuadrant, + required int targetQuadrant, + required int insertIndex, + }) { + final byId = {for (final todo in _todos) todo.id: todo}; + final moving = byId[todoId]; + if (moving == null) { + return null; + } + + final sourceList = _sortedQuadrantTodos(sourceQuadrant); + final targetList = sourceQuadrant == targetQuadrant + ? sourceList + : _sortedQuadrantTodos(targetQuadrant); + + final sourceIndex = sourceList.indexWhere((todo) => todo.id == todoId); + if (sourceIndex == -1) { + return null; + } + + final mutableSource = List.from(sourceList); + final extracted = mutableSource.removeAt(sourceIndex); + + int targetIndex = insertIndex; + if (sourceQuadrant == targetQuadrant && sourceIndex < targetIndex) { + targetIndex -= 1; + } + + if (targetIndex < 0) { + targetIndex = 0; + } + + final mutableTarget = sourceQuadrant == targetQuadrant + ? mutableSource + : List.from(targetList); + + if (targetIndex > mutableTarget.length) { + targetIndex = mutableTarget.length; + } + + final moved = extracted.copyWith(priority: targetQuadrant); + mutableTarget.insert(targetIndex, moved); + + final updatedById = {}; + void reindex(List list, int priority) { + for (var index = 0; index < list.length; index += 1) { + final current = list[index]; + final updated = current.copyWith(priority: priority, order: index); + list[index] = updated; + if (current.priority != updated.priority || + current.order != updated.order) { + updatedById[updated.id] = updated; + } + } + } + + if (sourceQuadrant == targetQuadrant) { + reindex(mutableTarget, targetQuadrant); + } else { + reindex(mutableSource, sourceQuadrant); + reindex(mutableTarget, targetQuadrant); + } + + if (updatedById.isEmpty) { + return null; + } + + final updatedTodos = _todos + .map((todo) => updatedById[todo.id] ?? todo) + .toList(growable: false); + + return _ReorderResult( + todos: updatedTodos, + changedTodos: updatedById.values.toList(growable: false), + ); } @override @@ -160,14 +243,23 @@ class _TodoQuadrantsScreenState extends State { await _loadTodos(showPageLoader: false); } - List get _importantUrgent => - _todos.where((t) => t.priority == 1).toList(); + List get _importantUrgent => _sortedQuadrantTodos(1); - List get _urgentNotImportant => - _todos.where((t) => t.priority == 3).toList(); + List get _urgentNotImportant => _sortedQuadrantTodos(3); - List get _importantNotUrgent => - _todos.where((t) => t.priority == 2).toList(); + List get _importantNotUrgent => _sortedQuadrantTodos(2); + + List _sortedQuadrantTodos(int quadrantValue) { + final list = _todos.where((t) => t.priority == quadrantValue).toList(); + list.sort((a, b) { + final byOrder = a.order.compareTo(b.order); + if (byOrder != 0) { + return byOrder; + } + return a.createdAt.compareTo(b.createdAt); + }); + return list; + } Future _completeTodo(TodoResponse todo) async { try { @@ -287,59 +379,12 @@ class _TodoQuadrantsScreenState extends State { return ErrorRetrySurface(message: '加载失败: $_error', onRetry: _loadTodos); } - Widget content = Column( - mainAxisSize: MainAxisSize.min, - children: [ - _buildQuadrant( - title: '重要紧急', - textColor: AppColors.g1Text, - dividerColor: AppColors.g1Divider, - borderColor: AppColors.g1Border, - items: _importantUrgent, - quadrantValue: 1, - onComplete: _completeTodo, - onTap: _navigateToDetail, - ), - const SizedBox(height: 12), - _buildQuadrant( - title: '紧急不重要', - textColor: AppColors.g2Text, - dividerColor: AppColors.g2Divider, - borderColor: AppColors.g2Border, - items: _urgentNotImportant, - quadrantValue: 3, - onComplete: _completeTodo, - onTap: _navigateToDetail, - ), - const SizedBox(height: 12), - _buildQuadrant( - title: '重要不紧急', - textColor: AppColors.g3Text, - dividerColor: AppColors.g3Divider, - borderColor: AppColors.g3Border, - items: _importantNotUrgent, - quadrantValue: 2, - onComplete: _completeTodo, - onTap: _navigateToDetail, - ), - ], - ); + final content = _buildDragBoard(); if (withScroll) { return Stack( children: [ - RefreshIndicator.noSpinner( - onRefresh: _onPullRefresh, - child: SingleChildScrollView( - padding: const EdgeInsets.only( - left: 16, - right: 16, - top: 4, - bottom: 96, - ), - child: content, - ), - ), + RefreshIndicator.noSpinner(onRefresh: _onPullRefresh, child: content), Align( alignment: Alignment.topCenter, child: AppPullRefreshFeedback(visible: _isPullRefreshing), @@ -348,141 +393,157 @@ class _TodoQuadrantsScreenState extends State { ); } - return Padding( - padding: const EdgeInsets.fromLTRB(16, 4, 16, 16), - child: content, - ); + return content; } - Widget _buildQuadrant({ - required String title, - required Color textColor, - required Color dividerColor, - required Color borderColor, - required List items, - required int quadrantValue, - required Future Function(TodoResponse) onComplete, - required void Function(TodoResponse) onTap, - }) { - return Container( - decoration: BoxDecoration( - color: AppColors.todoCardBg, - borderRadius: BorderRadius.circular(14), - border: Border.all(color: borderColor, width: 1), + Widget _buildDragBoard() { + final quadrants = [ + _QuadrantMeta( + value: 1, + title: '重要紧急', + textColor: AppColors.g1Text, + dividerColor: AppColors.g1Divider, + borderColor: AppColors.g1Border, + items: _importantUrgent, ), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _buildQuadrantHeader(title, textColor, items.length), - Container(height: 1, color: dividerColor), - const SizedBox(height: 8), - Padding( - padding: const EdgeInsets.fromLTRB(6, 0, 6, 8), - child: DragTarget( - onWillAcceptWithDetails: (details) { - _onDragEnterQuadrant(quadrantValue); - return true; - }, - onAcceptWithDetails: (details) { - final parts = details.data.split(':'); - final todoId = parts[0]; - _onDrop(todoId, quadrantValue, 0); - }, - onLeave: (_) { - _onDragLeave(); - }, - builder: (context, candidateData, rejectedData) { - final isDragOver = candidateData.isNotEmpty; - return Container( - decoration: BoxDecoration( - color: isDragOver - ? AppColors.blue50.withValues(alpha: 0.3) - : Colors.transparent, - borderRadius: BorderRadius.circular(8), - border: isDragOver - ? Border.all(color: AppColors.blue400, width: 2) - : null, + _QuadrantMeta( + value: 3, + title: '紧急不重要', + textColor: AppColors.g2Text, + dividerColor: AppColors.g2Divider, + borderColor: AppColors.g2Border, + items: _urgentNotImportant, + ), + _QuadrantMeta( + value: 2, + title: '重要不紧急', + textColor: AppColors.g3Text, + dividerColor: AppColors.g3Divider, + borderColor: AppColors.g3Border, + items: _importantNotUrgent, + ), + ]; + + final lists = quadrants + .map( + (meta) => DragAndDropList( + canDrag: false, + header: _buildQuadrantHeader(meta), + contentsWhenEmpty: _buildEmptyQuadrant(), + lastTarget: const SizedBox(height: AppSpacing.lg), + decoration: BoxDecoration( + color: AppColors.todoCardBg, + borderRadius: BorderRadius.circular(14), + border: Border.all(color: meta.borderColor), + ), + children: meta.items + .map( + (item) => DragAndDropItem( + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.sm, + ), + child: _TodoItemWidget( + item: item, + onComplete: () => _completeTodo(item), + onTap: () => _navigateToDetail(item), + ), + ), ), - child: items.isEmpty - ? SizedBox( - height: 60, - child: Center( - child: Text( - '暂无待办', - style: TextStyle( - fontFamily: 'Inter', - fontSize: 13, - color: AppColors.slate400, - ), - ), - ), - ) - : _buildQuadrantItemList( - items, - quadrantValue, - onComplete, - onTap, - ), - ); - }, - ), + ) + .toList(growable: false), ), - ], + ) + .toList(growable: false); + + return SingleChildScrollView( + padding: const EdgeInsets.fromLTRB( + AppSpacing.lg, + AppSpacing.xs, + AppSpacing.lg, + 96, + ), + child: DragAndDropLists( + children: lists, + onItemReorder: _onItemReorder, + onListReorder: (oldListIndex, newListIndex) {}, + listDivider: const SizedBox(height: AppSpacing.md), + itemDivider: Padding( + padding: const EdgeInsets.symmetric(horizontal: AppSpacing.md), + child: Container(height: 1, color: AppColors.slate100), + ), + listPadding: EdgeInsets.zero, + itemDecorationWhileDragging: BoxDecoration( + color: AppColors.todoCardBg, + borderRadius: BorderRadius.circular(AppRadius.md), + border: Border.all(color: AppColors.borderSecondary), + boxShadow: [ + BoxShadow( + color: AppColors.slate200.withValues(alpha: 0.6), + blurRadius: AppRadius.md, + offset: const Offset(0, AppSpacing.xs), + ), + ], + ), + itemGhost: const SizedBox(height: 42), + itemDragOnLongPress: true, + lastItemTargetHeight: AppSpacing.xl, + disableScrolling: true, ), ); } - Widget _buildQuadrantHeader(String title, Color textColor, int itemCount) { - return Padding( - padding: const EdgeInsets.fromLTRB(10, 10, 10, 8), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - title, - style: TextStyle( - fontFamily: 'Inter', - fontSize: 15, - fontWeight: FontWeight.w700, - color: textColor, - ), - ), - Text( - '${itemCount}项', - style: TextStyle( - fontFamily: 'Inter', - fontSize: 12, - fontWeight: FontWeight.w700, - color: textColor, - ), - ), - ], - ), - ); - } - - Widget _buildQuadrantItemList( - List items, - int quadrantValue, - Future Function(TodoResponse) onComplete, - void Function(TodoResponse) onTap, - ) { + Widget _buildQuadrantHeader(_QuadrantMeta meta) { return Column( mainAxisSize: MainAxisSize.min, - children: items.map((item) { - return TodoDragItem( - todo: item, - quadrant: quadrantValue, - onDragStarted: () => _onDragStart(item.id), - onDragEnd: _onDragEnd, - child: _TodoItemWidget( - item: item, - onComplete: () => onComplete(item), - onTap: () => onTap(item), + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(10, 10, 10, 8), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Text( + meta.title, + style: TextStyle( + fontFamily: 'Inter', + fontSize: 15, + fontWeight: FontWeight.w700, + color: meta.textColor, + ), + ), + Text( + '${meta.items.length}项', + style: TextStyle( + fontFamily: 'Inter', + fontSize: 12, + fontWeight: FontWeight.w700, + color: meta.textColor, + ), + ), + ], ), - ); - }).toList(), + ), + Container(height: 1, color: meta.dividerColor), + const SizedBox(height: AppSpacing.sm), + ], + ); + } + + Widget _buildEmptyQuadrant() { + return SizedBox( + height: 60, + child: Center( + child: Text( + '暂无待办', + style: TextStyle( + fontFamily: 'Inter', + fontSize: 13, + color: AppColors.slate400, + ), + ), + ), ); } @@ -507,6 +568,31 @@ class _TodoQuadrantsScreenState extends State { } } +class _ReorderResult { + final List todos; + final List changedTodos; + + const _ReorderResult({required this.todos, required this.changedTodos}); +} + +class _QuadrantMeta { + final int value; + final String title; + final Color textColor; + final Color dividerColor; + final Color borderColor; + final List items; + + const _QuadrantMeta({ + required this.value, + required this.title, + required this.textColor, + required this.dividerColor, + required this.borderColor, + required this.items, + }); +} + class _TodoItemWidget extends StatefulWidget { final TodoResponse item; final VoidCallback onComplete; diff --git a/apps/lib/features/todo/ui/widgets/todo_drag_item.dart b/apps/lib/features/todo/ui/widgets/todo_drag_item.dart index 51c6391..c51fed4 100644 --- a/apps/lib/features/todo/ui/widgets/todo_drag_item.dart +++ b/apps/lib/features/todo/ui/widgets/todo_drag_item.dart @@ -5,6 +5,7 @@ import 'package:social_app/features/todo/data/todo_api.dart'; class TodoDragItem extends StatelessWidget { final TodoResponse todo; final int quadrant; + final int sourceIndex; final VoidCallback onDragStarted; final VoidCallback onDragEnd; final Widget child; @@ -13,6 +14,7 @@ class TodoDragItem extends StatelessWidget { super.key, required this.todo, required this.quadrant, + required this.sourceIndex, required this.onDragStarted, required this.onDragEnd, required this.child, @@ -21,7 +23,7 @@ class TodoDragItem extends StatelessWidget { @override Widget build(BuildContext context) { return LongPressDraggable( - data: '${todo.id}:$quadrant', + data: '${todo.id}:$quadrant:$sourceIndex', delay: const Duration(milliseconds: 150), feedback: Material( elevation: 8, diff --git a/apps/pubspec.yaml b/apps/pubspec.yaml index 373b750..fc24ad2 100644 --- a/apps/pubspec.yaml +++ b/apps/pubspec.yaml @@ -25,6 +25,7 @@ dependencies: timezone: ^0.9.4 image_picker: ^1.0.7 package_info_plus: ^8.0.3 + drag_and_drop_lists: ^0.4.2 dev_dependencies: flutter_test: diff --git a/apps/test/features/todo/quadrant_drag_test.dart b/apps/test/features/todo/quadrant_drag_test.dart index 2a4398f..20c1acd 100644 --- a/apps/test/features/todo/quadrant_drag_test.dart +++ b/apps/test/features/todo/quadrant_drag_test.dart @@ -27,6 +27,7 @@ void main() { () async { const todoId = 'todo-123'; const targetPriority = 2; + const targetOrder = 0; when( () => mockClient.patch(any(), data: any(named: 'data')), @@ -38,6 +39,7 @@ void main() { 'owner_id': 'user-1', 'title': 'Test Todo', 'priority': targetPriority, + 'order': targetOrder, 'status': 'pending', 'created_at': '2024-01-01T00:00:00Z', 'updated_at': '2024-01-01T00:00:00Z', @@ -48,13 +50,15 @@ void main() { final result = await todoApi.updateTodo( todoId, priority: targetPriority, + order: targetOrder, ); expect(result.priority, targetPriority); + expect(result.order, targetOrder); verify( () => mockClient.patch( '/api/v1/todos/$todoId', - data: {'priority': targetPriority}, + data: {'priority': targetPriority, 'order': targetOrder}, ), ).called(1); }, @@ -67,7 +71,10 @@ void main() { () => mockClient.patch(any(), data: any(named: 'data')), ).thenThrow(Exception('Network error')); - expect(() => todoApi.updateTodo(todoId, priority: 2), throwsException); + expect( + () => todoApi.updateTodo(todoId, priority: 2, order: 0), + throwsException, + ); }); }); @@ -81,6 +88,7 @@ void main() { 'owner_id': 'user-1', 'title': 'Q1 Todo', 'priority': 1, + 'order': 1, 'status': 'pending', 'created_at': '2024-01-01T00:00:00Z', 'updated_at': '2024-01-01T00:00:00Z', @@ -88,8 +96,9 @@ void main() { ), ); - final result = await todoApi.updateTodo('todo-1', priority: 1); + final result = await todoApi.updateTodo('todo-1', priority: 1, order: 1); expect(result.priority, 1); + expect(result.order, 1); }); test('priority 2 = important not urgent (Q3)', () async { @@ -101,6 +110,7 @@ void main() { 'owner_id': 'user-1', 'title': 'Q3 Todo', 'priority': 2, + 'order': 2, 'status': 'pending', 'created_at': '2024-01-01T00:00:00Z', 'updated_at': '2024-01-01T00:00:00Z', @@ -108,8 +118,9 @@ void main() { ), ); - final result = await todoApi.updateTodo('todo-2', priority: 2); + final result = await todoApi.updateTodo('todo-2', priority: 2, order: 2); expect(result.priority, 2); + expect(result.order, 2); }); test('priority 3 = urgent not important (Q2)', () async { @@ -121,6 +132,7 @@ void main() { 'owner_id': 'user-1', 'title': 'Q2 Todo', 'priority': 3, + 'order': 0, 'status': 'pending', 'created_at': '2024-01-01T00:00:00Z', 'updated_at': '2024-01-01T00:00:00Z', @@ -128,8 +140,37 @@ void main() { ), ); - final result = await todoApi.updateTodo('todo-3', priority: 3); + final result = await todoApi.updateTodo('todo-3', priority: 3, order: 0); expect(result.priority, 3); + expect(result.order, 0); + }); + }); + + group('TodoApi.reorderTodos', () { + test('calls batch reorder endpoint once', () async { + when(() => mockClient.patch(any(), data: any(named: 'data'))).thenAnswer( + (_) async => Response( + requestOptions: RequestOptions(path: '/api/v1/todos/reorder'), + data: {}, + ), + ); + + await todoApi.reorderTodos(const [ + TodoReorderItemPayload(id: 'todo-1', priority: 1, order: 0), + TodoReorderItemPayload(id: 'todo-2', priority: 1, order: 1), + ]); + + verify( + () => mockClient.patch( + '/api/v1/todos/reorder', + data: { + 'items': [ + {'id': 'todo-1', 'priority': 1, 'order': 0}, + {'id': 'todo-2', 'priority': 1, 'order': 1}, + ], + }, + ), + ).called(1); }); }); } diff --git a/backend/alembic/versions/20260320_0001_todo_order_replace_due_at.py b/backend/alembic/versions/20260320_0001_todo_order_replace_due_at.py new file mode 100644 index 0000000..57b39a9 --- /dev/null +++ b/backend/alembic/versions/20260320_0001_todo_order_replace_due_at.py @@ -0,0 +1,108 @@ +"""replace todo due_at with order field + +Revision ID: 202603200001 +Revises: 20260319_0004 +Create Date: 2026-03-20 12:00:00 +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + +revision: str = "202603200001" +down_revision: Union[str, Sequence[str], None] = "20260319_0004" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + bind = op.get_bind() + inspector = sa.inspect(bind) + columns = {column["name"] for column in inspector.get_columns("todos")} + has_order = "order" in columns + has_due_at = "due_at" in columns + + if not has_order: + op.add_column("todos", sa.Column("order", sa.Integer(), nullable=True)) + + if has_due_at: + op.execute( + """ + WITH ranked AS ( + SELECT + id, + ROW_NUMBER() OVER ( + PARTITION BY owner_id, priority + ORDER BY due_at NULLS LAST, created_at ASC, id ASC + ) - 1 AS seq + FROM todos + WHERE deleted_at IS NULL + ) + UPDATE todos t + SET "order" = ranked.seq + FROM ranked + WHERE t.id = ranked.id + """ + ) + else: + op.execute( + """ + WITH ranked AS ( + SELECT + id, + ROW_NUMBER() OVER ( + PARTITION BY owner_id, priority + ORDER BY created_at ASC, id ASC + ) - 1 AS seq + FROM todos + WHERE deleted_at IS NULL + ) + UPDATE todos t + SET "order" = ranked.seq + FROM ranked + WHERE t.id = ranked.id AND t."order" IS NULL + """ + ) + + op.execute('UPDATE todos SET "order" = 0 WHERE "order" IS NULL') + op.alter_column("todos", "order", nullable=False, server_default=sa.text("0")) + + op.execute("DROP INDEX IF EXISTS ix_todos_owner_status_due") + op.execute("DROP INDEX IF EXISTS ix_todos_pending_due") + + op.execute( + 'CREATE INDEX IF NOT EXISTS ix_todos_owner_status_order ON todos (owner_id, status, priority, "order")' + ) + op.execute( + "CREATE INDEX IF NOT EXISTS ix_todos_pending_order ON todos (owner_id, priority, \"order\") WHERE status = 'pending'" + ) + + if has_due_at: + op.drop_column("todos", "due_at") + + +def downgrade() -> None: + bind = op.get_bind() + inspector = sa.inspect(bind) + columns = {column["name"] for column in inspector.get_columns("todos")} + has_order = "order" in columns + has_due_at = "due_at" in columns + + if not has_due_at: + op.add_column( + "todos", sa.Column("due_at", sa.DateTime(timezone=True), nullable=True) + ) + + op.execute("DROP INDEX IF EXISTS ix_todos_pending_order") + op.execute("DROP INDEX IF EXISTS ix_todos_owner_status_order") + + op.execute( + "CREATE INDEX IF NOT EXISTS ix_todos_owner_status_due ON todos (owner_id, status, due_at)" + ) + op.execute( + "CREATE INDEX IF NOT EXISTS ix_todos_pending_due ON todos (owner_id, due_at) WHERE status = 'pending'" + ) + + if has_order: + op.drop_column("todos", "order") diff --git a/backend/src/models/todos.py b/backend/src/models/todos.py index 65c96eb..c636164 100644 --- a/backend/src/models/todos.py +++ b/backend/src/models/todos.py @@ -26,7 +26,7 @@ class TodoPriority(int, Enum): class Todo(TimestampMixin, SoftDeleteMixin, Base): __tablename__: str = "todos" - __table_args__ = {"extend_existing": True} + __table_args__: dict[str, bool] = {"extend_existing": True} id: Mapped[uuid.UUID] = mapped_column( UUID(as_uuid=True), primary_key=True, default=uuid.uuid4 @@ -43,9 +43,10 @@ class Todo(TimestampMixin, SoftDeleteMixin, Base): String(1000), nullable=True, ) - due_at: Mapped[datetime | None] = mapped_column( - DateTime(timezone=True), - nullable=True, + order: Mapped[int] = mapped_column( + Integer, + nullable=False, + default=0, ) priority: Mapped[int] = mapped_column( Integer, diff --git a/backend/src/schemas/todo/__init__.py b/backend/src/schemas/todo/__init__.py new file mode 100644 index 0000000..896123c --- /dev/null +++ b/backend/src/schemas/todo/__init__.py @@ -0,0 +1,3 @@ +from .contracts import TodoOrder + +__all__ = ["TodoOrder"] diff --git a/backend/src/schemas/todo/contracts.py b/backend/src/schemas/todo/contracts.py new file mode 100644 index 0000000..b98df2f --- /dev/null +++ b/backend/src/schemas/todo/contracts.py @@ -0,0 +1,7 @@ +from __future__ import annotations + +from typing import Annotated + +from pydantic import Field + +TodoOrder = Annotated[int, Field(ge=0)] diff --git a/backend/src/v1/todo/repository.py b/backend/src/v1/todo/repository.py index 4751bb0..1919f7b 100644 --- a/backend/src/v1/todo/repository.py +++ b/backend/src/v1/todo/repository.py @@ -26,8 +26,8 @@ class TodoRepository(Protocol): owner_id: UUID, title: str, description: str | None = None, - due_at: datetime | None = None, priority: int = TodoPriority.IMPORTANT_URGENT, + order: int = 0, created_by: UUID | None = None, ) -> Todo: """Create a new todo.""" @@ -42,8 +42,8 @@ class TodoRepository(Protocol): todo: Todo, title: str | None = None, description: str | None = None, - due_at: datetime | None = None, priority: int | None = None, + order: int | None = None, status: TodoStatus | None = None, completed_at: datetime | None = None, ) -> Todo: @@ -79,6 +79,8 @@ class SQLAlchemyTodoRepository(BaseRepository[Todo]): - No HTTP exceptions - returns None or raises SQLAlchemyError """ + _session: AsyncSession + def __init__(self, session: AsyncSession) -> None: super().__init__(session, Todo) self._session = session @@ -88,8 +90,8 @@ class SQLAlchemyTodoRepository(BaseRepository[Todo]): owner_id: UUID, title: str, description: str | None = None, - due_at: datetime | None = None, priority: int = TodoPriority.IMPORTANT_URGENT, + order: int = 0, created_by: UUID | None = None, ) -> Todo: try: @@ -97,8 +99,8 @@ class SQLAlchemyTodoRepository(BaseRepository[Todo]): owner_id=owner_id, title=title, description=description, - due_at=due_at, priority=priority, + order=order, status=TodoStatus.PENDING, created_by=created_by, ) @@ -128,8 +130,8 @@ class SQLAlchemyTodoRepository(BaseRepository[Todo]): todo: Todo, title: str | None = None, description: str | None = None, - due_at: datetime | None = None, priority: int | None = None, + order: int | None = None, status: TodoStatus | None = None, completed_at: datetime | None = None, ) -> Todo: @@ -138,10 +140,10 @@ class SQLAlchemyTodoRepository(BaseRepository[Todo]): todo.title = title if description is not None: todo.description = description - if due_at is not None: - todo.due_at = due_at if priority is not None: todo.priority = priority + if order is not None: + todo.order = order if status is not None: todo.status = status if completed_at is not None: @@ -167,7 +169,7 @@ class SQLAlchemyTodoRepository(BaseRepository[Todo]): select(Todo) .where(Todo.owner_id == owner_id) .where(Todo.deleted_at.is_(None)) - .order_by(Todo.priority.asc(), Todo.due_at.asc().nullslast()) + .order_by(Todo.priority.asc(), Todo.order.asc(), Todo.created_at.asc()) ) if status is not None: diff --git a/backend/src/v1/todo/router.py b/backend/src/v1/todo/router.py index 9467563..02304f5 100644 --- a/backend/src/v1/todo/router.py +++ b/backend/src/v1/todo/router.py @@ -6,7 +6,13 @@ from uuid import UUID from fastapi import APIRouter, Depends, Query, status from v1.todo.dependencies import get_todo_service -from v1.todo.schemas import TodoComplete, TodoCreate, TodoResponse, TodoUpdate +from v1.todo.schemas import ( + TodoComplete, + TodoCreate, + TodoReorderRequest, + TodoResponse, + TodoUpdate, +) from v1.todo.service import TodoService @@ -42,6 +48,17 @@ async def get_todo( return await service.get_by_id(todo_id) +@router.patch( + "/reorder", + status_code=status.HTTP_204_NO_CONTENT, +) +async def reorder_todos( + payload: TodoReorderRequest, + service: Annotated[TodoService, Depends(get_todo_service)], +) -> None: + await service.reorder(payload) + + @router.patch("/{todo_id}", response_model=TodoResponse) async def update_todo( todo_id: UUID, diff --git a/backend/src/v1/todo/schemas.py b/backend/src/v1/todo/schemas.py index 3cb346a..b8d0c98 100644 --- a/backend/src/v1/todo/schemas.py +++ b/backend/src/v1/todo/schemas.py @@ -5,14 +5,16 @@ from uuid import UUID from pydantic import BaseModel, ConfigDict, Field +from schemas.todo import TodoOrder + class TodoCreate(BaseModel): model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid") title: str = Field(..., min_length=1, max_length=255) description: str | None = Field(None, max_length=1000) - due_at: datetime | None = None priority: int = Field(1, ge=1, le=4) + order: TodoOrder | None = None schedule_item_ids: list[UUID] = Field(default_factory=list) @@ -21,8 +23,8 @@ class TodoUpdate(BaseModel): title: str | None = Field(None, min_length=1, max_length=255) description: str | None = Field(None, max_length=1000) - due_at: datetime | None = None priority: int | None = Field(None, ge=1, le=4) + order: TodoOrder | None = None status: Literal["pending", "done", "canceled"] | None = None schedule_item_ids: list[UUID] | None = None @@ -43,8 +45,8 @@ class TodoResponse(BaseModel): owner_id: UUID title: str description: str | None - due_at: datetime | None priority: int + order: TodoOrder status: str completed_at: datetime | None created_at: datetime @@ -52,6 +54,20 @@ class TodoResponse(BaseModel): schedule_items: list[ScheduleItemBasic] = Field(default_factory=list) +class TodoReorderItem(BaseModel): + model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid") + + id: UUID + priority: int = Field(..., ge=1, le=4) + order: TodoOrder + + +class TodoReorderRequest(BaseModel): + model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid") + + items: list[TodoReorderItem] = Field(..., min_length=1) + + class TodoComplete(BaseModel): model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid") diff --git a/backend/src/v1/todo/service.py b/backend/src/v1/todo/service.py index 7a3c3ac..2852365 100644 --- a/backend/src/v1/todo/service.py +++ b/backend/src/v1/todo/service.py @@ -13,7 +13,13 @@ from core.logging import get_logger from models.todos import Todo, TodoStatus from v1.schedule_items.repository import SQLAlchemyScheduleItemRepository from v1.todo.repository import TodoRepository -from v1.todo.schemas import ScheduleItemBasic, TodoCreate, TodoResponse, TodoUpdate +from v1.todo.schemas import ( + ScheduleItemBasic, + TodoCreate, + TodoReorderRequest, + TodoResponse, + TodoUpdate, +) if TYPE_CHECKING: from sqlalchemy.ext.asyncio import AsyncSession @@ -52,12 +58,20 @@ class TodoService(BaseService): user_id = self.require_user_id() try: + order_value = request.order + if order_value is None: + todos_in_priority = await self._repository.list_by_owner( + owner_id=user_id, + priority=request.priority, + ) + order_value = len(todos_in_priority) + todo = await self._repository.create( owner_id=user_id, title=request.title, description=request.description, - due_at=request.due_at, priority=request.priority, + order=order_value, created_by=user_id, ) @@ -144,8 +158,8 @@ class TodoService(BaseService): todo, title=request.title, description=request.description, - due_at=request.due_at, priority=request.priority, + order=request.order, status=status_enum, completed_at=completed_at, ) @@ -253,6 +267,53 @@ class TodoService(BaseService): }, ) + async def reorder(self, request: TodoReorderRequest) -> None: + user_id = self.require_user_id() + + seen_ids: set[UUID] = set() + original_priorities: set[int] = set() + target_priorities: set[int] = set() + + try: + for item in request.items: + if item.id in seen_ids: + raise HTTPException(status_code=400, detail="Duplicate todo id") + seen_ids.add(item.id) + + todo = await self._repository.get_by_id(item.id) + if todo is None: + raise HTTPException(status_code=404, detail="Todo not found") + if todo.owner_id != user_id: + raise HTTPException( + status_code=403, detail="Not authorized to reorder this todo" + ) + + original_priorities.add(todo.priority) + target_priorities.add(item.priority) + + await self._repository.update( + todo, + priority=item.priority, + order=item.order, + ) + + affected_priorities = original_priorities.union(target_priorities) + for priority in affected_priorities: + todos = await self._repository.list_by_owner( + owner_id=user_id, + status=TodoStatus.PENDING, + priority=priority, + ) + todos.sort(key=lambda current: (current.order, current.created_at)) + for index, todo in enumerate(todos): + if todo.order != index: + await self._repository.update(todo, order=index) + + await self._session.commit() + except SQLAlchemyError: + await self._session.rollback() + raise HTTPException(status_code=503, detail="Todo service unavailable") + async def list_todos( self, status: str | None = None, @@ -287,7 +348,7 @@ class TodoService(BaseService): ) schedule_item_ids = await self._repository.get_schedule_items(todo.id) - schedule_items = [] + schedule_items: list[ScheduleItemBasic] = [] for item_id in schedule_item_ids: item = await self._schedule_item_repository.get_by_id(item_id) if item: @@ -305,8 +366,8 @@ class TodoService(BaseService): owner_id=todo.owner_id, title=todo.title, description=todo.description, - due_at=todo.due_at, priority=todo.priority, + order=todo.order, status=status_value, completed_at=todo.completed_at, created_at=todo.created_at, diff --git a/backend/tests/unit/v1/todo/test_schemas.py b/backend/tests/unit/v1/todo/test_schemas.py new file mode 100644 index 0000000..4d6e162 --- /dev/null +++ b/backend/tests/unit/v1/todo/test_schemas.py @@ -0,0 +1,65 @@ +from pydantic import ValidationError + +from v1.todo.schemas import TodoCreate, TodoReorderRequest, TodoResponse, TodoUpdate + + +def test_todo_create_accepts_zero_based_order() -> None: + request = TodoCreate.model_validate({"title": "Test", "priority": 1, "order": 0}) + + assert request.order == 0 + + +def test_todo_create_rejects_due_at_field() -> None: + try: + TodoCreate.model_validate( + { + "title": "Test", + "priority": 1, + "order": 0, + "due_at": "2026-01-01T00:00:00Z", + } + ) + except ValidationError: + return + + raise AssertionError("TodoCreate should reject due_at") + + +def test_todo_update_rejects_negative_order() -> None: + try: + TodoUpdate.model_validate({"order": -1}) + except ValidationError: + return + + raise AssertionError("TodoUpdate should reject negative order") + + +def test_todo_response_contains_order_without_due_at() -> None: + payload = { + "id": "9d72aa0f-e2d5-4de3-9b2d-4609f53376c0", + "owner_id": "2f5b38fb-cb6f-44ef-8824-e7978f644bc2", + "title": "Test", + "description": None, + "priority": 1, + "order": 0, + "status": "pending", + "completed_at": None, + "created_at": "2026-03-20T00:00:00Z", + "updated_at": "2026-03-20T00:00:00Z", + "schedule_items": [], + } + + response = TodoResponse.model_validate(payload) + dumped = response.model_dump() + + assert dumped["order"] == 0 + assert "due_at" not in dumped + + +def test_todo_reorder_request_requires_non_empty_items() -> None: + try: + TodoReorderRequest(items=[]) + except ValidationError: + return + + raise AssertionError("TodoReorderRequest should reject empty items") diff --git a/docs/plans/2026-03-20-navigation-cache-decoupling-design.md b/docs/plans/2026-03-20-navigation-cache-decoupling-design.md new file mode 100644 index 0000000..1d0386e --- /dev/null +++ b/docs/plans/2026-03-20-navigation-cache-decoupling-design.md @@ -0,0 +1,274 @@ +# 前端导航解耦与统一缓存重构设计 + +## 1. 背景与问题定义 + +当前 `apps` 端在日历(日/月)与待办页面中存在以下系统性问题: + +1. 页面切换语义错误:将业务 tab 切换实现为 `push/go` 混用,导致页面重建与路由栈膨胀。 +2. 数据刷新触发错误:页面通过路由监听触发 `load`,频繁重复请求后端。 +3. 状态职责耦合:导航状态、页面状态、数据状态边界不清,导致“切换逻辑改动会牵出数据 bug”。 +4. 回主页语义不一致:Dock 首页按钮被 `canPop -> pop` 策略污染,行为变成“返回上页”。 +5. 缓存能力分散:仅存在局部的个人信息缓存(`SettingsUserCache`),缺少统一可复用缓存模块。 + +目标是完成一次结构化重构,建立「解耦的导航切换 + 统一缓存 + 可控一致性」体系。 + +## 2. 目标与非目标 + +### 2.1 目标 + +1. Home/Calendar/Todo 切换不重建主页面(保持页面实例与滚动状态)。 +2. 日/月视图切换不触发整页重建和无必要网络请求。 +3. 建立统一缓存模块,合并个人信息缓存并覆盖 Calendar/Todo 数据读取。 +4. 启动体验采用「本地优先 + 后台静默刷新」策略,减少进入 App 的重复请求。 +5. 数据只在必要时刷新:手动下拉、写操作失效、生命周期关键点、缓存策略命中。 +6. 主页按钮语义固定为“回主页”,不再变成“返回上一页”。 + +### 2.2 非目标 + +1. 本次不改后端协议与接口契约。 +2. 本次不引入复杂离线同步冲突解决(如多端 CRDT)。 +3. 本次不引入全量本地数据库迁移(先基于 SharedPreferences 持久化层)。 + +## 3. 复杂度与风险分级 + +- Complexity: `S3` + - 跨 router、calendar、todo、settings、DI 的架构级调整。 +- Risk Tier: `L1` + - 不触及鉴权协议和支付等高风险域,但涉及导航返回栈与数据一致性高回归区。 + +## 4. 架构总览 + +### 4.1 导航分层 + +采用两级导航: + +1. 一级(主容器):`StatefulShellRoute.indexedStack` + - 分支:Home / Calendar / Todo + - 作用:保活分支页面,避免 tab 切换重建。 +2. 二级(分支内部) + - Calendar 分支:管理 month/day 主视图切换 + event detail/edit/share 子路由。 + - Todo 分支:管理 list/detail/edit 子路由。 + +### 4.2 状态与数据边界 + +1. 导航状态:Shell 当前分支 index、Calendar 内部视图类型。 +2. UI 状态:选中日期、滚动位置、拖拽态、loading/error。 +3. 数据状态:统一缓存模块管理(内存 + 持久化 + 网络回写)。 + +结论:页面只发“意图”,不直接承担缓存与路由策略。 + +## 5. 统一缓存模块设计 + +## 5.1 模块结构 + +新增 `apps/lib/core/cache/`: + +1. `cache_key.dart` + - 统一 key 命名规范。 +2. `cache_policy.dart` + - TTL、软/硬过期、最小刷新间隔、刷新原因枚举。 +3. `cache_entry.dart` + - 标准缓存实体(data/fetchedAt/expiresAt/version/dirty)。 +4. `cache_store.dart` + - 抽象接口(get/set/remove/invalidateNamespace)。 +5. `memory_cache_store.dart` + - 会话级热缓存。 +6. `persistent_cache_store.dart` + - 本地冷缓存(SharedPreferences JSON)。 +7. `hybrid_cache_store.dart` + - 两级缓存协调与 singleflight 去重。 +8. `cache_invalidator.dart` + - 统一精准失效入口。 + +### 5.2 key 设计(首版) + +1. 用户信息 + - `user:profile:me` +2. 日历 + - `calendar:day:YYYY-MM-DD` + - `calendar:month:YYYY-MM` +3. 待办 + - `todo:list:pending` + - `todo:list:priority:`(按需) + - `todo:detail:`(按需) + +### 5.3 策略设计(平衡型) + +读取顺序:`memory -> persistent -> network`。 + +刷新策略: + +1. 软过期(stale-while-revalidate) + - 先展示缓存,后台静默刷新。 +2. 硬过期 + - 超过硬过期后必须请求网络或提示数据过旧。 +3. 最小刷新间隔 + - 避免频繁切换/回前台引发抖动请求。 + +建议默认值: + +1. `user:profile`:软过期 30min,硬过期 24h。 +2. `calendar:day`:软过期 2min,硬过期 30min。 +3. `calendar:month`:软过期 5min,硬过期 60min。 +4. `todo:list:pending`:软过期 2min,硬过期 30min。 + +### 5.4 个人信息缓存合并方案 + +现有 `SettingsUserCache` 并入统一缓存模块: + +1. 新建 `UserProfileRepository`(或在现有 settings service 中引入统一缓存)。 +2. `getProfile()` 通过 hybrid cache 获取 `user:profile:me`。 +3. 更新 profile 成功后立即写回缓存并同步持久化。 +4. 登出/会话失效时统一调用 `invalidateNamespace('user')`。 + +## 6. 一致性风险与解决方案 + +平衡型缓存会存在“短暂陈旧窗口”。本设计通过以下机制将体验风险降到可接受范围。 + +### 6.1 触发刷新矩阵 + +1. 手动下拉刷新:强制网络刷新。 +2. 写操作成功:精准失效受影响 key 并触发回填。 +3. App 回前台:若超过最小刷新间隔,触发静默刷新。 +4. 网络离线 -> 在线:触发静默刷新。 +5. 进入关键详情页:按策略进行 freshness check。 + +### 6.2 写后一致性 + +1. 乐观更新:本地先更新 UI 与缓存,避免“我刚改完却没变”。 +2. 失败回滚:API 失败时恢复旧值并 Toast 提示。 +3. 精准失效:不做全局清空,只失效关联 key,兼顾一致性与性能。 + +### 6.3 并发安全 + +1. singleflight:同 key 同时只允许一个网络请求。 +2. 版本保护:缓存写入比较 `updatedAt/version`,拒绝旧响应覆盖新状态。 +3. 失败兜底:请求失败不清空旧缓存,保持可读并允许重试。 + +### 6.4 可见性保障 + +1. 页面可显示“上次同步时间”(轻提示)。 +2. 硬过期数据需可见提醒(弱提示,不阻断基础浏览)。 +3. 提供稳定手动刷新入口。 + +## 7. 导航与页面职责重构 + +### 7.1 路由重构 + +1. `app_router` 引入 shell 分支,不再平铺所有主页面。 +2. Dock 切换改为 branch index 切换,不再 `push` 主页面。 +3. Calendar 内部 month/day 切换改为视图切换,不新增栈层。 +4. 事件详情/编辑等保留 `push`(细节页合理叠栈)。 + +### 7.2 回主页逻辑修正 + +1. Dock Home 统一执行“切到 Home 分支/`go('/home')`”。 +2. `returnToHomePreserveState` 仅用于非 Dock 的返回策略场景。 +3. 消除 `canPop -> pop` 对主页按钮语义的影响。 + +### 7.3 页面职责收敛 + +1. Calendar/Todo 页面移除路由监听触发 `load`。 +2. 页面只调用 repository: + - `get(policy)` + - `refresh(force: true)` + - `mutate(...) + invalidate(...)` +3. 页面不直接感知“缓存在哪一层”。 + +## 8. 分阶段实施计划(里程碑) + +### M1 导航壳层与切换语义 + +1. 引入 shell + 分支保活。 +2. Dock 接口改造与主 tab 切换实现。 +3. Home 按钮语义修正。 + +### M2 统一缓存骨架 + +1. 新增 core cache 模块。 +2. 接入 user profile(替换 `SettingsUserCache`)。 +3. DI 注入 cache store 与 invalidator。 + +### M3 Calendar 接入 + +1. 引入 `CalendarRepository` 与 day/month key。 +2. 移除 route listener 自动刷新。 +3. 切换 month/day 时默认走缓存,不触发无必要请求。 + +### M4 Todo 接入 + +1. 引入 `TodoRepository` 与 list/detail key。 +2. 拖拽、完成、编辑后的精准失效。 +3. 下拉刷新走强制网络。 + +### M5 清理与验证 + +1. 清理旧缓存与重复加载逻辑。 +2. 补齐测试与性能观测。 +3. 评估参数并收敛默认策略。 + +## 9. 验收标准 + +### 9.1 体验验收 + +1. Home/Calendar/Todo 切换无明显重建卡顿。 +2. 日/月切换响应明显变快。 +3. 首次冷启动可先看到本地缓存内容。 +4. Dock Home 始终回主页。 + +### 9.2 网络验收 + +1. 切换页面时网络请求显著减少。 +2. 写操作后关联数据可及时更新。 +3. 手动刷新可强制拉取并回写缓存。 + +### 9.3 一致性验收 + +1. 不出现旧响应覆盖新数据。 +2. 离线后恢复在线可自动静默同步。 +3. 软过期/硬过期行为符合策略定义。 + +## 10. 测试与验证计划 + +### 10.1 单元测试 + +1. `hybrid_cache_store`:命中链路、singleflight、软硬过期判定。 +2. `cache_invalidator`:写操作触发的 key 精准失效。 +3. repository:读缓存、后台刷新、失败兜底、版本保护。 + +### 10.2 组件/页面测试(高回归) + +1. Dock 切换不重建分支主页面。 +2. 日/月切换不重复触发全量加载。 +3. Home 按钮行为稳定。 + +### 10.3 集成回归 + +1. Calendar -> Todo -> Calendar 多轮切换请求计数。 +2. Todo 完成后列表更新与缓存一致性。 +3. profile 更新后设置页/其他依赖页可见一致。 + +## 11. 风险与回滚 + +### 11.1 主要风险 + +1. 导航壳层改造可能引发深链与返回栈回归。 +2. 缓存策略参数不当可能造成陈旧感。 +3. 早期失效 key 设计不完整可能出现局部不刷新。 + +### 11.2 控制策略 + +1. 按里程碑逐步落地,每个里程碑可单独回滚。 +2. 默认保留手动刷新兜底。 +3. 增加请求计数与缓存命中日志(开发态)。 + +### 11.3 回滚策略 + +1. 若 M1 不稳定,可先回退 shell 改造并保留缓存模块。 +2. 若缓存接入问题集中,可按域回退(user/calendar/todo 分域开关)。 + +## 12. 待确认参数(实施前锁定) + +1. 软/硬过期默认值是否按本设计直接采用。 +2. 是否立即展示“上次同步时间”。 +3. 是否在首版启用“网络恢复自动静默刷新”。 diff --git a/docs/plans/2026-03-20-navigation-cache-decoupling-implementation-plan.md b/docs/plans/2026-03-20-navigation-cache-decoupling-implementation-plan.md new file mode 100644 index 0000000..72c624d --- /dev/null +++ b/docs/plans/2026-03-20-navigation-cache-decoupling-implementation-plan.md @@ -0,0 +1,438 @@ +# 前端导航解耦与统一缓存重构 Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** 完成 Home/Calendar/Todo 的解耦切换与统一缓存改造,实现本地优先显示、后台静默刷新、写后精准失效,并修复 Dock 回主页语义。 + +**Architecture:** 路由层采用 `StatefulShellRoute.indexedStack` 维持主分支保活;数据层新增 `core/cache` 统一缓存模块(memory + persistent + hybrid);业务层通过 repository 接入缓存策略,页面仅负责发意图和渲染状态。写操作触发精准失效,读取遵循 soft/hard TTL + minimum refresh interval。 + +**Tech Stack:** Flutter, go_router, get_it, shared_preferences, flutter_test, mocktail + +--- + +### Task 1: 建立统一缓存核心模型与策略 + +**Files:** +- Create: `apps/lib/core/cache/cache_entry.dart` +- Create: `apps/lib/core/cache/cache_key.dart` +- Create: `apps/lib/core/cache/cache_policy.dart` +- Test: `apps/test/core/cache/cache_policy_test.dart` + +**Step 1: Write the failing test** + +```dart +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); + }); +} +``` + +**Step 2: Run test to verify it fails** + +Run: `cd apps && flutter test test/core/cache/cache_policy_test.dart` +Expected: FAIL with missing cache policy symbols. + +**Step 3: Write minimal implementation** + +```dart +class CacheDecision { + final bool canUseCached; + final bool shouldRefreshInBackground; + final bool mustBlockForNetwork; + const CacheDecision({ + required this.canUseCached, + required this.shouldRefreshInBackground, + required this.mustBlockForNetwork, + }); +} +``` + +**Step 4: Run test to verify it passes** + +Run: `cd apps && flutter test test/core/cache/cache_policy_test.dart` +Expected: PASS. + +**Step 5: Commit** + +```bash +git add apps/lib/core/cache/cache_entry.dart apps/lib/core/cache/cache_key.dart apps/lib/core/cache/cache_policy.dart apps/test/core/cache/cache_policy_test.dart +git commit -m "feat: add unified cache policy primitives" +``` + +### Task 2: 实现 memory/persistent/hybrid cache store(含 singleflight) + +**Files:** +- Create: `apps/lib/core/cache/cache_store.dart` +- Create: `apps/lib/core/cache/memory_cache_store.dart` +- Create: `apps/lib/core/cache/persistent_cache_store.dart` +- Create: `apps/lib/core/cache/hybrid_cache_store.dart` +- Test: `apps/test/core/cache/hybrid_cache_store_test.dart` + +**Step 1: Write the failing test** + +```dart +test('same key concurrent load should execute loader once', () async { + var calls = 0; + final store = HybridCacheStore(...); + Future loader() async { + calls += 1; + return 'ok'; + } + await Future.wait([ + store.getOrLoad('k', loader: loader), + store.getOrLoad('k', loader: loader), + ]); + expect(calls, 1); +}); +``` + +**Step 2: Run test to verify it fails** + +Run: `cd apps && flutter test test/core/cache/hybrid_cache_store_test.dart` +Expected: FAIL with missing HybridCacheStore. + +**Step 3: Write minimal implementation** + +```dart +final Map> _inflight = {}; +``` + +**Step 4: Run test to verify it passes** + +Run: `cd apps && flutter test test/core/cache/hybrid_cache_store_test.dart` +Expected: PASS. + +**Step 5: Commit** + +```bash +git add apps/lib/core/cache/cache_store.dart apps/lib/core/cache/memory_cache_store.dart apps/lib/core/cache/persistent_cache_store.dart apps/lib/core/cache/hybrid_cache_store.dart apps/test/core/cache/hybrid_cache_store_test.dart +git commit -m "feat: implement hybrid cache store with singleflight" +``` + +### Task 3: 接入 DI 与统一失效器 + +**Files:** +- Create: `apps/lib/core/cache/cache_invalidator.dart` +- Modify: `apps/lib/core/di/injection.dart` +- Test: `apps/test/core/cache/cache_invalidator_test.dart` + +**Step 1: Write the failing test** + +```dart +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); +}); +``` + +**Step 2: Run test to verify it fails** + +Run: `cd apps && flutter test test/core/cache/cache_invalidator_test.dart` +Expected: FAIL. + +**Step 3: Write minimal implementation** + +```dart +class CacheInvalidator { + void invalidateCalendarDay(DateTime date) { /* invalidate day + month */ } +} +``` + +**Step 4: Run test to verify it passes** + +Run: `cd apps && flutter test test/core/cache/cache_invalidator_test.dart` +Expected: PASS. + +**Step 5: Commit** + +```bash +git add apps/lib/core/cache/cache_invalidator.dart apps/lib/core/di/injection.dart apps/test/core/cache/cache_invalidator_test.dart +git commit -m "refactor: wire unified cache and invalidator in di" +``` + +### Task 4: 合并个人信息缓存(替换 SettingsUserCache) + +**Files:** +- Modify: `apps/lib/features/settings/data/services/settings_user_cache.dart` +- Create: `apps/lib/features/settings/data/services/user_profile_cache_repository.dart` +- Modify: `apps/lib/features/settings/ui/screens/settings_screen.dart` +- Test: `apps/test/features/settings/data/services/settings_user_cache_test.dart` +- Create: `apps/test/features/settings/data/services/user_profile_cache_repository_test.dart` + +**Step 1: Write the failing test** + +```dart +test('repository should return persistent cache first then refresh in background', () async { + // Arrange cached profile in persistent store + // Assert immediate cached result + refresh called once +}); +``` + +**Step 2: Run test to verify it fails** + +Run: `cd apps && flutter test test/features/settings/data/services/user_profile_cache_repository_test.dart` +Expected: FAIL. + +**Step 3: Write minimal implementation** + +```dart +class UserProfileCacheRepository { + Future getProfile({bool forceRefresh = false}) async { ... } +} +``` + +**Step 4: Run tests to verify they pass** + +Run: `cd apps && flutter test test/features/settings/data/services/settings_user_cache_test.dart test/features/settings/data/services/user_profile_cache_repository_test.dart` +Expected: PASS. + +**Step 5: Commit** + +```bash +git add apps/lib/features/settings/data/services/settings_user_cache.dart apps/lib/features/settings/data/services/user_profile_cache_repository.dart apps/lib/features/settings/ui/screens/settings_screen.dart apps/test/features/settings/data/services/settings_user_cache_test.dart apps/test/features/settings/data/services/user_profile_cache_repository_test.dart +git commit -m "refactor: merge profile cache into unified cache repository" +``` + +### Task 5: 路由改造为 StatefulShellRoute + Dock 切换分支 + +**Files:** +- Modify: `apps/lib/core/router/app_router.dart` +- Modify: `apps/lib/core/router/app_routes.dart` +- Modify: `apps/lib/features/calendar/ui/widgets/bottom_dock.dart` +- Modify: `apps/lib/features/home/ui/navigation/home_return_policy.dart` +- Test: `apps/test/core/router/app_routes_test.dart` +- Modify: `apps/test/features/home/ui/navigation/home_return_policy_test.dart` + +**Step 1: Write the failing test** + +```dart +test('dock home action should always resolve to goHome', () { + final action = resolveHomeReturnAction(canPop: true, isAuthEntry: false); + expect(action, HomeReturnAction.goHomeForDock); +}); +``` + +**Step 2: Run test to verify it fails** + +Run: `cd apps && flutter test test/features/home/ui/navigation/home_return_policy_test.dart` +Expected: FAIL with old behavior. + +**Step 3: Write minimal implementation** + +```dart +enum HomeReturnAction { pop, goHome, goHomeForDock } +``` + +**Step 4: Run tests to verify they pass** + +Run: `cd apps && flutter test test/core/router/app_routes_test.dart test/features/home/ui/navigation/home_return_policy_test.dart` +Expected: PASS. + +**Step 5: Commit** + +```bash +git add apps/lib/core/router/app_router.dart apps/lib/core/router/app_routes.dart apps/lib/features/calendar/ui/widgets/bottom_dock.dart apps/lib/features/home/ui/navigation/home_return_policy.dart apps/test/core/router/app_routes_test.dart apps/test/features/home/ui/navigation/home_return_policy_test.dart +git commit -m "feat: switch main navigation to stateful shell tabs" +``` + +### Task 6: Calendar repository 化并移除路由监听刷新 + +**Files:** +- Create: `apps/lib/features/calendar/data/services/calendar_repository.dart` +- Modify: `apps/lib/features/calendar/ui/screens/calendar_dayweek_screen.dart` +- Modify: `apps/lib/features/calendar/ui/screens/calendar_month_screen.dart` +- Modify: `apps/lib/features/calendar/ui/calendar_state_manager.dart` +- Create: `apps/test/features/calendar/data/services/calendar_repository_test.dart` + +**Step 1: Write the failing test** + +```dart +test('getDayEvents returns cache immediately and refreshes in background', () async { + // Arrange cache day key + // Assert cached list emitted before network completion +}); +``` + +**Step 2: Run test to verify it fails** + +Run: `cd apps && flutter test test/features/calendar/data/services/calendar_repository_test.dart` +Expected: FAIL. + +**Step 3: Write minimal implementation** + +```dart +class CalendarRepository { + Future> getDayEvents(DateTime date, {bool forceRefresh = false}) async { ... } +} +``` + +**Step 4: Run tests to verify they pass** + +Run: `cd apps && flutter test test/features/calendar/data/services/calendar_repository_test.dart` +Expected: PASS. + +**Step 5: Commit** + +```bash +git add apps/lib/features/calendar/data/services/calendar_repository.dart apps/lib/features/calendar/ui/screens/calendar_dayweek_screen.dart apps/lib/features/calendar/ui/screens/calendar_month_screen.dart apps/lib/features/calendar/ui/calendar_state_manager.dart apps/test/features/calendar/data/services/calendar_repository_test.dart +git commit -m "refactor: decouple calendar screens from route-driven reload" +``` + +### Task 7: Todo repository 化与写后精准失效 + +**Files:** +- Create: `apps/lib/features/todo/data/todo_repository.dart` +- Modify: `apps/lib/features/todo/ui/screens/todo_quadrants_screen.dart` +- Modify: `apps/lib/features/todo/data/todo_api.dart` +- Create: `apps/test/features/todo/todo_repository_test.dart` +- Modify: `apps/test/features/todo/quadrant_drag_test.dart` + +**Step 1: Write the failing test** + +```dart +test('complete todo should optimistically update and invalidate pending list key', () async { + // assert local list updated first, invalidator called +}); +``` + +**Step 2: Run test to verify it fails** + +Run: `cd apps && flutter test test/features/todo/todo_repository_test.dart` +Expected: FAIL. + +**Step 3: Write minimal implementation** + +```dart +class TodoRepository { + Future completeTodo(String id) async { ... } +} +``` + +**Step 4: Run tests to verify they pass** + +Run: `cd apps && flutter test test/features/todo/todo_repository_test.dart test/features/todo/quadrant_drag_test.dart` +Expected: PASS. + +**Step 5: Commit** + +```bash +git add apps/lib/features/todo/data/todo_repository.dart apps/lib/features/todo/ui/screens/todo_quadrants_screen.dart apps/lib/features/todo/data/todo_api.dart apps/test/features/todo/todo_repository_test.dart apps/test/features/todo/quadrant_drag_test.dart +git commit -m "feat: add todo cache repository and precise invalidation" +``` + +### Task 8: App 生命周期与网络恢复刷新策略 + +**Files:** +- Create: `apps/lib/core/cache/cache_refresh_coordinator.dart` +- Modify: `apps/lib/main.dart` +- Create: `apps/test/core/cache/cache_refresh_coordinator_test.dart` + +**Step 1: Write the failing test** + +```dart +test('resume should trigger refresh only when min interval elapsed', () { + // Arrange last refreshed timestamp + // Assert callback invocation count +}); +``` + +**Step 2: Run test to verify it fails** + +Run: `cd apps && flutter test test/core/cache/cache_refresh_coordinator_test.dart` +Expected: FAIL. + +**Step 3: Write minimal implementation** + +```dart +class CacheRefreshCoordinator with WidgetsBindingObserver { ... } +``` + +**Step 4: Run test to verify it passes** + +Run: `cd apps && flutter test test/core/cache/cache_refresh_coordinator_test.dart` +Expected: PASS. + +**Step 5: Commit** + +```bash +git add apps/lib/core/cache/cache_refresh_coordinator.dart apps/lib/main.dart apps/test/core/cache/cache_refresh_coordinator_test.dart +git commit -m "feat: add app lifecycle refresh coordinator" +``` + +### Task 9: 全量验证与文档同步 + +**Files:** +- Modify: `docs/protocols/*`(仅当路由/数据契约文档需更新时) +- Modify: `docs/plans/2026-03-20-navigation-cache-decoupling-design.md`(回填最终参数) + +**Step 1: Run focused tests** + +Run: + +```bash +cd apps && flutter test test/core/cache test/features/settings/data/services/settings_user_cache_test.dart test/features/calendar test/features/todo test/features/home/ui/navigation/home_return_policy_test.dart test/core/router/app_routes_test.dart +``` + +Expected: PASS. + +**Step 2: Run app-level verification** + +Run: `cd apps && flutter test` +Expected: PASS. + +**Step 3: Static checks** + +Run: `cd apps && flutter analyze` +Expected: No errors. + +**Step 4: Manual verification checklist** + +1. 冷启动先显示缓存,随后静默更新。 +2. Home/Calendar/Todo 来回切换不重建主页面。 +3. 日/月切换不触发无必要请求。 +4. Dock Home 始终回主页。 +5. 写后数据可见一致,失败可回滚提示。 + +**Step 5: Commit** + +```bash +git add docs/plans/2026-03-20-navigation-cache-decoupling-design.md docs/protocols +git commit -m "docs: finalize navigation decoupling and unified cache rollout" +``` + +## 实施顺序约束 + +1. 必须先完成 Task 1-3 再改业务页面(否则会出现重复实现)。 +2. Task 5(路由壳层)与 Task 6/7(业务接入)要分开提交,便于回滚。 +3. 每个 Task 的测试必须在本 Task 完成后立即执行,避免堆积回归。 +4. 不允许在未通过 focused tests 的情况下进入全量验证。 + +## 回滚策略 + +1. 若导航回归:回滚 Task 5 提交,保留缓存模块提交。 +2. 若缓存一致性异常:优先回滚 Task 6/7 的 repository 接入提交。 +3. 若生命周期刷新过于频繁:关闭 Task 8 coordinator 挂载,保留手动刷新兜底。 + +## Done 定义 + +1. 所有测试与 analyze 通过。 +2. 主页按钮行为稳定,无“返回上一页”误行为。 +3. 切换页面请求数明显下降,写后一致性符合设计预期。 +4. 统一缓存已接管用户信息、日历、待办三域。 diff --git a/docs/protocols/models/todo.md b/docs/protocols/models/todo.md new file mode 100644 index 0000000..76d56f0 --- /dev/null +++ b/docs/protocols/models/todo.md @@ -0,0 +1,43 @@ +# Todo Data Protocol + +## Scope + +Defines the backend/frontend data contract for `/api/v1/todos`. + +## Field Definitions + +- `id`: string (UUID) +- `owner_id`: string (UUID) +- `title`: string, `1..255` +- `description`: string or `null`, max `1000` +- `priority`: int, quadrant value in `[1, 4]` +- `order`: int, zero-based ordering inside the same `priority` bucket, `>= 0` +- `status`: enum string: `pending | done | canceled` +- `completed_at`: datetime string or `null` +- `created_at`: datetime string +- `updated_at`: datetime string +- `schedule_items`: array + +## Request Contracts + +### Create Todo (`POST /api/v1/todos`) + +- required: `title` +- optional: `description`, `priority`, `order`, `schedule_item_ids` +- `order` omitted: backend assigns append position in the target quadrant. + +### Update Todo (`PATCH /api/v1/todos/{todo_id}`) + +- optional: `title`, `description`, `priority`, `order`, `status`, `schedule_item_ids` +- `order` is interpreted inside the todo's final `priority` quadrant. + +## Ordering Rules + +- Todo list API returns items sorted by `priority ASC`, then `order ASC`. +- Drag reorder in same quadrant updates `order` to a continuous sequence starting from `0`. +- Drag move across quadrants updates both `priority` and `order`, and source/target quadrants should both stay contiguous from `0`. + +## Compatibility Notes + +- `due_at` is removed from todo protocol. +- Clients must not send or depend on `due_at` for todo ordering. diff --git a/docs/superpowers/plans/2026-03-19-calendar-reminder-unified-interaction-implementation.md b/docs/superpowers/plans/2026-03-19-calendar-reminder-unified-interaction-implementation.md deleted file mode 100644 index 3763f6c..0000000 --- a/docs/superpowers/plans/2026-03-19-calendar-reminder-unified-interaction-implementation.md +++ /dev/null @@ -1,391 +0,0 @@ -# Calendar Reminder Unified Interaction Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** 交付“系统通知主触达 + 前台统一提醒面板”的提醒链路,保证 iOS/Android 在前台、后台、终止态都可执行“稍后提醒”和“取消并归档(内部 archive)”。 - -**Architecture:** `LocalNotificationService` 仅做调度与平台桥接;`ReminderActionExecutor` 作为唯一动作执行器;新增 `ReminderPresentationCoordinator` + `ReminderActionSheet` 负责前台展示;新增持久化幂等与 cold-start 回放,避免动作丢失/重复执行。 - -**Tech Stack:** Flutter, flutter_local_notifications, SharedPreferences, Dart isolate callback, AndroidManifest receiver, iOS AppDelegate callback, Flutter tests. - ---- - -## File Structure (Locked Before Tasks) - -### Protocol (Must First) -- Modify: `docs/protocols/calendar/reminder-alert-lifecycle.md` - -### Reminder Core -- Modify: `apps/lib/core/notifications/local_notification_service.dart` -- Create: `apps/lib/core/notifications/reminder_notification_callbacks.dart` -- Modify: `apps/lib/features/calendar/reminders/models/reminder_action.dart` -- Modify: `apps/lib/features/calendar/reminders/reminder_action_executor.dart` -- Create: `apps/lib/features/calendar/reminders/reminder_action_dedupe_store.dart` -- Create: `apps/lib/features/calendar/reminders/reminder_cold_start_queue.dart` - -### Foreground UI -- Create: `apps/lib/features/calendar/reminders/ui/reminder_presentation_coordinator.dart` -- Create: `apps/lib/features/calendar/reminders/ui/widgets/reminder_action_sheet.dart` - -### App & Platform Wiring -- Modify: `apps/lib/main.dart` -- Modify: `apps/android/app/src/main/AndroidManifest.xml` -- Modify: `apps/ios/Runner/AppDelegate.swift` - -### Tests -- Modify: `apps/test/features/calendar/reminders/reminder_action_executor_test.dart` -- Create: `apps/test/features/calendar/reminders/reminder_action_dedupe_store_test.dart` -- Create: `apps/test/features/calendar/reminders/reminder_cold_start_queue_test.dart` -- Create: `apps/test/features/calendar/reminders/reminder_notification_bridge_test.dart` -- Create: `apps/test/features/calendar/reminders/reminder_presentation_coordinator_test.dart` -- Create: `apps/test/features/calendar/reminders/reminder_action_sheet_test.dart` -- Create: `apps/test/features/calendar/reminders/reminder_permission_fallback_test.dart` -- Create: `apps/test/platform/android_manifest_notification_action_test.dart` -- Create: `apps/test/platform/ios_app_delegate_notification_callback_test.dart` - -### Cleanup -- Create: `docs/todo/calendar-reminder-migration-checklist.md` - ---- - -### Task 0: 协议先行更新(必须) - -**Files:** -- Modify: `docs/protocols/calendar/reminder-alert-lifecycle.md` - -- [ ] **Step 1: 更新动作语义文案** -```text -展示文案“取消”映射到内部动作 archive -``` -- [ ] **Step 2: 增加幂等键协议** -```text -actionExecutionId = notificationId + actionId + fireTimeBucket -``` -- [ ] **Step 3: 增加 cold-start queue 回放协议** -```text -顺序回放,单条失败不阻塞后续 -``` -- [ ] **Step 4: 运行协议自检** -Run: `rg "archive|actionExecutionId|cold-start" docs/protocols/calendar/reminder-alert-lifecycle.md` -Expected: 命中新增规则 -- [ ] **Step 5: Commit** -```bash -git add docs/protocols/calendar/reminder-alert-lifecycle.md -git commit -m "docs: align reminder protocol with archive and idempotency" -``` - -### Task 1: 动作语义收敛(TDD) - -**Files:** -- Modify: `apps/test/features/calendar/reminders/reminder_action_executor_test.dart` -- Modify: `apps/lib/features/calendar/reminders/models/reminder_action.dart` -- Modify: `apps/lib/features/calendar/reminders/reminder_action_executor.dart` - -- [ ] **Step 1: 写失败测试(archive 入口)** -```dart -test('archive action cancels reminder and archives event', () async {}); -``` -- [ ] **Step 2: 运行失败测试** -Run (workdir=`apps`): `flutter test test/features/calendar/reminders/reminder_action_executor_test.dart` -Expected: FAIL -- [ ] **Step 3: 最小实现(增加 archive 枚举并接入 executor)** -```dart -enum ReminderAction { archive('archive'), snooze10m('snooze_10m'), ... } -``` -- [ ] **Step 4: 运行通过测试** -Run (workdir=`apps`): `flutter test test/features/calendar/reminders/reminder_action_executor_test.dart` -Expected: PASS -- [ ] **Step 5: Commit** -```bash -git add apps/lib/features/calendar/reminders/models/reminder_action.dart apps/lib/features/calendar/reminders/reminder_action_executor.dart apps/test/features/calendar/reminders/reminder_action_executor_test.dart -git commit -m "refactor: unify reminder action semantics to archive" -``` - -### Task 2: 持久化幂等(TDD) - -**Files:** -- Create: `apps/test/features/calendar/reminders/reminder_action_dedupe_store_test.dart` -- Create: `apps/lib/features/calendar/reminders/reminder_action_dedupe_store.dart` - -- [ ] **Step 1: 写失败测试(重启后仍去重)** -```dart -test('same actionExecutionId is rejected after restart', () async {}); -``` -- [ ] **Step 2: 跑失败测试** -Run (workdir=`apps`): `flutter test test/features/calendar/reminders/reminder_action_dedupe_store_test.dart` -Expected: FAIL -- [ ] **Step 3: 实现最小 store** -```dart -Future markIfNew(String actionExecutionId) -``` -- [ ] **Step 4: 跑通过测试** -Run (workdir=`apps`): `flutter test test/features/calendar/reminders/reminder_action_dedupe_store_test.dart` -Expected: PASS -- [ ] **Step 5: Commit** -```bash -git add apps/lib/features/calendar/reminders/reminder_action_dedupe_store.dart apps/test/features/calendar/reminders/reminder_action_dedupe_store_test.dart -git commit -m "feat: add persistent reminder action dedupe store" -``` - -### Task 3: 冷启动回放队列(TDD) - -**Files:** -- Create: `apps/test/features/calendar/reminders/reminder_cold_start_queue_test.dart` -- Create: `apps/lib/features/calendar/reminders/reminder_cold_start_queue.dart` - -- [ ] **Step 1: 写失败测试(顺序回放)** -```dart -test('replays actions in receive order', () async {}); -``` -- [ ] **Step 2: 写失败测试(单条失败不阻塞)** -```dart -test('continues replay after one action failure', () async {}); -``` -- [ ] **Step 3: 跑失败测试** -Run (workdir=`apps`): `flutter test test/features/calendar/reminders/reminder_cold_start_queue_test.dart` -Expected: FAIL -- [ ] **Step 4: 最小实现队列** -```dart -Future replaySequentially(Future Function(...) handler) -``` -- [ ] **Step 5: 跑通过测试** -Run (workdir=`apps`): `flutter test test/features/calendar/reminders/reminder_cold_start_queue_test.dart` -Expected: PASS -- [ ] **Step 6: Commit** -```bash -git add apps/lib/features/calendar/reminders/reminder_cold_start_queue.dart apps/test/features/calendar/reminders/reminder_cold_start_queue_test.dart -git commit -m "feat: add reminder cold-start replay queue" -``` - -### Task 4: 通知桥接映射(TDD) - -**Files:** -- Create: `apps/test/features/calendar/reminders/reminder_notification_bridge_test.dart` -- Modify: `apps/lib/core/notifications/local_notification_service.dart` - -- [ ] **Step 1: 写失败测试(cancel 文案映射 archive)** -```dart -test('maps cancel action button to ReminderAction.archive', () async {}); -``` -- [ ] **Step 2: 跑失败测试** -Run (workdir=`apps`): `flutter test test/features/calendar/reminders/reminder_notification_bridge_test.dart` -Expected: FAIL -- [ ] **Step 3: 接入 dedupe store + bridge** -```dart -if (!await dedupeStore.markIfNew(actionExecutionId)) return; -``` -- [ ] **Step 4: 跑通过测试** -Run (workdir=`apps`): `flutter test test/features/calendar/reminders/reminder_notification_bridge_test.dart` -Expected: PASS -- [ ] **Step 5: Commit** -```bash -git add apps/lib/core/notifications/local_notification_service.dart apps/test/features/calendar/reminders/reminder_notification_bridge_test.dart -git commit -m "feat: map reminder notification actions through unified bridge" -``` - -### Task 5: 前台协调器(TDD) - -**Files:** -- Create: `apps/test/features/calendar/reminders/reminder_presentation_coordinator_test.dart` -- Create: `apps/lib/features/calendar/reminders/ui/reminder_presentation_coordinator.dart` - -- [ ] **Step 1: 写失败测试(仅前台展示)** -```dart -test('shows reminder sheet only in app active state', () async {}); -``` -- [ ] **Step 2: 写失败测试(去重窗口)** -```dart -test('suppresses duplicate presentation in dedupe window', () async {}); -``` -- [ ] **Step 3: 跑失败测试** -Run (workdir=`apps`): `flutter test test/features/calendar/reminders/reminder_presentation_coordinator_test.dart` -Expected: FAIL -- [ ] **Step 4: 最小实现协调器** -- [ ] **Step 5: 跑通过测试** -Run (workdir=`apps`): `flutter test test/features/calendar/reminders/reminder_presentation_coordinator_test.dart` -Expected: PASS -- [ ] **Step 6: Commit** -```bash -git add apps/lib/features/calendar/reminders/ui/reminder_presentation_coordinator.dart apps/test/features/calendar/reminders/reminder_presentation_coordinator_test.dart -git commit -m "feat: add reminder foreground presentation coordinator" -``` - -### Task 6: 前台提醒面板组件(TDD) - -**Files:** -- Create: `apps/test/features/calendar/reminders/reminder_action_sheet_test.dart` -- Create: `apps/lib/features/calendar/reminders/ui/widgets/reminder_action_sheet.dart` - -- [ ] **Step 1: 写失败测试(稍后提醒按钮)** -```dart -testWidgets('tap snooze triggers snooze callback', (tester) async {}); -``` -- [ ] **Step 2: 写失败测试(归档按钮)** -```dart -testWidgets('tap archive triggers archive callback', (tester) async {}); -``` -- [ ] **Step 3: 跑失败测试** -Run (workdir=`apps`): `flutter test test/features/calendar/reminders/reminder_action_sheet_test.dart` -Expected: FAIL -- [ ] **Step 4: 最小实现 token 驱动 UI** -- [ ] **Step 5: 跑通过测试** -Run (workdir=`apps`): `flutter test test/features/calendar/reminders/reminder_action_sheet_test.dart` -Expected: PASS -- [ ] **Step 6: Commit** -```bash -git add apps/lib/features/calendar/reminders/ui/widgets/reminder_action_sheet.dart apps/test/features/calendar/reminders/reminder_action_sheet_test.dart -git commit -m "feat: add reusable reminder action sheet" -``` - -### Task 7: App 接线与后台入口(TDD) - -**Files:** -- Modify: `apps/lib/main.dart` -- Create/Modify: `apps/lib/core/notifications/reminder_notification_callbacks.dart` -- Modify: `apps/lib/core/notifications/local_notification_service.dart` -- Create: `apps/test/features/calendar/reminders/reminder_notification_callbacks_test.dart` - -- [ ] **Step 1: 写失败测试(后台入口是 top-level + pragma)** -```dart -test('background notification callback is top-level entry-point', () async {}); -``` -- [ ] **Step 2: 跑失败测试** -Run (workdir=`apps`): `flutter test test/features/calendar/reminders/reminder_notification_callbacks_test.dart` -Expected: FAIL -- [ ] **Step 3: 接线 initialize 注册前台/后台回调** -- [ ] **Step 4: 接线 foreground presenter 到 coordinator** -- [ ] **Step 5: 接线 action handler 到 executor** -- [ ] **Step 6: reminders 套件回归** -Run (workdir=`apps`): `flutter test test/features/calendar/reminders` -Expected: PASS -- [ ] **Step 7: Commit** -```bash -git add apps/lib/main.dart apps/lib/core/notifications/reminder_notification_callbacks.dart apps/lib/core/notifications/local_notification_service.dart apps/test/features/calendar/reminders/reminder_notification_callbacks_test.dart -git commit -m "feat: wire reminder callbacks for foreground and background" -``` - -### Task 8: Android 平台配置可执行校验(TDD) - -**Files:** -- Create: `apps/test/platform/android_manifest_notification_action_test.dart` -- Modify: `apps/android/app/src/main/AndroidManifest.xml` - -- [ ] **Step 1: 写失败测试(缺 ActionBroadcastReceiver)** -```dart -test('android manifest contains ActionBroadcastReceiver', () async {}); -``` -- [ ] **Step 2: 跑失败测试** -Run (workdir=`apps`): `flutter test test/platform/android_manifest_notification_action_test.dart` -Expected: FAIL -- [ ] **Step 3: 增加 receiver 配置** -- [ ] **Step 4: 跑通过测试** -Run (workdir=`apps`): `flutter test test/platform/android_manifest_notification_action_test.dart` -Expected: PASS -- [ ] **Step 5: Commit** -```bash -git add apps/android/app/src/main/AndroidManifest.xml apps/test/platform/android_manifest_notification_action_test.dart -git commit -m "fix: register android action receiver for reminder notifications" -``` - -### Task 9: iOS 平台配置可执行校验(TDD) - -**Files:** -- Create: `apps/test/platform/ios_app_delegate_notification_callback_test.dart` -- Modify: `apps/ios/Runner/AppDelegate.swift` -- Modify: `apps/lib/core/notifications/local_notification_service.dart` - -- [ ] **Step 1: 写失败测试(registrant callback)** -```dart -test('ios app delegate registers flutter local notifications callback', () async {}); -``` -- [ ] **Step 2: 跑失败测试** -Run (workdir=`apps`): `flutter test test/platform/ios_app_delegate_notification_callback_test.dart` -Expected: FAIL -- [ ] **Step 3: 实现 callback 注册 + category version bump (`calendar_reminder_v2`)** -- [ ] **Step 4: 跑通过测试与 reminders 回归** -Run (workdir=`apps`): `flutter test test/platform/ios_app_delegate_notification_callback_test.dart` -Expected: PASS -Run (workdir=`apps`): `flutter test test/features/calendar/reminders` -Expected: PASS -- [ ] **Step 5: Commit** -```bash -git add apps/ios/Runner/AppDelegate.swift apps/lib/core/notifications/local_notification_service.dart apps/test/platform/ios_app_delegate_notification_callback_test.dart -git commit -m "fix: enable ios reminder action handling in background" -``` - -### Task 10: Android 13+ 权限降级与埋点(TDD) - -**Files:** -- Create: `apps/test/features/calendar/reminders/reminder_permission_fallback_test.dart` -- Modify: `apps/lib/core/notifications/local_notification_service.dart` - -- [ ] **Step 1: 写失败测试(未授权降级到应用内)** -```dart -test('fallbacks to in-app path when notifications permission denied', () async {}); -``` -- [ ] **Step 2: 跑失败测试** -Run (workdir=`apps`): `flutter test test/features/calendar/reminders/reminder_permission_fallback_test.dart` -Expected: FAIL -- [ ] **Step 3: 最小实现权限检查 + 降级埋点** -- [ ] **Step 3: 最小实现权限检查 + 降级埋点** -```text -埋点字段至少包含:actionExecutionId、permissionState、appLifecycleState、platform -``` -- [ ] **Step 4: 跑通过测试** -Run (workdir=`apps`): `flutter test test/features/calendar/reminders/reminder_permission_fallback_test.dart` -Expected: PASS -- [ ] **Step 5: Commit** -```bash -git add apps/lib/core/notifications/local_notification_service.dart apps/test/features/calendar/reminders/reminder_permission_fallback_test.dart -git commit -m "feat: add reminder permission fallback path and telemetry" -``` - -### Task 11: 旧代码清单与即时清理 - -**Files:** -- Create: `docs/todo/calendar-reminder-migration-checklist.md` -- Modify/Delete: `apps/lib/**`, `apps/test/**`(以清单为准) - -- [ ] **Step 1: 建立迁移清单(文件/符号/决策/责任人)** -- [ ] **Step 2: 清理前扫描** -Run: `rg "calendar_reminder_actions_v1|ReminderAction.cancel|_oldReminderEntry|_legacyReminderRoute" apps/lib apps/test` -Expected: 输出待清理命中(仅代码引用,不含注释/文档示例) -- [ ] **Step 3: 删除无用旧代码、无效测试、旧 fixture** -- [ ] **Step 4: 清理后扫描** -Run: `rg "calendar_reminder_actions_v1|ReminderAction.cancel|_oldReminderEntry|_legacyReminderRoute" apps/lib apps/test` -Expected: no matches(注释/文档示例允许存在需在清单标注) -- [ ] **Step 5: Commit** -```bash -git add docs/todo/calendar-reminder-migration-checklist.md apps/lib apps/test -git commit -m "refactor: remove obsolete reminder paths after migration" -``` - -### Task 12: 最终验证与交付 - -**Files:** -- Verify only - -- [ ] **Step 1: reminders 全量测试** -Run (workdir=`apps`): `flutter test test/features/calendar/reminders` -Expected: PASS -- [ ] **Step 2: calendar 相关测试** -Run (workdir=`apps`): `flutter test test/features/calendar` -Expected: PASS -- [ ] **Step 3: 手工矩阵 6/6 验证** -```text -Android: 前台/后台/杀进程 -iOS: 前台/后台锁屏/升级后 -``` -- [ ] **Step 4: 输出证据与指标** -```text -命令结果、回放日志、重试日志、重复执行率=0(按 actionExecutionId 统计) -``` - ---- - -## Execution Notes - -- 任务顺序不可打乱:协议 -> 动作语义 -> 幂等 -> 回放 -> 桥接 -> UI -> 平台 -> 清理。 -- 每个任务只做最小改动并回归对应测试。 -- 视觉组件严格使用 `apps/lib/core/theme/design_tokens.dart`。 -- 若实现中与 spec 冲突,先改 spec/协议再继续写代码。 diff --git a/docs/superpowers/plans/2026-03-20-todo-quadrant-drag-implementation.md b/docs/superpowers/plans/2026-03-20-todo-quadrant-drag-implementation.md deleted file mode 100644 index 06f10e2..0000000 --- a/docs/superpowers/plans/2026-03-20-todo-quadrant-drag-implementation.md +++ /dev/null @@ -1,524 +0,0 @@ -# 待办事项四象限拖拽实现计划 - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** 实现四象限待办页面的拖拽排序和跨象限移动功能 - -**Architecture:** 使用 Flutter `LongPressDraggable` + `DragTarget` 实现拖拽,`AnimatedContainer` 实现排序动画,乐观更新模式同步后端 - -**Tech Stack:** Flutter, go_router, provider/state management - ---- - -## 文件结构 - -``` -apps/lib/features/todo/ -├── ui/screens/todo_quadrants_screen.dart (修改: 添加拖拽状态管理) -├── ui/widgets/ -│ └── todo_drag_item.dart (创建: 可拖拽待办项组件) -└── data/todo_api.dart (检查: 确认 API 支持更新 priority) -``` - ---- - -## 前置检查 - -- [ ] **Step 1: 检查 TodoApi 支持更新 priority** - -文件: `apps/lib/features/todo/data/todo_api.dart` - -```dart -// 确认有 updateTodo 方法支持更新 priority -Future updateTodo(String id, {int? priority, ...}) -``` - ---- - -## Task 1: 添加拖拽状态管理 - -**文件:** -- 修改: `apps/lib/features/todo/ui/screens/todo_quadrants_screen.dart:26-40` - -- [ ] **Step 1: 添加拖拽相关状态和回调** - -在 `_TodoQuadrantsScreenState` 中添加: - -```dart -class _TodoQuadrantsScreenState extends State { - // ... existing states ... - - // 拖拽状态 - String? _draggingTodoId; - int? _dragTargetQuadrant; // 1, 2, 3 - int? _dragInsertIndex; // 插入位置索引 - - // 辅助方法 - bool get _isDragging => _draggingTodoId != null; - - void _onDragStart(String todoId) { - setState(() { - _draggingTodoId = todoId; - }); - } - - void _onDragEnd() { - setState(() { - _draggingTodoId = null; - _dragTargetQuadrant = null; - _dragInsertIndex = null; - }); - } - - void _onDragEnterQuadrant(int quadrant) { - setState(() { - _dragTargetQuadrant = quadrant; - }); - } - - void _onDragUpdateInsertIndex(int index) { - setState(() { - _dragInsertIndex = index; - }); - } - - Future _onDrop(String todoId, int targetQuadrant, int insertIndex) async { - // 实现乐观更新 + 后端同步 - } -} -``` - -- [ ] **Step 2: 将 TodoDragItem 回调传入正确的 State 方法** - -在 `_buildQuadrant` 构建 TodoDragItem 时传入: - -```dart -TodoDragItem( - todo: item, - quadrant: quadrantValue, - onDragStarted: () => _onDragStart(item.id), - onDragEnd: _onDragEnd, -) -``` - -- [ ] **Step 3: 运行测试验证编译通过** - -```bash -cd apps && flutter analyze lib/features/todo/ui/screens/todo_quadrants_screen.dart -``` - ---- - -## Task 2: 创建 TodoDragItem 组件 - -**文件:** -- 创建: `apps/lib/features/todo/ui/widgets/todo_drag_item.dart` - -- [ ] **Step 1: 创建 Stateful 拖拽组件** - -```dart -class TodoDragItem extends StatefulWidget { - final TodoResponse todo; - final int quadrant; // 1, 2, 3 - final VoidCallback onDragStarted; - final VoidCallback onDragEnd; - - const TodoDragItem({ - super.key, - required this.todo, - required this.quadrant, - required this.onDragStarted, - required this.onDragEnd, - }); - - @override - State createState() => _TodoDragItemState(); -} - -class _TodoDragItemState extends State { - @override - Widget build(BuildContext context) { - return LongPressDraggable( - data: '${widget.todo.id}:${widget.quadrant}', - delay: const Duration(milliseconds: 150), - feedback: Material( - elevation: 8, - borderRadius: BorderRadius.circular(8), - child: Transform.scale( - scale: 1.03, - child: SizedBox( - width: 280, - child: _buildDragFeedback(), - ), - ), - ), - childWhenDragging: Opacity( - opacity: 0.5, // Spec: 占位框 opacity 0.5 - child: widget.child, - ), - onDragStarted: widget.onDragStarted, - onDragEnd: (_) => widget.onDragEnd(), - child: widget.child, - ); - } - - Widget _buildDragFeedback() { - return Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), - decoration: BoxDecoration( - color: AppColors.white, - borderRadius: BorderRadius.circular(8), - boxShadow: [ - BoxShadow( - color: AppColors.slate400.withValues(alpha: 0.3), - blurRadius: 12, - offset: const Offset(0, 4), - ), - ], - ), - child: Text( - widget.todo.title, - style: const TextStyle( - fontSize: 13, - fontWeight: FontWeight.w600, - color: AppColors.slate700, - ), - ), - ); - } -} -``` - -- [ ] **Step 2: 验证编译** - -```bash -flutter analyze lib/features/todo/ui/widgets/todo_drag_item.dart -``` - ---- - -## Task 3: 实现 DragTarget 象限接收 - -**文件:** -- 修改: `apps/lib/features/todo/ui/screens/todo_quadrants_screen.dart` 的 `_buildQuadrant` 方法 - -- [ ] **Step 1: 将象限容器改为 DragTarget** - -```dart -Widget _buildQuadrant({ - required String title, - required Color textColor, - required Color dividerColor, - required Color borderColor, - required List items, - required int quadrantValue, // 1, 2, 3 - required Future Function(TodoResponse) onComplete, - required void Function(TodoResponse) onTap, -}) { - return DragTarget( - onWillAcceptWithDetails: (details) { - // 解析拖拽数据 - final parts = details.data.split(':'); - final todoId = parts[0]; - // 标记目标象限 - _onDragEnterQuadrant(quadrantValue); - return true; - }, - onAcceptWithDetails: (details) { - final parts = details.data.split(':'); - final todoId = parts[0]; - _onDrop(todoId, quadrantValue, 0); // TODO: 计算插入位置 - }, - onLeave: (_) { - // 清除高亮 - }, - builder: (context, candidateData, rejectedData) { - final isDragOver = candidateData.isNotEmpty; - return AnimatedContainer( - duration: const Duration(milliseconds: 200), - decoration: BoxDecoration( - border: Border.all( - color: isDragOver ? AppColors.blue400 : borderColor, - width: isDragOver ? 2 : 1, - ), - boxShadow: isDragOver ? [ - BoxShadow( - color: AppColors.blue200.withValues(alpha: 0.4), - blurRadius: 12, - ), - ] : null, - ), - child: _buildQuadrantContent(...), - ); - }, - ); -} -``` - -- [ ] **Step 2: 验证编译** - -```bash -flutter analyze lib/features/todo/ui/screens/todo_quadrants_screen.dart -``` - ---- - -## Task 4: 实现跨象限移动和象限内排序逻辑 - -**文件:** -- 修改: `apps/lib/features/todo/ui/screens/todo_quadrants_screen.dart` - -- [ ] **Step 1: 实现 _onDrop 方法(支持跨象限移动和象限内排序)** - -```dart -Future _onDrop(String todoId, int targetQuadrant, int insertIndex) async { - final todo = _todos.firstWhere((t) => t.id == todoId); - final sourceQuadrant = todo.priority; - - // 乐观更新:先保存当前状态用于回滚 - final previousTodos = List.from(_todos); - - if (sourceQuadrant == targetQuadrant) { - // 象限内排序:重新排列列表顺序 - setState(() { - final currentIndex = _todos.indexWhere((t) => t.id == todoId); - if (currentIndex != insertIndex) { - final item = _todos.removeAt(currentIndex); - _todos.insert(insertIndex, item); - // 更新 sort_order (前端维护的排序索引) - for (int i = 0; i < _todos.length; i++) { - _todos[i] = _todos[i].copyWith(sortOrder: i); - } - } - _onDragEnd(); - }); - - // 后端同步 sort_order - try { - // 只更新 sort_order 字段 - await _todoApi.updateTodo(todoId, sortOrder: insertIndex); - } catch (e) { - _rollbackAndShowError(previousTodos, '排序失败'); - } - } else { - // 跨象限移动:更新 priority - setState(() { - final index = _todos.indexWhere((t) => t.id == todoId); - if (index != -1) { - _todos[index] = _todos[index].copyWith(priority: targetQuadrant); - } - _onDragEnd(); - }); - - // 后端同步 priority - try { - await _todoApi.updateTodo(todoId, priority: targetQuadrant); - if (mounted) { - Toast.show(context, '已移动', type: ToastType.success); - } - } catch (e) { - _rollbackAndShowError(previousTodos, '移动失败'); - } - } -} - -void _rollbackAndShowError(List previousTodos, String message) { - setState(() { - _todos = previousTodos; - }); - if (mounted) { - Toast.show(context, message, type: ToastType.error); - } -} -``` - -- [ ] **Step 2: 检查 TodoApi.updateTodo 签名** - -如果 `updateTodo` 不支持 `sortOrder` 参数,需要在 `TodoApi` 中添加: - -```dart -// apps/lib/features/todo/data/todo_api.dart -Future updateTodo( - String id, { - int? priority, - int? sortOrder, // 添加此参数 - String? title, - String? description, -}) async { - // 调用后端 API 更新 -} -``` - -- [ ] **Step 3: 验证编译和功能** - -```bash -flutter analyze lib/features/todo/ui/screens/todo_quadrants_screen.dart -flutter analyze lib/features/todo/data/todo_api.dart -``` - ---- - -## Task 5: 添加插入指示器 - -**文件:** -- 修改: `apps/lib/features/todo/ui/screens/todo_quadrants_screen.dart` - -- [ ] **Step 1: 添加插入位置状态和指示器 Widget** - -```dart -// 在 State 中添加 -int? _dragInsertIndex; // 拖拽插入位置 - -// 添加插入指示器 Widget -Widget _buildInsertIndicator() { - return Container( - height: 2, - margin: const EdgeInsets.symmetric(vertical: 4), - decoration: BoxDecoration( - color: AppColors.blue500, - borderRadius: BorderRadius.circular(1), - boxShadow: [ - BoxShadow( - color: AppColors.blue400.withValues(alpha: 0.3), - blurRadius: 4, - ), - ], - ), - ); -} -``` - -- [ ] **Step 2: 在象限内容列表中渲染插入指示器** - -在 `_buildQuadrant` 的 items 列表中,根据 `_dragInsertIndex` 插入指示器: - -```dart -// items 列表构建 -final widgets = []; -for (int i = 0; i < items.length; i++) { - if (_dragInsertIndex == i && _dragTargetQuadrant == quadrantValue) { - widgets.add(_buildInsertIndicator()); - } - widgets.add(TodoDragItem( - todo: items[i], - quadrant: quadrantValue, - onDragStarted: () => _onDragStart(items[i].id), - onDragEnd: _onDragEnd, - )); -} -``` - -- [ ] **Step 3: 验证编译** - -```bash -flutter analyze lib/features/todo/ui/screens/todo_quadrants_screen.dart -``` - ---- - -## Task 6: 添加 Spring 动画和退出/进入动画比例 - -**文件:** -- 修改: `apps/lib/features/todo/ui/screens/todo_quadrants_screen.dart` - -- [ ] **Step 1: 使用真正的 Spring 动画替代 elasticOut** - -Flutter 的 `SpringSimulation` 需要使用 `AnimationController`。对于跨象限移动的 feedback,可以使用 `Curves.bounceOut` 或自定义 spring: - -```dart -// 使用 SpringSimulation 的替代方案 - CurvedAnimation + bounceOut -// 跨象限移动动画 (进入时间短,退出时间长,符合 spec) - AnimatedContainer( - duration: Duration( - milliseconds: _isDragging ? 200 : 150, // 进入 150ms < 退出 200ms - ), - curve: Curves.easeOutCubic, - // ... - ) -``` - -对于真正的 spring 物理效果,可以在 `pubspec.yaml` 添加 `flutter_animate` 包: - -```yaml -dependencies: - flutter_animate: ^4.5.0 -``` - -然后使用: -```dart -import 'package:flutter_animate/flutter_animate.dart'; - -child.animate().spring( - type: SpringType.bouncy, - duration: 300.ms, -) -``` - -- [ ] **Step 2: 验证编译** - -```bash -flutter analyze lib/features/todo/ui/screens/todo_quadrants_screen.dart -``` - ---- - -## Task 7: 集成测试 - -**文件:** -- 创建: `apps/test/features/todo/quadrant_drag_test.dart` - -- [ ] **Step 1: 编写核心功能测试** - -```dart -testWidgets('跨象限拖拽 - 成功后显示 Toast', (tester) async { - // mock TodoApi - when(() => todoApi.updateTodo(any(), priority: any(named: 'priority'))) - .thenAnswer((_) async => mockTodo); - - await pumpWidgetWithScaffolding(tester, todoApi: todoApi); - - // 执行拖拽 - final todoItem = find.text('测试待办'); - await tester.drag(todoItem, const Offset(0, 200)); // 拖到下一象限 - await tester.pumpAndSettle(); - - expect(find.text('已移动'), findsOneWidget); -}); - -testWidgets('跨象限拖拽 - 失败后回滚并显示错误', (tester) async { - when(() => todoApi.updateTodo(any(), priority: any(named: 'priority'))) - .thenThrow(Exception('网络错误')); - - await pumpWidgetWithScaffolding(tester, todoApi: todoApi); - - final todoItem = find.text('测试待办'); - await tester.drag(todoItem, const Offset(0, 200)); - await tester.pumpAndSettle(); - - expect(find.text('移动失败'), findsOneWidget); - // 验证 UI 回滚到原始状态 -}); - -testWidgets('象限内排序 - 位置正确交换', (tester) async { - // 验证排序逻辑 -}); -``` - -- [ ] **Step 2: 运行测试** - -```bash -cd apps && flutter test test/features/todo/quadrant_drag_test.dart -``` - ---- - -## 验收标准 - -- [ ] 长按待办项 150ms 后启动拖拽 -- [ ] 拖拽时卡片 scale 1.03 + 阴影,原位置显示 opacity 0.5 占位 -- [ ] 拖到目标象限时,象限边框高亮发光 (2px blue400) -- [ ] 目标位置显示 2px 蓝色插入指示器 -- [ ] 释放后卡片以平滑动画到达新位置 -- [ ] 跨象限移动后本地 UI 立即更新 -- [ ] 后端同步失败时回滚本地状态并显示错误 Toast -- [ ] 编译无错误,测试通过 diff --git a/docs/superpowers/specs/2026-03-19-calendar-reminder-unified-interaction-design.md b/docs/superpowers/specs/2026-03-19-calendar-reminder-unified-interaction-design.md deleted file mode 100644 index f6fa128..0000000 --- a/docs/superpowers/specs/2026-03-19-calendar-reminder-unified-interaction-design.md +++ /dev/null @@ -1,215 +0,0 @@ -# 日历提醒统一交互设计(iOS/Android) - -## 1. 背景与问题 - -当前日历提醒模块存在以下问题: - -1. iOS 通知动作("稍后提醒"/"取消")在横幅场景下可见性不稳定,用户误判为无按钮。 -2. Android 与 iOS 的通知动作回调链路不一致,导致按钮点击在部分状态下无效。 -3. 前台提醒体验依赖系统默认样式,Android 观感较弱,不符合产品视觉语言。 -4. 提醒动作与弹窗交互代码存在历史分叉,维护成本高,且存在潜在无用旧代码残留。 - -## 2. 目标与非目标 - -### 2.1 目标 - -- 支持 App 关闭状态下的到点提醒(系统通知主触达)。 -- 统一提醒动作语义: - - `稍后提醒` = 延后 10 分钟。 - - `取消` = 归档日历事件。 -- 前台状态提供一套跨平台复用的应用内提醒面板,提升视觉质量。 -- 无论动作来自系统通知还是应用内面板,都进入同一业务执行链路。 -- 在新链路稳定后,清除无用旧代码与重复入口。 - -### 2.2 非目标 - -- 不改变提醒策略(仍按当前 reminderMinutes + 重复提醒策略)。 -- 不改动日历事件核心数据结构与后端协议。 -- 不在本次引入新的提醒类型(例如自定义延后时长、多级动作)。 - -## 3. 总体方案 - -采用“系统通知主触达 + 前台应用内面板增强”的混合方案: - -1. **系统通知层(平台差异化)** - - Android/iOS 继续使用 `flutter_local_notifications`。 - - 平台分别补齐通知动作接收能力,确保前台/后台/终止态都可触发动作。 - -2. **动作执行层(跨平台统一)** - - 以 `ReminderActionExecutor` 作为唯一动作入口。 - - 内部动作 ID 固定为:`ReminderAction.snooze10m` 与 `ReminderAction.archive`。 - - UI 文案中的“取消”仅为展示文案,内部统一映射到 `archive`。 - -3. **前台呈现层(跨平台复用)** - - 新增应用内 `ReminderActionSheet`(共享组件,遵循设计 token)。 - - 仅在应用前台触发,用于替代系统默认弹窗体验。 - -4. **展示策略(避免双提醒)** - - 前台(App active):默认只展示 `ReminderActionSheet`,不展示系统通知横幅。 - - 后台/终止态:只展示系统通知。 - -## 4. 关键设计决策 - -### 4.1 是否需要 iOS/Android 各写一套弹窗组件 - -不需要。应用内提醒组件采用一套 Flutter 共享实现。 - -需要分平台处理的是系统通知配置与回调桥接,不是应用内 UI 组件本身。 - -### 4.2 到点提醒是否继续使用“弹窗” - -主流做法是系统通知,不是纯应用内弹窗。原因: - -- App 关闭态仅系统通知可达。 -- 锁屏、通知中心具备天然可达性与系统一致性。 -- 可直接承载动作按钮(稍后提醒、取消并归档)。 - -前台场景再补应用内面板,兼顾体验与一致行为。 - -### 4.3 iOS 动作按钮显示问题 - -iOS 横幅通常不保证直接展示全部动作按钮,需展开通知查看动作。该行为属于系统 UI 规则。 - -此外 iOS 通知 category 存在缓存特性,category 变更后可能需要重装或升级 category id 才能稳定生效。 - -## 5. 模块与职责划分 - -### 5.1 保留并增强 - -- `apps/lib/core/notifications/local_notification_service.dart` - - 仅负责通知调度与平台动作回调桥接。 - - 不负责前台 UI 展示。 - - 补齐后台动作回调接入。 - -- `apps/lib/features/calendar/reminders/reminder_action_executor.dart` - - 作为唯一动作执行器。 - - 保持归档 outbox 重试机制。 - -### 5.2 新增 - -- `apps/lib/features/calendar/reminders/ui/reminder_presentation_coordinator.dart` - - 感知 app 前后台状态。 - - 前台触发应用内提醒面板。 - - 作为前台展示唯一入口,禁止其他模块直接弹出提醒面板。 - -- `ReminderActionSheet`(共享组件) - - 展示事件摘要 + 两个动作按钮。 - - 保证与系统通知动作语义一致。 - -### 5.3 平台接入补齐 - -- Android: - - 在 `apps/android/app/src/main/AndroidManifest.xml` 增加 `ActionBroadcastReceiver`。 - - 配置并接入 `onDidReceiveBackgroundNotificationResponse`。 - - Android 13+ 先做 `POST_NOTIFICATIONS` 授权检查;未授权时降级应用内提示并记录埋点。 - - 后台回调函数必须为 top-level 且加 `@pragma('vm:entry-point')`。 - -- iOS: - - 在 `apps/ios/Runner/AppDelegate.swift` 配置 plugin registrant callback(用于后台 action isolate)。 - - 后台回调函数必须为 top-level 且加 `@pragma('vm:entry-point')`。 - - `UNNotificationCategory` 在应用启动早期完成注册(早于提醒调度)。 - - 为 category id 增加版本化策略(`calendar_reminder_v{n}`),避免缓存导致动作更新不生效。 - -## 6. 动作流转(统一) - -### 6.1 稍后提醒 - -触发源(系统通知按钮或应用内面板按钮) --> 统一映射为 `ReminderAction.snooze10m` --> `ReminderActionExecutor._snoozeEvent` --> 重新计算下一次时间并 `scheduleReminderAt` - -### 6.2 取消(归档) - -触发源(系统通知按钮或应用内面板按钮) --> 统一映射为 `ReminderAction.archive` --> 取消本地提醒 --> 写 outbox 并调用归档接口 --> 成功标记 done,失败进入 retry/backoff - -### 6.3 动作回传契约(前台/后台/终止态统一) - -- 每次动作生成幂等键:`actionExecutionId = notificationId + actionId + fireTimeBucket`。 -- 执行前先查重(本地持久化幂等表);命中时直接 ACK,不重复执行业务副作用。 -- 终止态动作进入 cold-start queue 回放,按接收时间顺序处理。 -- 单条动作失败不阻塞后续动作;失败进入 retry/backoff 并可观测。 - -## 7. 旧代码收集与清理计划 - -### 7.1 旧代码清单建立 - -改造前先建立“提醒模块迁移清单”,按三类标记: - -- `保留`:仍由新架构使用。 -- `替换`:保留接口,重写实现。 -- `删除`:无引用、重复职责、历史临时逻辑。 - -迁移清单字段必须包含:`文件路径`、`符号名`、`处理决策(保留/替换/删除)`、`责任人`。 - -### 7.2 清理时机 - -- 新链路在 Android+iOS 均验证通过后,立即执行删除。 -- 不做“先保留一版再说”的长期并存。 - -### 7.3 清理范围 - -- 无效弹窗触发入口。 -- 不再使用的提醒动作映射分支。 -- 重复回调注册与过时常量(旧 action id / 旧 category id)。 -- 不再有保护价值的旧测试与旧 fixture。 - -### 7.4 清理验收 - -- 以“旧标识 0 引用”为验收标准,至少覆盖:旧 action id、旧 category id、旧入口函数名。 -- 输出固定 grep 关键字清单并逐条验收。 -- 提醒链路测试通过。 -- 删除项对应测试通过,且无悬挂 fixture/snapshot 引用。 -- 手工回归覆盖前台/后台/终止态三种状态。 - -## 8. 测试与验证 - -### 8.1 自动化 - -- 新增/更新 reminders 相关单测: - - 动作映射正确性(notification/app sheet -> executor)。 - - `archive` 的 outbox 行为与重试退避逻辑。 - - `snooze10m` 在边界时间的调度行为。 - - 同一 `actionExecutionId` 重复投递仅执行一次(幂等)。 - - 终止态 cold-start queue 回放不丢失且顺序一致。 - - 前台面板与系统通知并发触发时仅产生一次业务副作用。 - -### 8.2 手工验证矩阵 - -- Android: - - 前台:面板按钮可用。 - - 后台:通知动作可用。 - - 杀进程:通知动作可用。 - -- iOS: - - 前台:面板按钮可用。 - - 后台/锁屏:通知展开后动作可用。 - - 安装升级后 category 动作可用。 - -## 9. 风险与缓解 - -- **风险**:iOS category 缓存导致动作更新不生效。 - - **缓解**:category id 版本化 + 明确重装验证步骤。 - -- **风险**:后台动作 isolate 未正确注册导致点击丢失。 - - **缓解**:AppDelegate/Manifest 严格按插件要求配置,并做终止态回归。 - -- **风险**:前台面板与系统通知并发触发造成重复操作。 - - **缓解**:PresentationCoordinator 增加去重窗口与事件级幂等保护。 - -- **风险**:通知权限未授权导致后台提醒不可达。 - - **缓解**:启动期权限检查 + 降级提示 + 埋点追踪。 - -## 10. 完成定义(DoD) - -- App 关闭状态下,系统通知可触达并可执行两个动作。 -- `取消` 在业务上严格等价于归档。 -- 前台统一提醒面板上线,Android 样式符合项目视觉语言。 -- 动作执行链路唯一,平台仅保留桥接差异。 -- 历史无用代码完成清理,且通过验证。 -- 手工矩阵 6/6 场景通过(Android 前台/后台/杀进程 + iOS 前台/后台锁屏/升级后)。 -- 动作日志可追踪,重复执行率=0(基于 `actionExecutionId` 统计)。 diff --git a/docs/superpowers/specs/2026-03-20-todo-quadrant-drag-design.md b/docs/superpowers/specs/2026-03-20-todo-quadrant-drag-design.md deleted file mode 100644 index 7d52e87..0000000 --- a/docs/superpowers/specs/2026-03-20-todo-quadrant-drag-design.md +++ /dev/null @@ -1,113 +0,0 @@ -# 待办事项四象限拖拽交互设计 - -## 概述 - -四象限待办页面支持待办项在象限内排序以及跨象限拖拽移动,同时保持与后端的数据同步。 - -## 交互设计 - -### 拖拽状态 - -| 状态 | 视觉反馈 | -|------|----------| -| 按住(未拖拽) | 卡片 scale 1.0,轻微阴影 | -| 拖拽开始 | 卡片 scale 1.03 + 阴影加深,原位置保留半透明占位框 | -| 拖拽中 | 卡片跟随手指(transform),目标象限边框高亮发光 | -| 释放-象限内排序 | 卡片平滑移动到新位置(200ms ease-out) | -| 释放-跨象限移动 | 卡片以 spring 动画弹入目标位置 | -| 操作完成 | 显示成功 Toast | - -### 动画参数 - -- **micro-interaction**: 150-300ms -- **easing**: ease-out 进入,ease-in 退出 -- **spring**: 用于跨象限移动,natural feel -- **scale feedback**: 0.95-1.05 on press -- **exit faster than enter**: 退出时长是进入的 60-70% - -### 防误触 - -- 拖拽启动延迟:100-150ms 确认是长按而非点击 -- 仅在按住并移动超过阈值后启动拖拽 - -## 数据流 - -### 状态管理 - -``` -_QuadrantScreenState - ├── List _todos - ├── DragState _dragState (null / dragging) - └── int? _dragTargetQuadrant (1, 2, 3) -``` - -### API 交互 - -1. **象限内排序**:调用 `PUT /todos/{id}` 更新 `priority` 和 `sort_order` -2. **跨象限移动**:调用 `PUT /todos/{id}` 更新 `priority` - -### 乐观更新 - -- 用户释放后立即更新本地 UI -- 后端请求失败时回滚 + 显示错误 Toast - -## 组件结构 - -``` -TodoQuadrantsScreen -├── _QuadrantDragContainer (LongPressDraggable + DragTarget) -│ ├── _QuadrantCard (象限容器) -│ └── _TodoDragItem (可拖拽待办项) -└── _DragFeedbackWidget (拖拽中的视觉反馈) -``` - -## 状态定义 - -| 状态 | 描述 | -|------|------| -| `idle` | 正常显示 | -| `dragging` | 正在拖拽某项 | -| `dragOverQuadrant` | 拖拽到某象限上方 | -| `reordering` | 正在执行排序动画 | - -## 优先级定义 - -| 象限 | Priority Value | -|------|---------------| -| 重要紧急 | 1 | -| 紧急不重要 | 3 | -| 重要不紧急 | 2 | - -## 视觉规范 - -### 卡片样式 - -- **正常**: `color: AppColors.todoCardBg`, `borderRadius: 14px` -- **拖拽中**: `opacity: 0.5` 在原位置显示占位 -- **跟随手指**: `scale: 1.03`, `shadow: elevated` - -### 象限边框高亮 - -- **正常**: `border: 1px solid {quadrantBorderColor}` -- **dragOver**: `border: 2px solid AppColors.blue400`, `boxShadow: 0 0 12px AppColors.blue200` - -### 插入指示器 - -- 高度 2px,圆角 1px -- 颜色:`AppColors.blue500` -- 位置:两个待办项之间 - -## 错误处理 - -| 场景 | 处理方式 | -|------|----------| -| 后端请求失败 | 回滚本地状态,显示错误 Toast | -| 网络断开 | 显示网络错误提示 | -| 并发冲突 | 以最新数据为准,提示用户刷新 | - -## 实现要点 - -1. 使用 Flutter `LongPressDraggable` + `DragTarget` 实现拖拽 -2. 使用 `AnimatedContainer` / `AnimatedPositioned` 实现平滑动画 -3. 乐观更新:先更新 UI,后请求后端 -4. 拖拽反馈使用 `Transform` 而非改变位置,避免 CLS