import 'package:flutter/material.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 createState() => _TodoQuadrantsScreenState(); } class _TodoQuadrantsScreenState extends State { final TodoApi _todoApi = sl(); final TodoRepository _todoRepository = sl(); List _todos = []; bool _isLoading = true; bool _isPullRefreshing = false; bool _loadingTodosRequest = false; bool _isReordering = false; String? _error; Future _onItemReorder( int oldItemIndex, int oldListIndex, int newItemIndex, int newListIndex, ) async { print( 'DEBUG _onItemReorder: oldItemIndex=$oldItemIndex, oldListIndex=$oldListIndex, newItemIndex=$newItemIndex, newListIndex=$newListIndex', ); if (_isReordering) { print('DEBUG _onItemReorder: early return - _isReordering=true'); return; } final sourceQuadrant = _quadrantByListIndex(oldListIndex); final targetQuadrant = _quadrantByListIndex(newListIndex); print( 'DEBUG _onItemReorder: sourceQuadrant=$sourceQuadrant, targetQuadrant=$targetQuadrant', ); final sourceItems = _sortedQuadrantTodos(sourceQuadrant); print('DEBUG _onItemReorder: sourceItems.length=${sourceItems.length}'); if (oldItemIndex < 0 || oldItemIndex >= sourceItems.length) { print('DEBUG _onItemReorder: early return - index out of bounds'); return; } final todoId = sourceItems[oldItemIndex].id; final previousTodos = List.from(_todos); try { setState(() { _isReordering = true; }); final reordered = _reorderTodos( todoId: todoId, sourceQuadrant: sourceQuadrant, targetQuadrant: targetQuadrant, insertIndex: newItemIndex, ); print('DEBUG _onItemReorder: reordered=$reordered'); if (reordered == null) { print('DEBUG _onItemReorder: early return - reordered is 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, }) { print( 'DEBUG _reorderTodos: todoId=$todoId, sourceQuadrant=$sourceQuadrant, targetQuadrant=$targetQuadrant, insertIndex=$insertIndex', ); final byId = {for (final todo in _todos) todo.id: todo}; final moving = byId[todoId]; print('DEBUG _reorderTodos: moving=$moving'); if (moving == null) { print('DEBUG _reorderTodos: early return - moving is null'); return null; } final sourceList = _sortedQuadrantTodos(sourceQuadrant); print('DEBUG _reorderTodos: sourceList.length=${sourceList.length}'); final targetList = sourceQuadrant == targetQuadrant ? sourceList : _sortedQuadrantTodos(targetQuadrant); final sourceIndex = sourceList.indexWhere((todo) => todo.id == todoId); print('DEBUG _reorderTodos: sourceIndex=$sourceIndex'); if (sourceIndex == -1) { print('DEBUG _reorderTodos: early return - sourceIndex is -1'); return null; } final mutableSource = List.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.from(targetList); if (targetIndex > mutableTarget.length) { targetIndex = mutableTarget.length; } final moved = extracted.copyWith(priority: targetQuadrant); print( 'DEBUG _reorderTodos: moved.priority=${moved.priority}, moved.order=${moved.order}', ); mutableTarget.insert(targetIndex, moved); print('DEBUG _reorderTodos: mutableTarget.length=${mutableTarget.length}'); for (var i = 0; i < mutableTarget.length; i++) { print( 'DEBUG _reorderTodos: mutableTarget[$i] id=${mutableTarget[i].id}, priority=${mutableTarget[i].priority}, order=${mutableTarget[i].order}', ); } final updatedById = {}; void reindex(List list, int priority) { print( 'DEBUG _reorderTodos: reindex called with priority=$priority, list.length=${list.length}', ); for (var index = 0; index < list.length; index += 1) { final current = list[index]; final updated = current.copyWith(priority: priority, order: index); list[index] = updated; print( 'DEBUG _reorderTodos: reindex item id=${current.id}, current.priority=${current.priority}, updated.priority=${updated.priority}, current.order=${current.order}, updated.order=${updated.order}', ); if (current.priority != updated.priority || current.order != updated.order) { print('DEBUG _reorderTodos: adding to updatedById id=${updated.id}'); updatedById[updated.id] = updated; } } } if (sourceQuadrant == targetQuadrant) { reindex(mutableTarget, targetQuadrant); } else { reindex(mutableSource, sourceQuadrant); reindex(mutableTarget, targetQuadrant); updatedById[moved.id] = moved; } print('DEBUG _reorderTodos: updatedById.length=${updatedById.length}'); if (updatedById.isEmpty) { print('DEBUG _reorderTodos: returning null because updatedById is empty'); 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 _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 _onPullRefresh() async { await _loadTodos(showPageLoader: false); } List get _importantUrgent => _sortedQuadrantTodos(1); List get _urgentNotImportant => _sortedQuadrantTodos(3); List get _importantNotUrgent => _sortedQuadrantTodos(2); List _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 _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 _navigateToDetail(TodoResponse todo) async { final changed = await context.push(AppRoutes.todoDetail(todo.id)); if (changed == true) { await _loadTodos(showPageLoader: false); } } Future _addTodo() async { final created = await context.push(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()!; return SingleChildScrollView( padding: const EdgeInsets.fromLTRB( AppSpacing.lg, AppSpacing.xs, AppSpacing.lg, 96, ), child: Column( children: [ _buildQuadrant( value: 1, title: context.l10n.todoQuadrantImportantUrgent, textColor: palette.g1Text, dividerColor: palette.g1Divider, borderColor: palette.g1Border, items: _importantUrgent, colorScheme: colorScheme, ), const SizedBox(height: AppSpacing.md), _buildQuadrant( value: 3, title: context.l10n.todoQuadrantUrgentNotImportant, textColor: palette.g3Text, dividerColor: palette.g3Divider, borderColor: palette.g3Border, items: _urgentNotImportant, colorScheme: colorScheme, ), const SizedBox(height: AppSpacing.md), _buildQuadrant( value: 2, title: context.l10n.todoQuadrantImportantNotUrgent, textColor: palette.g2Text, dividerColor: palette.g2Divider, borderColor: palette.g2Border, items: _importantNotUrgent, colorScheme: colorScheme, ), ], ), ); } Widget _buildQuadrant({ required int value, required String title, required Color textColor, required Color dividerColor, required Color borderColor, required List items, required ColorScheme colorScheme, }) { return DragTarget<_TodoDragInfo>( onWillAcceptWithDetails: (details) => true, onAcceptWithDetails: (details) { final info = details.data; print( 'DEBUG: onAccept - sourceQuadrant=${info.sourceQuadrant}, targetQuadrant=$value, sourceIndex=${info.sourceIndex}, todoId=${info.todoId}', ); if (info.sourceQuadrant != value) { print('DEBUG: calling _onItemReorder'); _onItemReorder( info.sourceIndex, _listIndexByQuadrant(info.sourceQuadrant), 0, _listIndexByQuadrant(value), ); } else { print('DEBUG: same quadrant, no reorder'); } }, builder: (context, candidateData, rejectedData) { final isHovering = candidateData.isNotEmpty; return AnimatedContainer( duration: const Duration(milliseconds: 150), decoration: BoxDecoration( color: colorScheme.surface, borderRadius: BorderRadius.circular(14), border: Border.all( color: isHovering ? colorScheme.primary : borderColor, width: isHovering ? 2 : 1, ), ), child: Column( mainAxisSize: MainAxisSize.min, children: [ _buildQuadrantHeader( _QuadrantMeta( value: value, title: title, textColor: textColor, dividerColor: dividerColor, borderColor: borderColor, items: items, ), ), if (items.isEmpty) _buildEmptyContent(colorScheme) else ..._buildItemList(items, value, colorScheme), ], ), ); }, ); } Widget _buildDraggableItem( TodoResponse item, int sourceQuadrant, ColorScheme colorScheme, ) { final sourceIndex = _sortedQuadrantTodos( sourceQuadrant, ).indexWhere((t) => t.id == item.id); final dragInfo = _TodoDragInfo( todoId: item.id, sourceQuadrant: sourceQuadrant, sourceIndex: sourceIndex, ); return LongPressDraggable<_TodoDragInfo>( data: dragInfo, delay: const Duration(milliseconds: 150), feedback: Material( elevation: 4, borderRadius: BorderRadius.circular(AppRadius.md), child: Container( width: 280, padding: const EdgeInsets.symmetric( horizontal: AppSpacing.md, vertical: AppSpacing.sm, ), decoration: BoxDecoration( color: colorScheme.surface, borderRadius: BorderRadius.circular(AppRadius.md), ), child: Text( item.title, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 13, fontWeight: FontWeight.w600, color: colorScheme.onSurfaceVariant, ), ), ), ), childWhenDragging: Opacity( opacity: 0.3, child: _TodoItemWidget( key: ValueKey(item.id), item: item, onComplete: () => _completeTodo(item), onTap: () => _navigateToDetail(item), ), ), child: _TodoItemWidget( key: ValueKey(item.id), item: item, onComplete: () => _completeTodo(item), onTap: () => _navigateToDetail(item), ), ); } Widget _buildEmptyContent(ColorScheme colorScheme) { return SizedBox( height: 60, child: Center( child: Text( context.l10n.todoNoItems, style: TextStyle( fontFamily: 'Inter', fontSize: 13, color: colorScheme.outline, ), ), ), ); } List _buildItemList( List items, int quadrant, ColorScheme colorScheme, ) { final result = []; for (var i = 0; i < items.length; i++) { result.add(_buildDraggableItem(items[i], quadrant, colorScheme)); if (i < items.length - 1) { result.add( Padding( padding: const EdgeInsets.symmetric(horizontal: AppSpacing.md), child: Container( height: 1, color: colorScheme.surfaceContainerHigh, ), ), ); } } return result; } 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), ], ); } int _listIndexByQuadrant(int quadrant) { switch (quadrant) { case 1: return 0; case 3: return 1; case 2: return 2; default: return 0; } } Widget _buildBottomDock() { return BottomDock( activeTab: DockTab.todo, onTodoTap: () {}, onCalendarTap: () { final manager = sl(); 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 _TodoDragInfo { final String todoId; final int sourceQuadrant; final int sourceIndex; const _TodoDragInfo({ required this.todoId, required this.sourceQuadrant, required this.sourceIndex, }); } class _ReorderResult { final List todos; final List 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 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 _scaleAnimation; @override void initState() { super.initState(); _controller = AnimationController( duration: const Duration(milliseconds: 200), vsync: this, ); _scaleAnimation = Tween( 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, ); }, ), ), ], ), ), ); } }