13 KiB
待办事项四象限拖拽实现计划
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
// 确认有 updateTodo 方法支持更新 priority
Future<TodoResponse> updateTodo(String id, {int? priority, ...})
Task 1: 添加拖拽状态管理
文件:
-
修改:
apps/lib/features/todo/ui/screens/todo_quadrants_screen.dart:26-40 -
Step 1: 添加拖拽相关状态和回调
在 _TodoQuadrantsScreenState 中添加:
class _TodoQuadrantsScreenState extends State<TodoQuadrantsScreen> {
// ... 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<void> _onDrop(String todoId, int targetQuadrant, int insertIndex) async {
// 实现乐观更新 + 后端同步
}
}
- Step 2: 将 TodoDragItem 回调传入正确的 State 方法
在 _buildQuadrant 构建 TodoDragItem 时传入:
TodoDragItem(
todo: item,
quadrant: quadrantValue,
onDragStarted: () => _onDragStart(item.id),
onDragEnd: _onDragEnd,
)
- Step 3: 运行测试验证编译通过
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 拖拽组件
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<TodoDragItem> createState() => _TodoDragItemState();
}
class _TodoDragItemState extends State<TodoDragItem> {
@override
Widget build(BuildContext context) {
return LongPressDraggable<String>(
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: 验证编译
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
Widget _buildQuadrant({
required String title,
required Color textColor,
required Color dividerColor,
required Color borderColor,
required List<TodoResponse> items,
required int quadrantValue, // 1, 2, 3
required Future<void> Function(TodoResponse) onComplete,
required void Function(TodoResponse) onTap,
}) {
return DragTarget<String>(
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: 验证编译
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 方法(支持跨象限移动和象限内排序)
Future<void> _onDrop(String todoId, int targetQuadrant, int insertIndex) async {
final todo = _todos.firstWhere((t) => t.id == todoId);
final sourceQuadrant = todo.priority;
// 乐观更新:先保存当前状态用于回滚
final previousTodos = List<TodoResponse>.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<TodoResponse> previousTodos, String message) {
setState(() {
_todos = previousTodos;
});
if (mounted) {
Toast.show(context, message, type: ToastType.error);
}
}
- Step 2: 检查 TodoApi.updateTodo 签名
如果 updateTodo 不支持 sortOrder 参数,需要在 TodoApi 中添加:
// apps/lib/features/todo/data/todo_api.dart
Future<TodoResponse> updateTodo(
String id, {
int? priority,
int? sortOrder, // 添加此参数
String? title,
String? description,
}) async {
// 调用后端 API 更新
}
- Step 3: 验证编译和功能
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
// 在 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 插入指示器:
// items 列表构建
final widgets = <Widget>[];
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: 验证编译
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:
// 使用 SpringSimulation 的替代方案 - CurvedAnimation + bounceOut
// 跨象限移动动画 (进入时间短,退出时间长,符合 spec)
AnimatedContainer(
duration: Duration(
milliseconds: _isDragging ? 200 : 150, // 进入 150ms < 退出 200ms
),
curve: Curves.easeOutCubic,
// ...
)
对于真正的 spring 物理效果,可以在 pubspec.yaml 添加 flutter_animate 包:
dependencies:
flutter_animate: ^4.5.0
然后使用:
import 'package:flutter_animate/flutter_animate.dart';
child.animate().spring(
type: SpringType.bouncy,
duration: 300.ms,
)
- Step 2: 验证编译
flutter analyze lib/features/todo/ui/screens/todo_quadrants_screen.dart
Task 7: 集成测试
文件:
-
创建:
apps/test/features/todo/quadrant_drag_test.dart -
Step 1: 编写核心功能测试
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: 运行测试
cd apps && flutter test test/features/todo/quadrant_drag_test.dart
验收标准
- 长按待办项 150ms 后启动拖拽
- 拖拽时卡片 scale 1.03 + 阴影,原位置显示 opacity 0.5 占位
- 拖到目标象限时,象限边框高亮发光 (2px blue400)
- 目标位置显示 2px 蓝色插入指示器
- 释放后卡片以平滑动画到达新位置
- 跨象限移动后本地 UI 立即更新
- 后端同步失败时回滚本地状态并显示错误 Toast
- 编译无错误,测试通过