525 lines
13 KiB
Markdown
525 lines
13 KiB
Markdown
# 待办事项四象限拖拽实现计划
|
||
|
||
> **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<TodoResponse> 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<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 时传入:
|
||
|
||
```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<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: 验证编译**
|
||
|
||
```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<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: 验证编译**
|
||
|
||
```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<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` 中添加:
|
||
|
||
```dart
|
||
// 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: 验证编译和功能**
|
||
|
||
```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 = <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: 验证编译**
|
||
|
||
```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
|
||
- [ ] 编译无错误,测试通过
|