Files
social-app/apps/lib/features/todo/presentation/screens/todo_quadrants_screen.dart
T

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,
);
},
),
),
],
),
),
);
}
}