# 待办事项四象限拖拽实现计划 > **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 - [ ] 编译无错误,测试通过