713 lines
20 KiB
Dart
713 lines
20 KiB
Dart
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 '../../../../app/di/injection.dart';
|
|
import '../../../../app/router/app_routes.dart';
|
|
import '../../../../app/router/home_return_policy.dart';
|
|
import '../../../../core/l10n/l10n.dart';
|
|
import '../../../../core/theme/design_tokens.dart';
|
|
import '../../../../shared/widgets/app_pull_refresh_feedback.dart';
|
|
import '../../../../shared/widgets/app_pressable.dart';
|
|
import '../../../../shared/widgets/back_title_page_header.dart';
|
|
import '../../../../shared/widgets/error_retry_surface.dart';
|
|
import '../../../../shared/widgets/full_screen_loading.dart';
|
|
import '../../../../shared/widgets/bottom_dock.dart';
|
|
import '../../../../shared/state/calendar_state_manager.dart';
|
|
import '../../../../shared/widgets/toast/toast.dart';
|
|
import '../../../../shared/widgets/toast/toast_type.dart';
|
|
import '../../data/apis/todo_api.dart';
|
|
import '../../data/repositories/todo_repository.dart';
|
|
|
|
class TodoQuadrantsScreen extends StatefulWidget {
|
|
const TodoQuadrantsScreen({super.key});
|
|
|
|
@override
|
|
State<TodoQuadrantsScreen> createState() => _TodoQuadrantsScreenState();
|
|
}
|
|
|
|
class _TodoQuadrantsScreenState extends State<TodoQuadrantsScreen> {
|
|
final TodoApi _todoApi = sl<TodoApi>();
|
|
final TodoRepository _todoRepository = sl<TodoRepository>();
|
|
|
|
List<TodoResponse> _todos = [];
|
|
bool _isLoading = true;
|
|
bool _isPullRefreshing = false;
|
|
bool _loadingTodosRequest = false;
|
|
bool _isReordering = false;
|
|
String? _error;
|
|
|
|
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 {
|
|
setState(() {
|
|
_isReordering = true;
|
|
});
|
|
|
|
final reordered = _reorderTodos(
|
|
todoId: todoId,
|
|
sourceQuadrant: sourceQuadrant,
|
|
targetQuadrant: targetQuadrant,
|
|
insertIndex: newItemIndex,
|
|
);
|
|
if (reordered == null) {
|
|
return;
|
|
}
|
|
|
|
setState(() => _todos = reordered.todos);
|
|
|
|
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(() {
|
|
_todos = previousTodos;
|
|
});
|
|
Toast.show(context, context.l10n.todoMoveFailed, type: ToastType.error);
|
|
} finally {
|
|
if (mounted) {
|
|
setState(() {
|
|
_isReordering = false;
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
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
|
|
void initState() {
|
|
super.initState();
|
|
_loadTodos();
|
|
}
|
|
|
|
Future<void> _loadTodos({bool showPageLoader = true}) async {
|
|
if (_loadingTodosRequest || _isPullRefreshing) {
|
|
return;
|
|
}
|
|
_loadingTodosRequest = true;
|
|
|
|
setState(() {
|
|
if (showPageLoader) {
|
|
_isLoading = true;
|
|
_error = null;
|
|
} else {
|
|
_isPullRefreshing = true;
|
|
}
|
|
});
|
|
|
|
try {
|
|
final todos = await _todoRepository.getPendingTodos(
|
|
forceRefresh: !showPageLoader,
|
|
);
|
|
if (!mounted) {
|
|
return;
|
|
}
|
|
setState(() {
|
|
_todos = todos;
|
|
_isLoading = false;
|
|
_isPullRefreshing = false;
|
|
_error = null;
|
|
});
|
|
} catch (e) {
|
|
if (!mounted) {
|
|
return;
|
|
}
|
|
if (showPageLoader) {
|
|
setState(() {
|
|
_error = e.toString();
|
|
_isLoading = false;
|
|
_isPullRefreshing = false;
|
|
});
|
|
} else {
|
|
setState(() => _isPullRefreshing = false);
|
|
Toast.show(
|
|
context,
|
|
context.l10n.todoRefreshFailed,
|
|
type: ToastType.error,
|
|
);
|
|
}
|
|
} finally {
|
|
_loadingTodosRequest = false;
|
|
}
|
|
}
|
|
|
|
Future<void> _onPullRefresh() async {
|
|
await _loadTodos(showPageLoader: false);
|
|
}
|
|
|
|
List<TodoResponse> get _importantUrgent => _sortedQuadrantTodos(1);
|
|
|
|
List<TodoResponse> get _urgentNotImportant => _sortedQuadrantTodos(3);
|
|
|
|
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 {
|
|
await _todoRepository.completeTodo(todo.id);
|
|
try {
|
|
await _loadTodos(showPageLoader: false);
|
|
} catch (_) {
|
|
// ignore reload error
|
|
}
|
|
} catch (e) {
|
|
if (mounted) {
|
|
Toast.show(
|
|
context,
|
|
context.l10n.todoCompleteFailed(e.toString()),
|
|
type: ToastType.error,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
Future<void> _navigateToDetail(TodoResponse todo) async {
|
|
final changed = await context.push<bool>(AppRoutes.todoDetail(todo.id));
|
|
if (changed == true) {
|
|
await _loadTodos(showPageLoader: false);
|
|
}
|
|
}
|
|
|
|
Future<void> _addTodo() async {
|
|
final created = await context.push<bool>(AppRoutes.todoCreate);
|
|
if (created == true) {
|
|
await _loadTodos(showPageLoader: false);
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final colorScheme = Theme.of(context).colorScheme;
|
|
return Scaffold(
|
|
backgroundColor: colorScheme.surface,
|
|
body: PopScope(
|
|
canPop: false,
|
|
onPopInvokedWithResult: (didPop, result) {
|
|
if (!didPop) {
|
|
returnToHomePreserveState(context, forceGoHome: true);
|
|
}
|
|
},
|
|
child: SafeArea(
|
|
child: Column(
|
|
children: [
|
|
_buildHeader(),
|
|
Expanded(child: _buildContent(withScroll: true)),
|
|
_buildBottomDock(),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildHeader() {
|
|
final colorScheme = Theme.of(context).colorScheme;
|
|
return BackTitlePageHeader(
|
|
title: context.l10n.todoScreenTitle,
|
|
showBackButton: false,
|
|
trailing: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.center,
|
|
children: [
|
|
AppPressable(
|
|
borderRadius: BorderRadius.circular(AppRadius.full),
|
|
onTap: _addTodo,
|
|
child: Container(
|
|
width: 36,
|
|
height: 36,
|
|
decoration: BoxDecoration(
|
|
color: colorScheme.primary,
|
|
borderRadius: BorderRadius.circular(AppRadius.full),
|
|
boxShadow: [
|
|
BoxShadow(
|
|
color: colorScheme.primary.withValues(alpha: 0.28),
|
|
blurRadius: AppRadius.lg,
|
|
offset: const Offset(0, AppSpacing.xs),
|
|
),
|
|
],
|
|
),
|
|
child: Icon(
|
|
LucideIcons.plus,
|
|
size: 18,
|
|
color: colorScheme.onPrimary,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildContent({bool withScroll = false}) {
|
|
if (_isLoading) {
|
|
return const FullScreenLoading();
|
|
}
|
|
|
|
if (_error != null) {
|
|
return ErrorRetrySurface(
|
|
message: context.l10n.commonLoadFailed(_error!),
|
|
onRetry: _loadTodos,
|
|
);
|
|
}
|
|
|
|
final content = _buildDragBoard();
|
|
|
|
if (withScroll) {
|
|
return Stack(
|
|
children: [
|
|
RefreshIndicator.noSpinner(onRefresh: _onPullRefresh, child: content),
|
|
Align(
|
|
alignment: Alignment.topCenter,
|
|
child: AppPullRefreshFeedback(visible: _isPullRefreshing),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
return content;
|
|
}
|
|
|
|
Widget _buildDragBoard() {
|
|
final colorScheme = Theme.of(context).colorScheme;
|
|
final palette = Theme.of(context).extension<AppColorPalette>()!;
|
|
final quadrants = [
|
|
_QuadrantMeta(
|
|
value: 1,
|
|
title: context.l10n.todoQuadrantImportantUrgent,
|
|
textColor: palette.g1Text,
|
|
dividerColor: palette.g1Divider,
|
|
borderColor: palette.g1Border,
|
|
items: _importantUrgent,
|
|
),
|
|
_QuadrantMeta(
|
|
value: 3,
|
|
title: context.l10n.todoQuadrantUrgentNotImportant,
|
|
textColor: palette.g3Text,
|
|
dividerColor: palette.g3Divider,
|
|
borderColor: palette.g3Border,
|
|
items: _urgentNotImportant,
|
|
),
|
|
_QuadrantMeta(
|
|
value: 2,
|
|
title: context.l10n.todoQuadrantImportantNotUrgent,
|
|
textColor: palette.g2Text,
|
|
dividerColor: palette.g2Divider,
|
|
borderColor: palette.g2Border,
|
|
items: _importantNotUrgent,
|
|
),
|
|
];
|
|
|
|
final lists = quadrants
|
|
.map(
|
|
(meta) => DragAndDropList(
|
|
canDrag: false,
|
|
header: _buildQuadrantHeader(meta),
|
|
contentsWhenEmpty: _buildEmptyQuadrant(),
|
|
lastTarget: const SizedBox(height: AppSpacing.lg),
|
|
decoration: BoxDecoration(
|
|
color: colorScheme.surface,
|
|
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(
|
|
key: ValueKey(item.id),
|
|
item: item,
|
|
onComplete: () => _completeTodo(item),
|
|
onTap: () => _navigateToDetail(item),
|
|
),
|
|
),
|
|
),
|
|
)
|
|
.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: colorScheme.surfaceContainerHigh),
|
|
),
|
|
listPadding: EdgeInsets.zero,
|
|
itemDecorationWhileDragging: BoxDecoration(
|
|
color: colorScheme.surface,
|
|
borderRadius: BorderRadius.circular(AppRadius.md),
|
|
border: Border.all(color: colorScheme.outlineVariant),
|
|
boxShadow: [
|
|
BoxShadow(
|
|
color: colorScheme.shadow.withValues(alpha: 0.16),
|
|
blurRadius: AppRadius.md,
|
|
offset: const Offset(0, AppSpacing.xs),
|
|
),
|
|
],
|
|
),
|
|
itemGhost: const SizedBox(height: 42),
|
|
itemDragOnLongPress: true,
|
|
lastItemTargetHeight: AppSpacing.xl,
|
|
disableScrolling: true,
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildQuadrantHeader(_QuadrantMeta meta) {
|
|
return Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
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(
|
|
context.l10n.todoItemCount(meta.items.length),
|
|
style: TextStyle(
|
|
fontFamily: 'Inter',
|
|
fontSize: 12,
|
|
fontWeight: FontWeight.w700,
|
|
color: meta.textColor,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
Container(height: 1, color: meta.dividerColor),
|
|
const SizedBox(height: AppSpacing.sm),
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _buildEmptyQuadrant() {
|
|
final colorScheme = Theme.of(context).colorScheme;
|
|
return SizedBox(
|
|
height: 60,
|
|
child: Center(
|
|
child: Text(
|
|
context.l10n.todoNoItems,
|
|
style: TextStyle(
|
|
fontFamily: 'Inter',
|
|
fontSize: 13,
|
|
color: colorScheme.outline,
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildBottomDock() {
|
|
return BottomDock(
|
|
activeTab: DockTab.todo,
|
|
onTodoTap: () {},
|
|
onCalendarTap: () {
|
|
final manager = sl<CalendarStateManager>();
|
|
final viewType = manager.viewType;
|
|
final date = manager.selectedDate;
|
|
final dateStr =
|
|
'${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}';
|
|
if (viewType == CalendarViewType.month) {
|
|
context.push(AppRoutes.calendarMonth);
|
|
} else {
|
|
context.push('${AppRoutes.calendarDayWeek}?date=$dateStr');
|
|
}
|
|
},
|
|
onHomeTap: () => returnToHomePreserveState(context, forceGoHome: true),
|
|
);
|
|
}
|
|
}
|
|
|
|
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;
|
|
final VoidCallback onTap;
|
|
|
|
const _TodoItemWidget({
|
|
super.key,
|
|
required this.item,
|
|
required this.onComplete,
|
|
required this.onTap,
|
|
});
|
|
|
|
@override
|
|
State<_TodoItemWidget> createState() => _TodoItemWidgetState();
|
|
}
|
|
|
|
class _TodoItemWidgetState extends State<_TodoItemWidget>
|
|
with SingleTickerProviderStateMixin {
|
|
bool _isChecked = false;
|
|
late AnimationController _controller;
|
|
late Animation<double> _scaleAnimation;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_controller = AnimationController(
|
|
duration: const Duration(milliseconds: 200),
|
|
vsync: this,
|
|
);
|
|
_scaleAnimation = Tween<double>(
|
|
begin: 0.0,
|
|
end: 1.0,
|
|
).animate(CurvedAnimation(parent: _controller, curve: Curves.easeOutBack));
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_controller.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
void _handleCheckTap() async {
|
|
if (_isChecked) return;
|
|
|
|
setState(() {
|
|
_isChecked = true;
|
|
});
|
|
_controller.forward().then((_) {
|
|
widget.onComplete();
|
|
});
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final colorScheme = Theme.of(context).colorScheme;
|
|
return GestureDetector(
|
|
onTap: widget.onTap,
|
|
child: SizedBox(
|
|
height: 42,
|
|
child: Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
crossAxisAlignment: CrossAxisAlignment.center,
|
|
children: [
|
|
Expanded(
|
|
child: Text(
|
|
widget.item.title,
|
|
style: TextStyle(
|
|
fontFamily: 'Inter',
|
|
fontSize: 13,
|
|
fontWeight: FontWeight.w600,
|
|
color: colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
),
|
|
GestureDetector(
|
|
onTap: _handleCheckTap,
|
|
child: AnimatedBuilder(
|
|
animation: _controller,
|
|
builder: (context, child) {
|
|
return Container(
|
|
width: 20,
|
|
height: 20,
|
|
decoration: BoxDecoration(
|
|
color: _isChecked
|
|
? colorScheme.primary
|
|
: colorScheme.surface,
|
|
border: Border.all(
|
|
color: _isChecked
|
|
? colorScheme.primary
|
|
: colorScheme.outlineVariant,
|
|
width: 1.5,
|
|
),
|
|
borderRadius: BorderRadius.circular(4),
|
|
),
|
|
child: _isChecked
|
|
? Transform.scale(
|
|
scale: _scaleAnimation.value,
|
|
child: Icon(
|
|
Icons.check,
|
|
size: 14,
|
|
color: colorScheme.onPrimary,
|
|
),
|
|
)
|
|
: null,
|
|
);
|
|
},
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|