feat(apps): 重构 UI 架构为 presentation 层并新增 l10n 国际化支持

This commit is contained in:
qzl
2026-03-27 14:05:03 +08:00
parent b1f0eb8921
commit c592cc7854
178 changed files with 10748 additions and 5764 deletions
@@ -0,0 +1,424 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:intl/intl.dart';
import 'package:lucide_icons/lucide_icons.dart';
import '../../../../app/di/injection.dart';
import '../../../../app/router/app_routes.dart';
import '../../../../core/l10n/l10n.dart';
import '../../../../core/theme/design_tokens.dart';
import '../../../../shared/widgets/back_title_page_header.dart';
import '../../../../shared/widgets/detail_header_action_menu.dart';
import '../../../../shared/widgets/destructive_action_sheet.dart';
import '../../../../shared/widgets/error_retry_surface.dart';
import '../../../../shared/widgets/full_screen_loading.dart';
import '../../../../shared/widgets/toast/toast.dart';
import '../../../../shared/widgets/toast/toast_type.dart';
import '../../data/todo_api.dart';
enum _TodoHeaderAction { edit, delete }
class TodoDetailScreen extends StatefulWidget {
final String todoId;
const TodoDetailScreen({super.key, required this.todoId});
@override
State<TodoDetailScreen> createState() => _TodoDetailScreenState();
}
class _TodoDetailScreenState extends State<TodoDetailScreen> {
final TodoApi _todoApi = sl<TodoApi>();
TodoResponse? _todo;
bool _isLoading = true;
bool _didMutate = false;
String? _error;
@override
void initState() {
super.initState();
_loadTodo();
}
Future<void> _loadTodo() async {
if (!mounted) {
return;
}
setState(() {
_isLoading = true;
_error = null;
});
try {
final todo = await _todoApi.getTodo(widget.todoId);
if (!mounted) {
return;
}
setState(() {
_todo = todo;
_isLoading = false;
});
} catch (e) {
if (!mounted) {
return;
}
setState(() {
_error = e.toString();
_isLoading = false;
});
}
}
String _getPriorityLabel(int priority) {
switch (priority) {
case 1:
return context.l10n.todoQuadrantImportantUrgent;
case 2:
return context.l10n.todoQuadrantImportantNotUrgent;
case 3:
return context.l10n.todoQuadrantUrgentNotImportant;
case 4:
return context.l10n.todoQuadrantNotUrgentNotImportant;
default:
return context.l10n.commonUnknown;
}
}
Color _getPriorityColor(int priority) {
switch (priority) {
case 1:
return AppColors.g1Text;
case 2:
return AppColors.g3Text;
case 3:
return AppColors.g2Text;
default:
return AppColors.slate500;
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: AppColors.todoBg,
body: SafeArea(
child: Container(
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [AppColors.homeBackgroundTop, AppColors.todoBg],
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildHeader(),
Expanded(child: _buildContent()),
],
),
),
),
);
}
Widget _buildHeader() {
return BackTitlePageHeader(
title: context.l10n.todoDetailTitle,
onBack: () => context.pop(_didMutate),
trailing: _buildHeaderMenu(),
);
}
Widget? _buildHeaderMenu() {
if (_todo == null) {
return null;
}
return DetailHeaderActionMenu<_TodoHeaderAction>(
items: [
DetailHeaderActionItem<_TodoHeaderAction>(
value: _TodoHeaderAction.edit,
label: context.l10n.commonEdit,
icon: LucideIcons.pencil,
),
DetailHeaderActionItem<_TodoHeaderAction>(
value: _TodoHeaderAction.delete,
label: context.l10n.commonDelete,
icon: LucideIcons.trash2,
isDestructive: true,
),
],
onSelected: _handleHeaderAction,
);
}
void _handleHeaderAction(_TodoHeaderAction action) {
switch (action) {
case _TodoHeaderAction.edit:
_editTodo();
return;
case _TodoHeaderAction.delete:
_deleteTodo();
return;
}
}
Widget _buildContent() {
if (_isLoading) {
return const FullScreenLoading();
}
if (_error != null) {
return ErrorRetrySurface(
message: context.l10n.commonLoadFailed(_error!),
onRetry: _loadTodo,
);
}
if (_todo == null) {
return Center(child: Text(context.l10n.todoNotFound));
}
return Padding(
padding: const EdgeInsets.only(left: 16, right: 16, top: 4, bottom: 20),
child: ListView(
children: [
_buildMainCard(),
const SizedBox(height: 12),
if (_todo!.scheduleItems.isNotEmpty) ...[
Text(
context.l10n.todoCalendarEventCards,
style: TextStyle(
fontFamily: 'Inter',
fontSize: 12,
fontWeight: FontWeight.w500,
color: AppColors.slate500,
),
),
const SizedBox(height: 10),
..._todo!.scheduleItems.map(
(item) => _buildEventCard(
id: item.id,
title: item.title,
time: _formatEventTime(item.startAt, item.endAt),
borderColor: AppColors.todoEventBorder1,
onTap: () =>
context.push(AppRoutes.calendarEventDetail(item.id)),
),
),
],
],
),
);
}
String _formatEventTime(DateTime start, DateTime? end) {
final startStr = DateFormat.yMd(
context.l10n.localeName,
).add_Hm().format(start);
if (end != null) {
final endStr =
'${end.hour.toString().padLeft(2, '0')}:${end.minute.toString().padLeft(2, '0')}';
return '$startStr - $endStr';
}
return startStr;
}
Widget _buildMainCard() {
return Container(
width: double.infinity,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: AppColors.todoCardBg,
borderRadius: BorderRadius.circular(14),
border: Border.all(color: AppColors.todoDetailCardBorder, width: 1),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
_todo!.title,
style: const TextStyle(
fontFamily: 'Inter',
fontSize: 18,
fontWeight: FontWeight.w700,
color: AppColors.slate900,
),
),
const SizedBox(height: 4),
Text(
_buildSubtitle(),
style: const TextStyle(
fontFamily: 'Inter',
fontSize: 12,
fontWeight: FontWeight.w500,
color: AppColors.slate500,
),
),
if (_todo!.description != null && _todo!.description!.isNotEmpty) ...[
const SizedBox(height: 8),
Text(
_todo!.description!,
style: const TextStyle(
fontFamily: 'Inter',
fontSize: 13,
color: AppColors.slate600,
),
),
],
const SizedBox(height: 8),
Container(height: 1, color: AppColors.border),
const SizedBox(height: 8),
_buildInfoRow(
label: context.l10n.todoPriorityQuadrant,
value: _getPriorityLabel(_todo!.priority),
valueColor: _getPriorityColor(_todo!.priority),
),
const SizedBox(height: 8),
_buildInfoRow(
label: context.l10n.todoLinkedCalendarEvents,
value: context.l10n.todoItemCount(_todo!.scheduleItems.length),
valueColor: AppColors.g3Text,
),
const SizedBox(height: 8),
_buildInfoRow(
label: context.l10n.todoStatus,
value: _todo!.status == 'done'
? context.l10n.todoStatusDone
: context.l10n.todoStatusInProgress,
valueColor: _todo!.status == 'done'
? AppColors.success
: AppColors.blue600,
),
],
),
);
}
String _buildSubtitle() {
final parts = <String>[];
parts.add(context.l10n.todoQuadrantOrder(_todo!.order + 1));
if (_todo!.scheduleItems.isNotEmpty) {
parts.add(context.l10n.todoSplitToEvents(_todo!.scheduleItems.length));
} else {
parts.add(context.l10n.todoNoLinkedEvents);
}
return parts.join(' · ');
}
Widget _buildInfoRow({
required String label,
required String value,
required Color valueColor,
}) {
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
label,
style: const TextStyle(
fontFamily: 'Inter',
fontSize: 12,
fontWeight: FontWeight.w600,
color: AppColors.slate400,
),
),
Text(
value,
style: TextStyle(
fontFamily: 'Inter',
fontSize: 12,
fontWeight: FontWeight.w700,
color: valueColor,
),
),
],
);
}
Widget _buildEventCard({
required String id,
required String title,
required String time,
required Color borderColor,
VoidCallback? onTap,
}) {
return GestureDetector(
onTap: onTap,
child: Container(
width: double.infinity,
padding: const EdgeInsets.all(10),
margin: const EdgeInsets.only(bottom: 10),
decoration: BoxDecoration(
color: AppColors.todoCardBg,
borderRadius: BorderRadius.circular(14),
border: Border.all(color: borderColor, width: 1),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: const TextStyle(
fontFamily: 'Inter',
fontSize: 13,
fontWeight: FontWeight.w600,
color: AppColors.slate700,
),
),
const SizedBox(height: 8),
Text(
time,
style: const TextStyle(
fontFamily: 'Inter',
fontSize: 12,
fontWeight: FontWeight.w500,
color: AppColors.slate500,
),
),
],
),
),
);
}
void _editTodo() async {
if (_todo == null) {
return;
}
final changed = await context.push<bool>(AppRoutes.todoEdit(_todo!.id));
if (changed == true) {
_didMutate = true;
if (!mounted) {
return;
}
context.pop(true);
}
}
void _deleteTodo() async {
final confirm = await showDestructiveActionSheet(
context,
title: context.l10n.todoDeleteTitle,
message: context.l10n.todoDeleteMessage,
confirmText: context.l10n.todoDeleteConfirm,
);
if (confirm == true) {
try {
await _todoApi.deleteTodo(_todo!.id);
if (mounted) {
context.pop(true);
}
} catch (e) {
if (mounted) {
Toast.show(
context,
context.l10n.todoDeleteFailed(e.toString()),
type: ToastType.error,
);
}
}
}
}
}
@@ -0,0 +1,593 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:intl/intl.dart';
import '../../../../app/di/injection.dart';
import '../../../../core/l10n/l10n.dart';
import '../../../../core/theme/design_tokens.dart';
import '../../../../shared/widgets/app_button.dart';
import '../../../../shared/widgets/app_pressable.dart';
import '../../../../shared/widgets/app_sheet_input_field.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/toast/toast.dart';
import '../../../../shared/widgets/toast/toast_type.dart';
import '../../../calendar/data/calendar_api.dart';
import '../../../calendar/data/models/schedule_item_model.dart';
import '../../data/todo_api.dart';
class TodoEditScreen extends StatefulWidget {
final String? todoId;
const TodoEditScreen({super.key, required this.todoId});
const TodoEditScreen.create({super.key}) : todoId = null;
bool get isCreateMode => todoId == null;
@override
State<TodoEditScreen> createState() => _TodoEditScreenState();
}
class _TodoEditScreenState extends State<TodoEditScreen> {
final TodoApi _todoApi = sl<TodoApi>();
final CalendarApi _calendarApi = sl<CalendarApi>();
final TextEditingController _titleController = TextEditingController();
final TextEditingController _descriptionController = TextEditingController();
TodoResponse? _todo;
bool _loading = true;
bool _saving = false;
String? _error;
int _priority = 1;
final Set<String> _selectedScheduleItemIds = <String>{};
List<_ScheduleItemSimple> _scheduleItems = const <_ScheduleItemSimple>[];
@override
void initState() {
super.initState();
_loadPage();
}
@override
void dispose() {
_titleController.dispose();
_descriptionController.dispose();
super.dispose();
}
Future<void> _loadPage() async {
setState(() {
_loading = true;
_error = null;
});
try {
final now = DateTime.now();
final start = now.subtract(const Duration(days: 30));
final end = now.add(const Duration(days: 90));
final scheduleItems = await _calendarApi.listByRange(
startAt: start,
endAt: end,
);
TodoResponse? todo;
if (!widget.isCreateMode) {
todo = await _todoApi.getTodo(widget.todoId!);
}
if (!mounted) {
return;
}
_todo = todo;
_titleController.text = todo?.title ?? '';
_descriptionController.text = todo?.description ?? '';
_priority = todo?.priority ?? 1;
_selectedScheduleItemIds
..clear()
..addAll(todo?.scheduleItems.map((item) => item.id) ?? const []);
_scheduleItems = scheduleItems
.where((item) => item.status == ScheduleStatus.active)
.map(
(item) => _ScheduleItemSimple(
id: item.id,
title: item.title,
startAt: item.startAt,
),
)
.toList();
setState(() {
_loading = false;
});
} catch (error) {
if (!mounted) {
return;
}
setState(() {
_loading = false;
_error = error.toString();
});
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: AppColors.todoBg,
resizeToAvoidBottomInset: false,
body: SafeArea(
child: GestureDetector(
behavior: HitTestBehavior.translucent,
onTap: () => FocusScope.of(context).unfocus(),
child: Container(
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [AppColors.homeBackgroundTop, AppColors.todoBg],
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
BackTitlePageHeader(
title: widget.isCreateMode
? context.l10n.todoCreateTitle
: context.l10n.todoEditTitle,
),
Expanded(child: _buildBody()),
_buildBottomAction(),
],
),
),
),
),
);
}
Widget _buildBody() {
if (_loading) {
return const FullScreenLoading();
}
if (_error != null) {
return ErrorRetrySurface(
message: context.l10n.commonLoadFailed(_error!),
onRetry: _loadPage,
);
}
if (!widget.isCreateMode && _todo == null) {
return Center(child: Text(context.l10n.todoNotFound));
}
return ListView(
padding: const EdgeInsets.fromLTRB(
AppSpacing.lg,
AppSpacing.sm,
AppSpacing.lg,
AppSpacing.lg,
),
children: [
_buildHeaderCard(),
const SizedBox(height: AppSpacing.md),
_buildFormCard(),
const SizedBox(height: AppSpacing.md),
_buildScheduleCard(),
],
);
}
Widget _buildHeaderCard() {
return Container(
padding: const EdgeInsets.all(AppSpacing.lg),
decoration: BoxDecoration(
color: AppColors.white,
borderRadius: BorderRadius.circular(AppRadius.xl),
border: Border.all(color: AppColors.borderTertiary),
boxShadow: [
BoxShadow(
color: AppColors.slate200.withValues(alpha: 0.34),
blurRadius: AppRadius.lg,
offset: const Offset(0, AppSpacing.xs),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
context.l10n.todoInfoTitle,
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w700,
color: AppColors.slate900,
),
),
const SizedBox(height: AppSpacing.xs),
Text(
widget.isCreateMode
? context.l10n.todoInfoDescCreate
: _todo?.status == 'done'
? context.l10n.todoInfoDescDone
: context.l10n.todoInfoDescDefault,
style: const TextStyle(fontSize: 13, color: AppColors.slate500),
),
],
),
);
}
Widget _buildFormCard() {
return Container(
padding: const EdgeInsets.all(AppSpacing.lg),
decoration: BoxDecoration(
color: AppColors.white,
borderRadius: BorderRadius.circular(AppRadius.xl),
border: Border.all(color: AppColors.borderSecondary),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
AppSheetInputField(
controller: _titleController,
label: context.l10n.todoFieldTitle,
hint: context.l10n.todoFieldTitleHint,
),
const SizedBox(height: AppSpacing.lg),
AppSheetInputField(
controller: _descriptionController,
label: context.l10n.todoFieldDescriptionOptional,
hint: context.l10n.todoFieldDescriptionHint,
maxLines: 2,
),
const SizedBox(height: AppSpacing.lg),
Text(
context.l10n.todoPriority,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: AppColors.slate700,
),
),
const SizedBox(height: AppSpacing.sm),
Wrap(
spacing: AppSpacing.sm,
runSpacing: AppSpacing.sm,
children: [
_PriorityPill(
label: context.l10n.todoQuadrantImportantUrgent,
selected: _priority == 1,
borderColor: AppColors.g1Border,
activeColor: AppColors.g1Text,
onTap: () => setState(() => _priority = 1),
),
_PriorityPill(
label: context.l10n.todoQuadrantUrgentNotImportant,
selected: _priority == 3,
borderColor: AppColors.g2Border,
activeColor: AppColors.g2Text,
onTap: () => setState(() => _priority = 3),
),
_PriorityPill(
label: context.l10n.todoQuadrantImportantNotUrgent,
selected: _priority == 2,
borderColor: AppColors.g3Border,
activeColor: AppColors.g3Text,
onTap: () => setState(() => _priority = 2),
),
],
),
],
),
);
}
Widget _buildScheduleCard() {
return Container(
padding: const EdgeInsets.all(AppSpacing.lg),
decoration: BoxDecoration(
color: AppColors.surfaceSecondary,
borderRadius: BorderRadius.circular(AppRadius.xl),
border: Border.all(color: AppColors.borderSecondary),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Text(
context.l10n.todoLinkedCalendarEvents,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: AppColors.slate700,
),
),
const Spacer(),
Text(
context.l10n.todoItemCount(_selectedScheduleItemIds.length),
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: AppColors.slate500,
),
),
],
),
const SizedBox(height: AppSpacing.sm),
if (_scheduleItems.isEmpty)
Padding(
padding: EdgeInsets.symmetric(vertical: AppSpacing.xl),
child: Center(
child: Text(
context.l10n.todoNoSelectableCalendarEvents,
style: const TextStyle(color: AppColors.slate500),
),
),
)
else
ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 280),
child: ListView.separated(
shrinkWrap: true,
itemCount: _scheduleItems.length,
separatorBuilder: (context, index) =>
const SizedBox(height: AppSpacing.sm),
itemBuilder: (context, index) {
final item = _scheduleItems[index];
final selected = _selectedScheduleItemIds.contains(item.id);
return _ScheduleSelectableTile(
title: item.title,
subtitle: _formatDate(item.startAt),
selected: selected,
onTap: () {
setState(() {
if (selected) {
_selectedScheduleItemIds.remove(item.id);
} else {
_selectedScheduleItemIds.add(item.id);
}
});
},
);
},
),
),
],
),
);
}
Widget _buildBottomAction() {
final canSave = !_loading && !_saving;
return Container(
padding: const EdgeInsets.fromLTRB(
AppSpacing.lg,
AppSpacing.sm,
AppSpacing.lg,
AppSpacing.lg,
),
decoration: BoxDecoration(
color: AppColors.white.withValues(alpha: 0.9),
border: const Border(top: BorderSide(color: AppColors.borderSecondary)),
),
child: AppButton(
text: _saving
? context.l10n.todoSaveInProgress
: (widget.isCreateMode
? context.l10n.todoCreateButton
: context.l10n.todoSaveChanges),
onPressed: canSave ? _save : null,
),
);
}
Future<void> _save() async {
if (_saving) {
return;
}
final title = _titleController.text.trim();
if (title.isEmpty) {
Toast.show(context, context.l10n.todoEnterTitle, type: ToastType.warning);
return;
}
setState(() {
_saving = true;
});
try {
final description = _descriptionController.text.trim().isEmpty
? null
: _descriptionController.text.trim();
if (widget.isCreateMode) {
await _todoApi.createTodo(
title: title,
description: description,
priority: _priority,
scheduleItemIds: _selectedScheduleItemIds.toList(),
);
} else {
await _todoApi.updateTodo(
widget.todoId!,
title: title,
description: description,
priority: _priority,
scheduleItemIds: _selectedScheduleItemIds.toList(),
);
}
if (!mounted) {
return;
}
context.pop(true);
} catch (error) {
if (!mounted) {
return;
}
Toast.show(
context,
context.l10n.todoSaveFailed(error.toString()),
type: ToastType.error,
);
} finally {
if (mounted) {
setState(() {
_saving = false;
});
}
}
}
String _formatDate(DateTime dt) {
return DateFormat.yMd(context.l10n.localeName).add_Hm().format(dt);
}
}
class _ScheduleItemSimple {
final String id;
final String title;
final DateTime startAt;
const _ScheduleItemSimple({
required this.id,
required this.title,
required this.startAt,
});
}
class _PriorityPill extends StatelessWidget {
final String label;
final bool selected;
final Color borderColor;
final Color activeColor;
final VoidCallback onTap;
const _PriorityPill({
required this.label,
required this.selected,
required this.borderColor,
required this.activeColor,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return AppPressable(
onTap: onTap,
borderRadius: BorderRadius.circular(AppRadius.full),
child: AnimatedContainer(
duration: const Duration(milliseconds: 140),
curve: Curves.easeOut,
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.md,
vertical: AppSpacing.sm,
),
decoration: BoxDecoration(
color: selected
? borderColor.withValues(alpha: 0.28)
: AppColors.white,
borderRadius: BorderRadius.circular(AppRadius.full),
border: Border.all(
color: selected ? borderColor : AppColors.slate300,
width: selected ? 1.5 : 1,
),
),
child: Text(
label,
style: TextStyle(
fontSize: 12,
fontWeight: selected ? FontWeight.w600 : FontWeight.w500,
color: selected ? activeColor : AppColors.slate600,
),
),
),
);
}
}
class _ScheduleSelectableTile extends StatelessWidget {
final String title;
final String subtitle;
final bool selected;
final VoidCallback onTap;
const _ScheduleSelectableTile({
required this.title,
required this.subtitle,
required this.selected,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return AppPressable(
onTap: onTap,
borderRadius: BorderRadius.circular(AppRadius.lg),
child: AnimatedContainer(
duration: const Duration(milliseconds: 120),
curve: Curves.easeOut,
padding: const EdgeInsets.all(AppSpacing.md),
decoration: BoxDecoration(
color: selected ? AppColors.surfaceInfoLight : AppColors.white,
borderRadius: BorderRadius.circular(AppRadius.lg),
border: Border.all(
color: selected ? AppColors.borderQuaternary : AppColors.border,
),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: AppColors.slate800,
),
),
const SizedBox(height: AppSpacing.xs),
Text(
subtitle,
style: const TextStyle(
fontSize: 12,
color: AppColors.slate500,
),
),
],
),
),
const SizedBox(width: AppSpacing.md),
AnimatedContainer(
duration: const Duration(milliseconds: 120),
width: AppSpacing.lg,
height: AppSpacing.lg,
decoration: BoxDecoration(
color: selected ? AppColors.blue600 : AppColors.white,
borderRadius: BorderRadius.circular(AppRadius.full),
border: Border.all(
color: selected ? AppColors.blue600 : AppColors.slate300,
),
),
child: selected
? const Icon(Icons.check, size: 12, color: AppColors.white)
: null,
),
],
),
),
);
}
}
@@ -0,0 +1,704 @@
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 '../../../../core/l10n/l10n.dart';
import '../../../../core/theme/design_tokens.dart';
import '../../../home/presentation/navigation/home_return_policy.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/toast/toast.dart';
import '../../../../shared/widgets/toast/toast_type.dart';
import '../../../calendar/presentation/calendar_state_manager.dart';
import '../../../calendar/presentation/widgets/bottom_dock.dart';
import '../../data/todo_api.dart';
import '../../data/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) {
return Scaffold(
backgroundColor: AppColors.todoBg,
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() {
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: AppColors.blue600,
borderRadius: BorderRadius.circular(AppRadius.full),
boxShadow: [
BoxShadow(
color: AppColors.blue300.withValues(alpha: 0.28),
blurRadius: AppRadius.lg,
offset: const Offset(0, AppSpacing.xs),
),
],
),
child: const Icon(
LucideIcons.plus,
size: 18,
color: AppColors.white,
),
),
),
],
),
);
}
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 quadrants = [
_QuadrantMeta(
value: 1,
title: context.l10n.todoQuadrantImportantUrgent,
textColor: AppColors.g1Text,
dividerColor: AppColors.g1Divider,
borderColor: AppColors.g1Border,
items: _importantUrgent,
),
_QuadrantMeta(
value: 3,
title: context.l10n.todoQuadrantUrgentNotImportant,
textColor: AppColors.g2Text,
dividerColor: AppColors.g2Divider,
borderColor: AppColors.g2Border,
items: _urgentNotImportant,
),
_QuadrantMeta(
value: 2,
title: context.l10n.todoQuadrantImportantNotUrgent,
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(
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: 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(_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() {
return SizedBox(
height: 60,
child: Center(
child: Text(
context.l10n.todoNoItems,
style: TextStyle(
fontFamily: 'Inter',
fontSize: 13,
color: AppColors.slate400,
),
),
),
);
}
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) {
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: const TextStyle(
fontFamily: 'Inter',
fontSize: 13,
fontWeight: FontWeight.w600,
color: AppColors.slate700,
),
),
),
GestureDetector(
onTap: _handleCheckTap,
child: AnimatedBuilder(
animation: _controller,
builder: (context, child) {
return Container(
width: 20,
height: 20,
decoration: BoxDecoration(
color: _isChecked ? AppColors.blue600 : Colors.white,
border: Border.all(
color: _isChecked
? AppColors.blue600
: AppColors.slate300,
width: 1.5,
),
borderRadius: BorderRadius.circular(4),
),
child: _isChecked
? Transform.scale(
scale: _scaleAnimation.value,
child: const Icon(
Icons.check,
size: 14,
color: Colors.white,
),
)
: null,
);
},
),
),
],
),
),
);
}
}
@@ -0,0 +1,76 @@
import 'package:flutter/material.dart';
import 'package:social_app/core/theme/design_tokens.dart';
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;
const TodoDragItem({
super.key,
required this.todo,
required this.quadrant,
required this.sourceIndex,
required this.onDragStarted,
required this.onDragEnd,
required this.child,
});
@override
Widget build(BuildContext context) {
return LongPressDraggable<String>(
data: '${todo.id}:$quadrant:$sourceIndex',
delay: const Duration(milliseconds: 150),
feedback: Material(
elevation: 8,
borderRadius: BorderRadius.circular(AppRadius.md),
child: Transform.scale(
scale: 1.03,
child: SizedBox(width: 280, child: _buildDragFeedback()),
),
),
childWhenDragging: AnimatedOpacity(
duration: const Duration(milliseconds: 100),
opacity: 0.3,
child: child,
),
onDragStarted: onDragStarted,
onDragEnd: (_) => onDragEnd(),
child: child,
);
}
Widget _buildDragFeedback() {
return Container(
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.md,
vertical: AppSpacing.sm,
),
decoration: BoxDecoration(
color: AppColors.white,
borderRadius: BorderRadius.circular(AppRadius.md),
boxShadow: [
BoxShadow(
color: AppColors.slate400.withValues(alpha: 0.3),
blurRadius: 12,
offset: const Offset(0, 4),
),
],
),
child: Text(
todo.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: AppColors.slate700,
),
),
);
}
}