feat(apps): 重构 UI 架构为 presentation 层并新增 l10n 国际化支持
This commit is contained in:
@@ -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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user