Files
social-app/docs/superpowers/plans/2026-03-20-todo-quadrant-drag-implementation.md
T

525 lines
13 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 待办事项四象限拖拽实现计划
> **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
- [ ] 编译无错误,测试通过