refactor(todo): 移除 due_at 字段,改用 order 字段管理象限内顺序

This commit is contained in:
qzl
2026-03-20 11:09:38 +08:00
parent d574128815
commit fbf15bc937
22 changed files with 1458 additions and 1524 deletions
+34 -17
View File
@@ -25,13 +25,13 @@ class TodoApi {
Future<TodoResponse> createTodo({
required String title,
String? description,
DateTime? dueAt,
int priority = 1,
int? order,
List<String> scheduleItemIds = const [],
}) async {
final data = <String, dynamic>{'title': title, 'priority': priority};
if (description != null) data['description'] = description;
if (dueAt != null) data['due_at'] = dueAt.toIso8601String();
if (order != null) data['order'] = order;
if (scheduleItemIds.isNotEmpty) data['schedule_item_ids'] = scheduleItemIds;
final response = await _client.post(_prefix, data: data);
@@ -42,16 +42,16 @@ class TodoApi {
String id, {
String? title,
String? description,
DateTime? dueAt,
int? priority,
int? order,
String? status,
List<String>? scheduleItemIds,
}) async {
final data = <String, dynamic>{};
if (title != null) data['title'] = title;
if (description != null) data['description'] = description;
if (dueAt != null) data['due_at'] = dueAt.toIso8601String();
if (priority != null) data['priority'] = priority;
if (order != null) data['order'] = order;
if (status != null) data['status'] = status;
if (scheduleItemIds != null) data['schedule_item_ids'] = scheduleItemIds;
@@ -59,12 +59,19 @@ class TodoApi {
return TodoResponse.fromJson(response.data);
}
Future<void> updateTodoPriority(String id, int priority) async {
try {
await _client.patch('$_prefix/$id', data: {'priority': priority});
} catch (_) {
// Ignore response parsing errors, just need to know if request succeeded
}
Future<void> reorderTodos(List<TodoReorderItemPayload> items) async {
final data = {
'items': items
.map(
(item) => {
'id': item.id,
'priority': item.priority,
'order': item.order,
},
)
.toList(),
};
await _client.patch('$_prefix/reorder', data: data);
}
Future<TodoResponse> completeTodo(String id) async {
@@ -77,6 +84,18 @@ class TodoApi {
}
}
class TodoReorderItemPayload {
final String id;
final int priority;
final int order;
const TodoReorderItemPayload({
required this.id,
required this.priority,
required this.order,
});
}
class ScheduleItemBasic {
final String id;
final String title;
@@ -107,7 +126,7 @@ class TodoResponse {
final String ownerId;
final String title;
final String? description;
final DateTime? dueAt;
final int order;
final int priority;
final String status;
final DateTime? completedAt;
@@ -120,8 +139,8 @@ class TodoResponse {
required this.ownerId,
required this.title,
this.description,
this.dueAt,
required this.priority,
required this.order,
required this.status,
this.completedAt,
required this.createdAt,
@@ -136,10 +155,8 @@ class TodoResponse {
ownerId: json['owner_id'] as String,
title: json['title'] as String,
description: json['description'] as String?,
dueAt: json['due_at'] != null
? DateTime.parse(json['due_at'] as String)
: null,
priority: json['priority'] as int,
order: json['order'] as int,
status: json['status'] as String,
completedAt: json['completed_at'] != null
? DateTime.parse(json['completed_at'] as String)
@@ -157,8 +174,8 @@ class TodoResponse {
String? ownerId,
String? title,
String? description,
DateTime? dueAt,
int? priority,
int? order,
String? status,
DateTime? completedAt,
DateTime? createdAt,
@@ -170,8 +187,8 @@ class TodoResponse {
ownerId: ownerId ?? this.ownerId,
title: title ?? this.title,
description: description ?? this.description,
dueAt: dueAt ?? this.dueAt,
priority: priority ?? this.priority,
order: order ?? this.order,
status: status ?? this.status,
completedAt: completedAt ?? this.completedAt,
createdAt: createdAt ?? this.createdAt,
@@ -288,12 +288,7 @@ class _TodoDetailScreenState extends State<TodoDetailScreen> {
String _buildSubtitle() {
final parts = <String>[];
if (_todo!.dueAt != null) {
final due = _todo!.dueAt!;
parts.add(
'截止 ${due.month}${due.day}${due.hour.toString().padLeft(2, '0')}:${due.minute.toString().padLeft(2, '0')}',
);
}
parts.add('象限内顺序 #${_todo!.order + 1}');
if (_todo!.scheduleItems.isNotEmpty) {
parts.add('已拆分为${_todo!.scheduleItems.length}个日历事件');
} else {
@@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:drag_and_drop_lists/drag_and_drop_lists.dart';
import 'package:go_router/go_router.dart';
import 'package:lucide_icons/lucide_icons.dart';
import '../../../../core/di/injection.dart';
@@ -15,7 +16,6 @@ import '../../../../shared/widgets/toast/toast_type.dart';
import '../../../calendar/ui/calendar_state_manager.dart';
import '../../../calendar/ui/widgets/bottom_dock.dart';
import '../../data/todo_api.dart';
import '../widgets/todo_drag_item.dart';
class TodoQuadrantsScreen extends StatefulWidget {
const TodoQuadrantsScreen({super.key});
@@ -31,65 +31,56 @@ class _TodoQuadrantsScreenState extends State<TodoQuadrantsScreen> {
bool _isLoading = true;
bool _isPullRefreshing = false;
bool _loadingTodosRequest = false;
bool _isReordering = false;
String? _error;
String? _draggingTodoId;
int? _dragTargetQuadrant;
int? _dragInsertIndex;
bool get _isDragging => _draggingTodoId != null;
void _onDragStart(String todoId) {
setState(() {
_draggingTodoId = todoId;
_dragTargetQuadrant = null;
_dragInsertIndex = null;
});
}
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,
Future<void> _onItemReorder(
int oldItemIndex,
int oldListIndex,
int newItemIndex,
int newListIndex,
) async {
if (_isReordering) {
return;
}
final sourceQuadrant = _quadrantByListIndex(oldListIndex);
final targetQuadrant = _quadrantByListIndex(newListIndex);
final sourceItems = _sortedQuadrantTodos(sourceQuadrant);
if (oldItemIndex < 0 || oldItemIndex >= sourceItems.length) {
return;
}
final todoId = sourceItems[oldItemIndex].id;
final previousTodos = List<TodoResponse>.from(_todos);
try {
final todo = _todos.firstWhere((t) => t.id == todoId);
final sourceQuadrant = todo.priority;
setState(() {
_isReordering = true;
});
if (sourceQuadrant == targetQuadrant) {
_onDragEnd();
final reordered = _reorderTodos(
todoId: todoId,
sourceQuadrant: sourceQuadrant,
targetQuadrant: targetQuadrant,
insertIndex: newItemIndex,
);
if (reordered == null) {
return;
}
setState(() {
final index = _todos.indexWhere((t) => t.id == todoId);
if (index != -1) {
_todos[index] = _todos[index].copyWith(priority: targetQuadrant);
}
});
setState(() => _todos = reordered.todos);
await _todoApi.updateTodoPriority(todoId, targetQuadrant);
await _todoApi.reorderTodos(
reordered.changedTodos
.map(
(updated) => TodoReorderItemPayload(
id: updated.id,
priority: updated.priority,
order: updated.order,
),
)
.toList(growable: false),
);
} catch (e) {
if (!mounted) return;
setState(() {
@@ -97,12 +88,104 @@ class _TodoQuadrantsScreenState extends State<TodoQuadrantsScreen> {
});
Toast.show(context, '移动失败', type: ToastType.error);
} finally {
if (mounted) _onDragEnd();
if (mounted) {
setState(() {
_isReordering = false;
});
}
}
}
void _onDragLeave() {
// 清除高亮
int _quadrantByListIndex(int listIndex) {
switch (listIndex) {
case 0:
return 1;
case 1:
return 3;
case 2:
return 2;
default:
return 1;
}
}
_ReorderResult? _reorderTodos({
required String todoId,
required int sourceQuadrant,
required int targetQuadrant,
required int insertIndex,
}) {
final byId = {for (final todo in _todos) todo.id: todo};
final moving = byId[todoId];
if (moving == null) {
return null;
}
final sourceList = _sortedQuadrantTodos(sourceQuadrant);
final targetList = sourceQuadrant == targetQuadrant
? sourceList
: _sortedQuadrantTodos(targetQuadrant);
final sourceIndex = sourceList.indexWhere((todo) => todo.id == todoId);
if (sourceIndex == -1) {
return null;
}
final mutableSource = List<TodoResponse>.from(sourceList);
final extracted = mutableSource.removeAt(sourceIndex);
int targetIndex = insertIndex;
if (sourceQuadrant == targetQuadrant && sourceIndex < targetIndex) {
targetIndex -= 1;
}
if (targetIndex < 0) {
targetIndex = 0;
}
final mutableTarget = sourceQuadrant == targetQuadrant
? mutableSource
: List<TodoResponse>.from(targetList);
if (targetIndex > mutableTarget.length) {
targetIndex = mutableTarget.length;
}
final moved = extracted.copyWith(priority: targetQuadrant);
mutableTarget.insert(targetIndex, moved);
final updatedById = <String, TodoResponse>{};
void reindex(List<TodoResponse> list, int priority) {
for (var index = 0; index < list.length; index += 1) {
final current = list[index];
final updated = current.copyWith(priority: priority, order: index);
list[index] = updated;
if (current.priority != updated.priority ||
current.order != updated.order) {
updatedById[updated.id] = updated;
}
}
}
if (sourceQuadrant == targetQuadrant) {
reindex(mutableTarget, targetQuadrant);
} else {
reindex(mutableSource, sourceQuadrant);
reindex(mutableTarget, targetQuadrant);
}
if (updatedById.isEmpty) {
return null;
}
final updatedTodos = _todos
.map((todo) => updatedById[todo.id] ?? todo)
.toList(growable: false);
return _ReorderResult(
todos: updatedTodos,
changedTodos: updatedById.values.toList(growable: false),
);
}
@override
@@ -160,14 +243,23 @@ class _TodoQuadrantsScreenState extends State<TodoQuadrantsScreen> {
await _loadTodos(showPageLoader: false);
}
List<TodoResponse> get _importantUrgent =>
_todos.where((t) => t.priority == 1).toList();
List<TodoResponse> get _importantUrgent => _sortedQuadrantTodos(1);
List<TodoResponse> get _urgentNotImportant =>
_todos.where((t) => t.priority == 3).toList();
List<TodoResponse> get _urgentNotImportant => _sortedQuadrantTodos(3);
List<TodoResponse> get _importantNotUrgent =>
_todos.where((t) => t.priority == 2).toList();
List<TodoResponse> get _importantNotUrgent => _sortedQuadrantTodos(2);
List<TodoResponse> _sortedQuadrantTodos(int quadrantValue) {
final list = _todos.where((t) => t.priority == quadrantValue).toList();
list.sort((a, b) {
final byOrder = a.order.compareTo(b.order);
if (byOrder != 0) {
return byOrder;
}
return a.createdAt.compareTo(b.createdAt);
});
return list;
}
Future<void> _completeTodo(TodoResponse todo) async {
try {
@@ -287,59 +379,12 @@ class _TodoQuadrantsScreenState extends State<TodoQuadrantsScreen> {
return ErrorRetrySurface(message: '加载失败: $_error', onRetry: _loadTodos);
}
Widget content = Column(
mainAxisSize: MainAxisSize.min,
children: [
_buildQuadrant(
title: '重要紧急',
textColor: AppColors.g1Text,
dividerColor: AppColors.g1Divider,
borderColor: AppColors.g1Border,
items: _importantUrgent,
quadrantValue: 1,
onComplete: _completeTodo,
onTap: _navigateToDetail,
),
const SizedBox(height: 12),
_buildQuadrant(
title: '紧急不重要',
textColor: AppColors.g2Text,
dividerColor: AppColors.g2Divider,
borderColor: AppColors.g2Border,
items: _urgentNotImportant,
quadrantValue: 3,
onComplete: _completeTodo,
onTap: _navigateToDetail,
),
const SizedBox(height: 12),
_buildQuadrant(
title: '重要不紧急',
textColor: AppColors.g3Text,
dividerColor: AppColors.g3Divider,
borderColor: AppColors.g3Border,
items: _importantNotUrgent,
quadrantValue: 2,
onComplete: _completeTodo,
onTap: _navigateToDetail,
),
],
);
final content = _buildDragBoard();
if (withScroll) {
return Stack(
children: [
RefreshIndicator.noSpinner(
onRefresh: _onPullRefresh,
child: SingleChildScrollView(
padding: const EdgeInsets.only(
left: 16,
right: 16,
top: 4,
bottom: 96,
),
child: content,
),
),
RefreshIndicator.noSpinner(onRefresh: _onPullRefresh, child: content),
Align(
alignment: Alignment.topCenter,
child: AppPullRefreshFeedback(visible: _isPullRefreshing),
@@ -348,141 +393,157 @@ class _TodoQuadrantsScreenState extends State<TodoQuadrantsScreen> {
);
}
return Padding(
padding: const EdgeInsets.fromLTRB(16, 4, 16, 16),
child: content,
);
return content;
}
Widget _buildQuadrant({
required String title,
required Color textColor,
required Color dividerColor,
required Color borderColor,
required List<TodoResponse> items,
required int quadrantValue,
required Future<void> Function(TodoResponse) onComplete,
required void Function(TodoResponse) onTap,
}) {
return Container(
decoration: BoxDecoration(
color: AppColors.todoCardBg,
borderRadius: BorderRadius.circular(14),
border: Border.all(color: borderColor, width: 1),
Widget _buildDragBoard() {
final quadrants = [
_QuadrantMeta(
value: 1,
title: '重要紧急',
textColor: AppColors.g1Text,
dividerColor: AppColors.g1Divider,
borderColor: AppColors.g1Border,
items: _importantUrgent,
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildQuadrantHeader(title, textColor, items.length),
Container(height: 1, color: dividerColor),
const SizedBox(height: 8),
Padding(
padding: const EdgeInsets.fromLTRB(6, 0, 6, 8),
child: DragTarget<String>(
onWillAcceptWithDetails: (details) {
_onDragEnterQuadrant(quadrantValue);
return true;
},
onAcceptWithDetails: (details) {
final parts = details.data.split(':');
final todoId = parts[0];
_onDrop(todoId, quadrantValue, 0);
},
onLeave: (_) {
_onDragLeave();
},
builder: (context, candidateData, rejectedData) {
final isDragOver = candidateData.isNotEmpty;
return Container(
decoration: BoxDecoration(
color: isDragOver
? AppColors.blue50.withValues(alpha: 0.3)
: Colors.transparent,
borderRadius: BorderRadius.circular(8),
border: isDragOver
? Border.all(color: AppColors.blue400, width: 2)
: null,
_QuadrantMeta(
value: 3,
title: '紧急不重要',
textColor: AppColors.g2Text,
dividerColor: AppColors.g2Divider,
borderColor: AppColors.g2Border,
items: _urgentNotImportant,
),
_QuadrantMeta(
value: 2,
title: '重要不紧急',
textColor: AppColors.g3Text,
dividerColor: AppColors.g3Divider,
borderColor: AppColors.g3Border,
items: _importantNotUrgent,
),
];
final lists = quadrants
.map(
(meta) => DragAndDropList(
canDrag: false,
header: _buildQuadrantHeader(meta),
contentsWhenEmpty: _buildEmptyQuadrant(),
lastTarget: const SizedBox(height: AppSpacing.lg),
decoration: BoxDecoration(
color: AppColors.todoCardBg,
borderRadius: BorderRadius.circular(14),
border: Border.all(color: meta.borderColor),
),
children: meta.items
.map(
(item) => DragAndDropItem(
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.sm,
),
child: _TodoItemWidget(
item: item,
onComplete: () => _completeTodo(item),
onTap: () => _navigateToDetail(item),
),
),
),
child: items.isEmpty
? SizedBox(
height: 60,
child: Center(
child: Text(
'暂无待办',
style: TextStyle(
fontFamily: 'Inter',
fontSize: 13,
color: AppColors.slate400,
),
),
),
)
: _buildQuadrantItemList(
items,
quadrantValue,
onComplete,
onTap,
),
);
},
),
)
.toList(growable: false),
),
],
)
.toList(growable: false);
return SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(
AppSpacing.lg,
AppSpacing.xs,
AppSpacing.lg,
96,
),
child: DragAndDropLists(
children: lists,
onItemReorder: _onItemReorder,
onListReorder: (oldListIndex, newListIndex) {},
listDivider: const SizedBox(height: AppSpacing.md),
itemDivider: Padding(
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.md),
child: Container(height: 1, color: AppColors.slate100),
),
listPadding: EdgeInsets.zero,
itemDecorationWhileDragging: BoxDecoration(
color: AppColors.todoCardBg,
borderRadius: BorderRadius.circular(AppRadius.md),
border: Border.all(color: AppColors.borderSecondary),
boxShadow: [
BoxShadow(
color: AppColors.slate200.withValues(alpha: 0.6),
blurRadius: AppRadius.md,
offset: const Offset(0, AppSpacing.xs),
),
],
),
itemGhost: const SizedBox(height: 42),
itemDragOnLongPress: true,
lastItemTargetHeight: AppSpacing.xl,
disableScrolling: true,
),
);
}
Widget _buildQuadrantHeader(String title, Color textColor, int itemCount) {
return Padding(
padding: const EdgeInsets.fromLTRB(10, 10, 10, 8),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
title,
style: TextStyle(
fontFamily: 'Inter',
fontSize: 15,
fontWeight: FontWeight.w700,
color: textColor,
),
),
Text(
'${itemCount}',
style: TextStyle(
fontFamily: 'Inter',
fontSize: 12,
fontWeight: FontWeight.w700,
color: textColor,
),
),
],
),
);
}
Widget _buildQuadrantItemList(
List<TodoResponse> items,
int quadrantValue,
Future<void> Function(TodoResponse) onComplete,
void Function(TodoResponse) onTap,
) {
Widget _buildQuadrantHeader(_QuadrantMeta meta) {
return Column(
mainAxisSize: MainAxisSize.min,
children: items.map((item) {
return TodoDragItem(
todo: item,
quadrant: quadrantValue,
onDragStarted: () => _onDragStart(item.id),
onDragEnd: _onDragEnd,
child: _TodoItemWidget(
item: item,
onComplete: () => onComplete(item),
onTap: () => onTap(item),
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(10, 10, 10, 8),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Text(
meta.title,
style: TextStyle(
fontFamily: 'Inter',
fontSize: 15,
fontWeight: FontWeight.w700,
color: meta.textColor,
),
),
Text(
'${meta.items.length}',
style: TextStyle(
fontFamily: 'Inter',
fontSize: 12,
fontWeight: FontWeight.w700,
color: meta.textColor,
),
),
],
),
);
}).toList(),
),
Container(height: 1, color: meta.dividerColor),
const SizedBox(height: AppSpacing.sm),
],
);
}
Widget _buildEmptyQuadrant() {
return SizedBox(
height: 60,
child: Center(
child: Text(
'暂无待办',
style: TextStyle(
fontFamily: 'Inter',
fontSize: 13,
color: AppColors.slate400,
),
),
),
);
}
@@ -507,6 +568,31 @@ class _TodoQuadrantsScreenState extends State<TodoQuadrantsScreen> {
}
}
class _ReorderResult {
final List<TodoResponse> todos;
final List<TodoResponse> changedTodos;
const _ReorderResult({required this.todos, required this.changedTodos});
}
class _QuadrantMeta {
final int value;
final String title;
final Color textColor;
final Color dividerColor;
final Color borderColor;
final List<TodoResponse> items;
const _QuadrantMeta({
required this.value,
required this.title,
required this.textColor,
required this.dividerColor,
required this.borderColor,
required this.items,
});
}
class _TodoItemWidget extends StatefulWidget {
final TodoResponse item;
final VoidCallback onComplete;
@@ -5,6 +5,7 @@ import 'package:social_app/features/todo/data/todo_api.dart';
class TodoDragItem extends StatelessWidget {
final TodoResponse todo;
final int quadrant;
final int sourceIndex;
final VoidCallback onDragStarted;
final VoidCallback onDragEnd;
final Widget child;
@@ -13,6 +14,7 @@ class TodoDragItem extends StatelessWidget {
super.key,
required this.todo,
required this.quadrant,
required this.sourceIndex,
required this.onDragStarted,
required this.onDragEnd,
required this.child,
@@ -21,7 +23,7 @@ class TodoDragItem extends StatelessWidget {
@override
Widget build(BuildContext context) {
return LongPressDraggable<String>(
data: '${todo.id}:$quadrant',
data: '${todo.id}:$quadrant:$sourceIndex',
delay: const Duration(milliseconds: 150),
feedback: Material(
elevation: 8,