From 82a7d594bd15a52ff9267d71a7079188947ccc06 Mon Sep 17 00:00:00 2001 From: zl-q Date: Thu, 19 Mar 2026 00:51:52 +0800 Subject: [PATCH] =?UTF-8?q?feat(apps/todo):=20=E6=96=B0=E5=A2=9E=20TodoEdi?= =?UTF-8?q?tScreen=20=E7=BC=96=E8=BE=91=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../todo/ui/screens/todo_detail_screen.dart | 341 ++--------- .../todo/ui/screens/todo_edit_screen.dart | 574 ++++++++++++++++++ .../ui/screens/todo_quadrants_screen.dart | 408 +------------ 3 files changed, 627 insertions(+), 696 deletions(-) create mode 100644 apps/lib/features/todo/ui/screens/todo_edit_screen.dart diff --git a/apps/lib/features/todo/ui/screens/todo_detail_screen.dart b/apps/lib/features/todo/ui/screens/todo_detail_screen.dart index 2b3d102..669132e 100644 --- a/apps/lib/features/todo/ui/screens/todo_detail_screen.dart +++ b/apps/lib/features/todo/ui/screens/todo_detail_screen.dart @@ -2,15 +2,15 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:lucide_icons/lucide_icons.dart'; import '../../../../core/di/injection.dart'; +import '../../../../core/router/app_routes.dart'; import '../../../../core/theme/design_tokens.dart'; -import '../../../../shared/widgets/app_loading_indicator.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/app_button.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 '../../data/todo_api.dart'; enum _TodoHeaderAction { edit, delete } @@ -38,6 +38,9 @@ class _TodoDetailScreenState extends State { } Future _loadTodo() async { + if (!mounted) { + return; + } setState(() { _isLoading = true; _error = null; @@ -45,11 +48,17 @@ class _TodoDetailScreenState extends State { 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; @@ -90,12 +99,21 @@ class _TodoDetailScreenState extends State { return Scaffold( backgroundColor: AppColors.todoBg, body: SafeArea( - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - _buildHeader(), - Expanded(child: _buildContent()), - ], + 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()), + ], + ), ), ), ); @@ -144,20 +162,11 @@ class _TodoDetailScreenState extends State { Widget _buildContent() { if (_isLoading) { - return const Center(child: AppLoadingIndicator(size: 22)); + return const FullScreenLoading(); } if (_error != null) { - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text('加载失败: $_error', style: const TextStyle(color: Colors.red)), - const SizedBox(height: 16), - AppButton(text: '重试', onPressed: _loadTodo), - ], - ), - ); + return ErrorRetrySurface(message: '加载失败: $_error', onRetry: _loadTodo); } if (_todo == null) { @@ -187,7 +196,8 @@ class _TodoDetailScreenState extends State { title: item.title, time: _formatEventTime(item.startAt, item.endAt), borderColor: AppColors.todoEventBorder1, - onTap: () => context.push('/calendar/events/${item.id}'), + onTap: () => + context.push(AppRoutes.calendarEventDetail(item.id)), ), ), ], @@ -369,27 +379,14 @@ class _TodoDetailScreenState extends State { } void _editTodo() async { - final result = await showModalBottomSheet>( - context: context, - isScrollControlled: true, - backgroundColor: Colors.transparent, - builder: (context) => _EditTodoSheet(todo: _todo!), - ); - - if (result != null) { - try { - await _todoApi.updateTodo( - _todo!.id, - title: result['title'] as String, - description: result['description'] as String?, - priority: result['priority'] as int, - scheduleItemIds: result['schedule_item_ids'] as List?, - ); - await _loadTodo(); - } catch (e) { - if (mounted) { - Toast.show(context, '更新失败: $e', type: ToastType.error); - } + if (_todo == null) { + return; + } + final changed = await context.push(AppRoutes.todoEdit(_todo!.id)); + if (changed == true) { + await _loadTodo(); + if (mounted && _error != null) { + Toast.show(context, '刷新失败: $_error', type: ToastType.error); } } } @@ -416,263 +413,3 @@ class _TodoDetailScreenState extends State { } } } - -class _EditTodoSheet extends StatefulWidget { - final TodoResponse todo; - - const _EditTodoSheet({required this.todo}); - - @override - State<_EditTodoSheet> createState() => _EditTodoSheetState(); -} - -class _EditTodoSheetState extends State<_EditTodoSheet> { - late TextEditingController _titleController; - late TextEditingController _descriptionController; - late int _priority; - late Set _selectedScheduleItems; - late final Future> _scheduleItemsFuture; - - @override - void initState() { - super.initState(); - _titleController = TextEditingController(text: widget.todo.title); - _descriptionController = TextEditingController( - text: widget.todo.description ?? '', - ); - _priority = widget.todo.priority; - _selectedScheduleItems = widget.todo.scheduleItems.map((e) => e.id).toSet(); - _scheduleItemsFuture = _loadScheduleItems(); - } - - @override - void dispose() { - _titleController.dispose(); - _descriptionController.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return Container( - height: MediaQuery.of(context).size.height * 0.85, - decoration: const BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.vertical(top: Radius.circular(20)), - ), - child: Column( - children: [ - SingleChildScrollView( - padding: const EdgeInsets.all(24), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - const Text( - '编辑待办', - style: TextStyle( - fontFamily: 'Inter', - fontSize: 20, - fontWeight: FontWeight.w700, - ), - ), - IconButton( - onPressed: () => Navigator.of(context).pop(), - icon: const Icon(LucideIcons.x), - ), - ], - ), - const SizedBox(height: 20), - TextField( - controller: _titleController, - decoration: const InputDecoration( - labelText: '标题', - border: OutlineInputBorder(), - ), - ), - const SizedBox(height: 16), - TextField( - controller: _descriptionController, - decoration: const InputDecoration( - labelText: '描述(可选)', - border: OutlineInputBorder(), - ), - maxLines: 2, - ), - const SizedBox(height: 16), - const Text( - '优先级', - style: TextStyle( - fontFamily: 'Inter', - fontSize: 14, - fontWeight: FontWeight.w600, - ), - ), - const SizedBox(height: 8), - Row( - children: [ - _PriorityChip( - label: '重要紧急', - selected: _priority == 1, - color: AppColors.g1Border, - onTap: () => setState(() => _priority = 1), - ), - const SizedBox(width: 8), - _PriorityChip( - label: '紧急不重要', - selected: _priority == 3, - color: AppColors.g2Border, - onTap: () => setState(() => _priority = 3), - ), - const SizedBox(width: 8), - _PriorityChip( - label: '重要不紧急', - selected: _priority == 2, - color: AppColors.g3Border, - onTap: () => setState(() => _priority = 2), - ), - ], - ), - ], - ), - ), - Expanded( - child: FutureBuilder>( - future: _scheduleItemsFuture, - builder: (context, snapshot) { - if (snapshot.connectionState == ConnectionState.waiting) { - return const Center(child: AppLoadingIndicator(size: 22)); - } - if (snapshot.hasError) { - return Center(child: Text('加载失败: ${snapshot.error}')); - } - final items = snapshot.data ?? []; - if (items.isEmpty) { - return const Center(child: Text('暂无日历事件')); - } - return ListView.builder( - padding: const EdgeInsets.symmetric(horizontal: 16), - itemCount: items.length, - itemBuilder: (context, index) { - final item = items[index]; - final isSelected = _selectedScheduleItems.contains(item.id); - return CheckboxListTile( - title: Text(item.title), - subtitle: Text(_formatDate(item.startAt)), - value: isSelected, - onChanged: (value) { - setState(() { - if (value == true) { - _selectedScheduleItems.add(item.id); - } else { - _selectedScheduleItems.remove(item.id); - } - }); - }, - ); - }, - ); - }, - ), - ), - Padding( - padding: const EdgeInsets.all(16), - child: SizedBox( - width: double.infinity, - child: AppButton( - text: '保存', - onPressed: () { - if (_titleController.text.trim().isEmpty) { - Toast.show(context, '请输入标题', type: ToastType.warning); - return; - } - Navigator.of(context).pop({ - 'title': _titleController.text.trim(), - 'description': _descriptionController.text.trim().isEmpty - ? null - : _descriptionController.text.trim(), - 'priority': _priority, - 'schedule_item_ids': _selectedScheduleItems.toList(), - }); - }, - ), - ), - ), - ], - ), - ); - } - - Future> _loadScheduleItems() async { - final calendarApi = sl(); - final now = DateTime.now(); - final start = now.subtract(const Duration(days: 30)); - final end = now.add(const Duration(days: 90)); - final items = await calendarApi.listByRange(startAt: start, endAt: end); - return items - .map( - (e) => - _ScheduleItemSimple(id: e.id, title: e.title, startAt: e.startAt), - ) - .toList(); - } - - String _formatDate(DateTime dt) { - return '${dt.year}年${dt.month}月${dt.day}日 ${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}'; - } -} - -class _ScheduleItemSimple { - final String id; - final String title; - final DateTime startAt; - - _ScheduleItemSimple({ - required this.id, - required this.title, - required this.startAt, - }); -} - -class _PriorityChip extends StatelessWidget { - final String label; - final bool selected; - final Color color; - final VoidCallback onTap; - - const _PriorityChip({ - required this.label, - required this.selected, - required this.color, - required this.onTap, - }); - - @override - Widget build(BuildContext context) { - return GestureDetector( - onTap: onTap, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - decoration: BoxDecoration( - color: selected ? color.withValues(alpha: 0.2) : Colors.transparent, - border: Border.all( - color: selected ? color : AppColors.slate300, - width: selected ? 2 : 1, - ), - borderRadius: BorderRadius.circular(20), - ), - child: Text( - label, - style: TextStyle( - fontFamily: 'Inter', - fontSize: 12, - fontWeight: selected ? FontWeight.w600 : FontWeight.normal, - color: selected ? color : AppColors.slate600, - ), - ), - ), - ); - } -} diff --git a/apps/lib/features/todo/ui/screens/todo_edit_screen.dart b/apps/lib/features/todo/ui/screens/todo_edit_screen.dart new file mode 100644 index 0000000..438efa1 --- /dev/null +++ b/apps/lib/features/todo/ui/screens/todo_edit_screen.dart @@ -0,0 +1,574 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; + +import '../../../../core/di/injection.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 '../../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 createState() => _TodoEditScreenState(); +} + +class _TodoEditScreenState extends State { + final TodoApi _todoApi = sl(); + final CalendarApi _calendarApi = sl(); + + final TextEditingController _titleController = TextEditingController(); + final TextEditingController _descriptionController = TextEditingController(); + + TodoResponse? _todo; + bool _loading = true; + bool _saving = false; + String? _error; + + int _priority = 1; + final Set _selectedScheduleItemIds = {}; + List<_ScheduleItemSimple> _scheduleItems = const <_ScheduleItemSimple>[]; + + @override + void initState() { + super.initState(); + _loadPage(); + } + + @override + void dispose() { + _titleController.dispose(); + _descriptionController.dispose(); + super.dispose(); + } + + Future _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 + .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, + 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: [ + BackTitlePageHeader(title: widget.isCreateMode ? '新建待办' : '编辑待办'), + Expanded(child: _buildBody()), + _buildBottomAction(), + ], + ), + ), + ), + ); + } + + Widget _buildBody() { + if (_loading) { + return const FullScreenLoading(); + } + + if (_error != null) { + return ErrorRetrySurface(message: '加载失败: $_error', onRetry: _loadPage); + } + + if (!widget.isCreateMode && _todo == null) { + return const Center(child: Text('待办不存在')); + } + + 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: [ + const Text( + '待办信息', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w700, + color: AppColors.slate900, + ), + ), + const SizedBox(height: AppSpacing.xs), + Text( + widget.isCreateMode + ? '创建后可在四象限中查看并继续调整优先级与关联事件。' + : _todo?.status == 'done' + ? '该待办已完成,你仍可调整内容并重新组织关联事件。' + : '调整标题、优先级和关联事件,保持任务结构清晰。', + 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: '标题', + hint: '输入待办标题', + ), + const SizedBox(height: AppSpacing.lg), + AppSheetInputField( + controller: _descriptionController, + label: '描述(可选)', + hint: '补充细节或备注', + maxLines: 2, + ), + const SizedBox(height: AppSpacing.lg), + const Text( + '优先级', + 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: '重要紧急', + selected: _priority == 1, + borderColor: AppColors.g1Border, + activeColor: AppColors.g1Text, + onTap: () => setState(() => _priority = 1), + ), + _PriorityPill( + label: '紧急不重要', + selected: _priority == 3, + borderColor: AppColors.g2Border, + activeColor: AppColors.g2Text, + onTap: () => setState(() => _priority = 3), + ), + _PriorityPill( + label: '重要不紧急', + 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: [ + const Text( + '关联日历事件', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: AppColors.slate700, + ), + ), + const Spacer(), + Text( + '${_selectedScheduleItemIds.length}项', + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: AppColors.slate500, + ), + ), + ], + ), + const SizedBox(height: AppSpacing.sm), + if (_scheduleItems.isEmpty) + const Padding( + padding: EdgeInsets.symmetric(vertical: AppSpacing.xl), + child: Center( + child: Text( + '暂无可关联的日历事件', + style: 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 ? '保存中...' : (widget.isCreateMode ? '创建待办' : '保存修改'), + onPressed: canSave ? _save : null, + ), + ); + } + + Future _save() async { + if (_saving) { + return; + } + final title = _titleController.text.trim(); + if (title.isEmpty) { + Toast.show(context, '请输入标题', 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; + } + Toast.show( + context, + widget.isCreateMode ? '待办已创建' : '待办已更新', + type: ToastType.success, + ); + context.pop(true); + } catch (error) { + if (!mounted) { + return; + } + Toast.show(context, '保存失败: $error', type: ToastType.error); + } finally { + if (mounted) { + setState(() { + _saving = false; + }); + } + } + } + + String _formatDate(DateTime dt) { + return '${dt.year}年${dt.month}月${dt.day}日 ${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}'; + } +} + +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, + ), + ], + ), + ), + ); + } +} diff --git a/apps/lib/features/todo/ui/screens/todo_quadrants_screen.dart b/apps/lib/features/todo/ui/screens/todo_quadrants_screen.dart index d410ece..075af86 100644 --- a/apps/lib/features/todo/ui/screens/todo_quadrants_screen.dart +++ b/apps/lib/features/todo/ui/screens/todo_quadrants_screen.dart @@ -2,16 +2,16 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:lucide_icons/lucide_icons.dart'; import '../../../../core/di/injection.dart'; +import '../../../../core/router/app_routes.dart'; import '../../../../core/theme/design_tokens.dart'; -import '../../../../shared/widgets/app_button.dart'; -import '../../../../shared/widgets/app_loading_indicator.dart'; +import '../../../home/ui/navigation/home_return_policy.dart'; import '../../../../shared/widgets/app_pull_refresh_feedback.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/ui/calendar_state_manager.dart'; import '../../../calendar/ui/widgets/bottom_dock.dart'; import '../../data/todo_api.dart'; @@ -115,31 +115,13 @@ class _TodoQuadrantsScreenState extends State { } void _navigateToDetail(TodoResponse todo) { - context.push('/todo/${todo.id}'); + context.push(AppRoutes.todoDetail(todo.id)); } Future _addTodo() async { - final result = await showModalBottomSheet>( - context: context, - isScrollControlled: true, - backgroundColor: Colors.transparent, - builder: (context) => const _AddTodoSheet(), - ); - - if (result != null) { - try { - await _todoApi.createTodo( - title: result['title'] as String, - description: result['description'] as String?, - priority: result['priority'] as int, - scheduleItemIds: (result['schedule_item_ids'] as List?) ?? [], - ); - await _loadTodos(); - } catch (e) { - if (mounted) { - Toast.show(context, '创建失败: $e', type: ToastType.error); - } - } + final created = await context.push(AppRoutes.todoCreate); + if (created == true) { + await _loadTodos(); } } @@ -151,7 +133,7 @@ class _TodoQuadrantsScreenState extends State { canPop: false, onPopInvokedWithResult: (didPop, result) { if (!didPop) { - context.go('/home'); + returnToHomePreserveState(context); } }, child: SafeArea( @@ -225,20 +207,11 @@ class _TodoQuadrantsScreenState extends State { Widget _buildContent() { if (_isLoading) { - return const Center(child: AppLoadingIndicator(size: 22)); + return const FullScreenLoading(); } if (_error != null) { - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text('加载失败: $_error', style: const TextStyle(color: Colors.red)), - const SizedBox(height: 16), - AppButton(text: '重试', onPressed: _loadTodos), - ], - ), - ); + return ErrorRetrySurface(message: '加载失败: $_error', onRetry: _loadTodos); } return Stack( @@ -379,12 +352,12 @@ class _TodoQuadrantsScreenState extends State { final dateStr = '${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}'; if (viewType == CalendarViewType.month) { - context.go('/calendar/month'); + context.push(AppRoutes.calendarMonth); } else { - context.go('/calendar/dayweek?date=$dateStr'); + context.push('${AppRoutes.calendarDayWeek}?date=$dateStr'); } }, - onHomeTap: () => context.go('/home'), + onHomeTap: () => returnToHomePreserveState(context), ); } } @@ -499,356 +472,3 @@ class _TodoItemWidgetState extends State<_TodoItemWidget> ); } } - -class _AddTodoSheet extends StatefulWidget { - const _AddTodoSheet(); - - @override - State<_AddTodoSheet> createState() => _AddTodoSheetState(); -} - -class _AddTodoSheetState extends State<_AddTodoSheet> { - final _titleController = TextEditingController(); - final _descriptionController = TextEditingController(); - int _priority = 1; - final Set _selectedScheduleItems = {}; - late final Future> _scheduleItemsFuture; - - @override - void initState() { - super.initState(); - _scheduleItemsFuture = _loadScheduleItems(); - } - - @override - void dispose() { - _titleController.dispose(); - _descriptionController.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - final bottomInset = MediaQuery.of(context).viewInsets.bottom; - - return AnimatedPadding( - duration: const Duration(milliseconds: 150), - curve: Curves.easeOut, - padding: EdgeInsets.only(bottom: bottomInset), - child: Container( - height: MediaQuery.of(context).size.height * 0.85, - decoration: const BoxDecoration( - color: AppColors.white, - borderRadius: BorderRadius.vertical( - top: Radius.circular(AppRadius.xxl), - ), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - const SizedBox(height: AppSpacing.sm), - Center( - child: Container( - width: 36, - height: 4, - decoration: BoxDecoration( - color: AppColors.slate200, - borderRadius: BorderRadius.circular(AppRadius.full), - ), - ), - ), - const SizedBox(height: AppSpacing.md), - const Padding( - padding: EdgeInsets.symmetric(horizontal: AppSpacing.xl), - child: Text( - '添加待办', - style: TextStyle( - fontFamily: 'Inter', - fontSize: 20, - fontWeight: FontWeight.w700, - color: AppColors.slate900, - ), - ), - ), - const SizedBox(height: AppSpacing.lg), - Expanded( - child: SingleChildScrollView( - keyboardDismissBehavior: - ScrollViewKeyboardDismissBehavior.onDrag, - padding: const EdgeInsets.symmetric(horizontal: AppSpacing.xl), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _buildInputField( - controller: _titleController, - label: '标题', - hint: '输入待办标题', - autofocus: true, - ), - const SizedBox(height: AppSpacing.lg), - _buildInputField( - controller: _descriptionController, - label: '描述(可选)', - hint: '补充细节或备注', - maxLines: 2, - ), - const SizedBox(height: AppSpacing.lg), - const Text( - '优先级', - style: TextStyle( - fontFamily: 'Inter', - fontSize: 14, - fontWeight: FontWeight.w600, - color: AppColors.slate700, - ), - ), - const SizedBox(height: AppSpacing.sm), - Wrap( - spacing: AppSpacing.sm, - runSpacing: AppSpacing.sm, - children: [ - _PriorityChip( - label: '重要紧急', - selected: _priority == 1, - color: AppColors.g1Border, - onTap: () => setState(() => _priority = 1), - ), - _PriorityChip( - label: '紧急不重要', - selected: _priority == 3, - color: AppColors.g2Border, - onTap: () => setState(() => _priority = 3), - ), - _PriorityChip( - label: '重要不紧急', - selected: _priority == 2, - color: AppColors.g3Border, - onTap: () => setState(() => _priority = 2), - ), - ], - ), - const SizedBox(height: AppSpacing.lg), - const Text( - '关联日历事件', - style: TextStyle( - fontFamily: 'Inter', - fontSize: 14, - fontWeight: FontWeight.w600, - color: AppColors.slate700, - ), - ), - const SizedBox(height: AppSpacing.sm), - Container( - constraints: const BoxConstraints(maxHeight: 260), - decoration: BoxDecoration( - color: AppColors.slate50, - borderRadius: BorderRadius.circular(AppRadius.lg), - border: Border.all(color: AppColors.borderSecondary), - ), - child: FutureBuilder>( - future: _scheduleItemsFuture, - builder: (context, snapshot) { - if (snapshot.connectionState == - ConnectionState.waiting) { - return _buildScheduleSkeleton(); - } - if (snapshot.hasError) { - return Padding( - padding: const EdgeInsets.all(AppSpacing.lg), - child: Text( - '加载失败: ${snapshot.error}', - style: const TextStyle(color: AppColors.red500), - ), - ); - } - final items = snapshot.data ?? const []; - if (items.isEmpty) { - return const SizedBox( - height: 120, - child: Center( - child: Text( - '暂无日历事件', - style: TextStyle(color: AppColors.slate500), - ), - ), - ); - } - return ListView.builder( - padding: const EdgeInsets.symmetric( - horizontal: AppSpacing.sm, - vertical: AppSpacing.xs, - ), - itemCount: items.length, - itemBuilder: (context, index) { - final item = items[index]; - final isSelected = _selectedScheduleItems - .contains(item.id); - return CheckboxListTile( - dense: true, - value: isSelected, - title: Text(item.title), - subtitle: Text(_formatDate(item.startAt)), - onChanged: (value) { - setState(() { - if (value == true) { - _selectedScheduleItems.add(item.id); - } else { - _selectedScheduleItems.remove(item.id); - } - }); - }, - ); - }, - ); - }, - ), - ), - const SizedBox(height: AppSpacing.xl), - ], - ), - ), - ), - Padding( - padding: const EdgeInsets.fromLTRB( - AppSpacing.lg, - AppSpacing.sm, - AppSpacing.lg, - AppSpacing.lg, - ), - child: SizedBox( - width: double.infinity, - child: AppButton( - text: '添加', - onPressed: () { - if (_titleController.text.trim().isEmpty) { - Toast.show(context, '请输入标题', type: ToastType.warning); - return; - } - Navigator.of(context).pop({ - 'title': _titleController.text.trim(), - 'description': _descriptionController.text.trim().isEmpty - ? null - : _descriptionController.text.trim(), - 'priority': _priority, - 'schedule_item_ids': _selectedScheduleItems.toList(), - }); - }, - ), - ), - ), - ], - ), - ), - ); - } - - Widget _buildInputField({ - required TextEditingController controller, - required String label, - required String hint, - int maxLines = 1, - bool autofocus = false, - }) { - return AppSheetInputField( - controller: controller, - label: label, - hint: hint, - maxLines: maxLines, - autofocus: autofocus, - ); - } - - Widget _buildScheduleSkeleton() { - return ListView.separated( - physics: const NeverScrollableScrollPhysics(), - padding: const EdgeInsets.symmetric( - horizontal: AppSpacing.md, - vertical: AppSpacing.md, - ), - itemCount: 4, - separatorBuilder: (context, index) => - const SizedBox(height: AppSpacing.sm), - itemBuilder: (context, index) { - return Container( - height: AppSpacing.xxl + AppSpacing.lg, - decoration: BoxDecoration( - color: AppColors.white, - borderRadius: BorderRadius.circular(AppRadius.md), - border: Border.all(color: AppColors.borderSecondary), - ), - ); - }, - ); - } - - Future> _loadScheduleItems() async { - final calendarApi = sl(); - final now = DateTime.now(); - final start = now.subtract(const Duration(days: 30)); - final end = now.add(const Duration(days: 90)); - final items = await calendarApi.listByRange(startAt: start, endAt: end); - return items - .map( - (e) => - _ScheduleItemSimple(id: e.id, title: e.title, startAt: e.startAt), - ) - .toList(); - } - - String _formatDate(DateTime dt) { - return '${dt.year}年${dt.month}月${dt.day}日 ${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}'; - } -} - -class _ScheduleItemSimple { - final String id; - final String title; - final DateTime startAt; - - _ScheduleItemSimple({ - required this.id, - required this.title, - required this.startAt, - }); -} - -class _PriorityChip extends StatelessWidget { - final String label; - final bool selected; - final Color color; - final VoidCallback onTap; - - const _PriorityChip({ - required this.label, - required this.selected, - required this.color, - required this.onTap, - }); - - @override - Widget build(BuildContext context) { - return GestureDetector( - onTap: onTap, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - decoration: BoxDecoration( - color: selected ? color.withValues(alpha: 0.2) : Colors.transparent, - border: Border.all( - color: selected ? color : AppColors.slate300, - width: selected ? 2 : 1, - ), - borderRadius: BorderRadius.circular(20), - ), - child: Text( - label, - style: TextStyle( - fontFamily: 'Inter', - fontSize: 12, - fontWeight: selected ? FontWeight.w600 : FontWeight.normal, - color: selected ? color : AppColors.slate600, - ), - ), - ), - ); - } -}