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

13 KiB
Raw Blame History

待办事项四象限拖拽实现计划

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
  • 编译无错误,测试通过