From 1fc0ff6738f79c91f8e2bb77f4d0b12bb276fdb2 Mon Sep 17 00:00:00 2001 From: Levi Date: Sat, 12 Sep 2026 09:57:02 +0800 Subject: [PATCH] feat: emphasize task creation and add empty-list actions --- .../home/presentation/pages/home_page.dart | 21 ++++++-- .../presentation/widgets/tasks_top_bar.dart | 25 +-------- .../widgets/task_create_button.dart | 32 ++++++++++++ .../widgets/task_empty_state.dart | 52 ++++++++++++------- ui/flutter/test/widget_test.dart | 19 +++++-- 5 files changed, 100 insertions(+), 49 deletions(-) create mode 100644 ui/flutter/lib/features/tasks/presentation/widgets/task_create_button.dart diff --git a/ui/flutter/lib/features/home/presentation/pages/home_page.dart b/ui/flutter/lib/features/home/presentation/pages/home_page.dart index f23af3ee..4728259d 100644 --- a/ui/flutter/lib/features/home/presentation/pages/home_page.dart +++ b/ui/flutter/lib/features/home/presentation/pages/home_page.dart @@ -30,6 +30,7 @@ import '../../../tasks/presentation/widgets/task_drawer.dart'; import '../../../tasks/presentation/widgets/task_context_menu.dart'; import '../../../tasks/presentation/widgets/task_delete_dialog.dart'; import '../../../tasks/presentation/widgets/task_empty_state.dart'; +import '../../../tasks/presentation/widgets/task_create_button.dart'; import '../../../tasks/presentation/widgets/task_file_browser_dialog.dart'; import '../../../tasks/presentation/widgets/task_update_url_dialog.dart'; import '../widgets/primary_rail.dart'; @@ -172,7 +173,10 @@ class _HomePageState extends ConsumerState { loading: () => const Center(child: shad.CircularProgressIndicator()), error: (error, _) => const TaskEmptyState(), data: (_) => filteredTasks.isEmpty - ? TaskEmptyState(message: emptyStateMessage) + ? TaskEmptyState( + message: emptyStateMessage, + onCreateTask: _searchController.text.trim().isEmpty ? _handleAddTask : null, + ) : ListView.separated( padding: const EdgeInsets.only(bottom: 16), itemCount: filteredTasks.length, @@ -260,6 +264,7 @@ class _HomePageState extends ConsumerState { data: (_) => _MobileTasksView( tasks: filteredTasks, emptyStateMessage: emptyStateMessage, + onCreateTask: _searchController.text.trim().isEmpty ? _handleAddTask : null, batchMode: _batchMode, batchSelection: _batchSelection, onToggleSelectAll: _toggleSelectAll, @@ -685,6 +690,7 @@ class _MobileTasksView extends StatelessWidget { required this.contextActionsForTask, required this.pendingUpdateTaskId, this.emptyStateMessage, + this.onCreateTask, this.loading = false, }); @@ -705,6 +711,7 @@ class _MobileTasksView extends StatelessWidget { final TaskContextActions Function(TaskRecord task, bool selected, bool allSelected) contextActionsForTask; final String? pendingUpdateTaskId; final String? emptyStateMessage; + final VoidCallback? onCreateTask; final bool loading; @override @@ -745,7 +752,7 @@ class _MobileTasksView extends StatelessWidget { child: loading ? const Center(child: shad.CircularProgressIndicator()) : tasks.isEmpty - ? TaskEmptyState(message: emptyStateMessage) + ? TaskEmptyState(message: emptyStateMessage, onCreateTask: onCreateTask) : ListView.separated( padding: const EdgeInsets.fromLTRB(16, 8, 16, 12), itemCount: tasks.length, @@ -814,7 +821,15 @@ class _MobileTasksHeader extends StatelessWidget { style: TextStyle(color: palette.textPrimary, fontSize: 22, fontWeight: FontWeight.w800), ), ), - _MobileHeaderIconButton(icon: Icons.add_rounded, onPressed: onAddTask), + const SizedBox(width: AppDesignTokens.space8), + ConstrainedBox( + constraints: BoxConstraints(maxWidth: MediaQuery.sizeOf(context).width * 0.5), + child: TaskCreateButton( + key: const ValueKey('tasks-mobile-create-button'), + onPressed: onAddTask, + minHeight: 44, + ), + ), const SizedBox(width: 8), _MobileHeaderIconButton( icon: Icons.checklist_rtl_outlined, diff --git a/ui/flutter/lib/features/home/presentation/widgets/tasks_top_bar.dart b/ui/flutter/lib/features/home/presentation/widgets/tasks_top_bar.dart index 595d78b4..5805d4bf 100644 --- a/ui/flutter/lib/features/home/presentation/widgets/tasks_top_bar.dart +++ b/ui/flutter/lib/features/home/presentation/widgets/tasks_top_bar.dart @@ -4,7 +4,7 @@ import 'package:shadcn_flutter/shadcn_flutter.dart' as shad; import '../../../../shared/theme/app_design_tokens.dart'; import '../../../../shared/theme/app_palette.dart'; -import '../../../../shared/widgets/app_primary_button.dart'; +import '../../../tasks/presentation/widgets/task_create_button.dart'; import '../../../../shared/widgets/app_text_field.dart'; import '../../../../l10n/l10n.dart'; @@ -62,7 +62,7 @@ class TasksTopBar extends StatelessWidget { ConstrainedBox( key: const ValueKey('tasks-create-button-container'), constraints: const BoxConstraints(minWidth: 120, maxWidth: 190), - child: _AddTaskButton(onPressed: onAddTask), + child: TaskCreateButton(onPressed: onAddTask), ), const SizedBox(width: 16), Row( @@ -131,27 +131,6 @@ class _SearchField extends StatelessWidget { } } -class _AddTaskButton extends StatelessWidget { - const _AddTaskButton({required this.onPressed}); - - final VoidCallback onPressed; - - @override - Widget build(BuildContext context) { - return AppPrimaryButton( - onPressed: onPressed, - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon(Icons.add), - const SizedBox(width: AppDesignTokens.space8), - Flexible(child: Text(context.l10n.create, maxLines: 1, overflow: TextOverflow.ellipsis)), - ], - ), - ); - } -} - class _ActionIconButton extends StatelessWidget { const _ActionIconButton({required this.icon, required this.onPressed}); diff --git a/ui/flutter/lib/features/tasks/presentation/widgets/task_create_button.dart b/ui/flutter/lib/features/tasks/presentation/widgets/task_create_button.dart new file mode 100644 index 00000000..44e9bd3e --- /dev/null +++ b/ui/flutter/lib/features/tasks/presentation/widgets/task_create_button.dart @@ -0,0 +1,32 @@ +import 'package:flutter/material.dart' show Icons; +import 'package:flutter/widgets.dart'; + +import '../../../../l10n/l10n.dart'; +import '../../../../shared/theme/app_design_tokens.dart'; +import '../../../../shared/widgets/app_primary_button.dart'; + +/// Shared creation action for task headers and empty-list guidance. +class TaskCreateButton extends StatelessWidget { + const TaskCreateButton({super.key, required this.onPressed, this.minHeight = 0}); + + final VoidCallback onPressed; + final double minHeight; + + @override + Widget build(BuildContext context) { + return ConstrainedBox( + constraints: BoxConstraints(minHeight: minHeight), + child: AppPrimaryButton( + onPressed: onPressed, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.add), + const SizedBox(width: AppDesignTokens.space8), + Flexible(child: Text(context.l10n.create, maxLines: 1, overflow: TextOverflow.ellipsis)), + ], + ), + ), + ); + } +} diff --git a/ui/flutter/lib/features/tasks/presentation/widgets/task_empty_state.dart b/ui/flutter/lib/features/tasks/presentation/widgets/task_empty_state.dart index 73b7f816..21f6a7d1 100644 --- a/ui/flutter/lib/features/tasks/presentation/widgets/task_empty_state.dart +++ b/ui/flutter/lib/features/tasks/presentation/widgets/task_empty_state.dart @@ -1,35 +1,49 @@ import 'package:flutter/widgets.dart'; import '../../../../shared/theme/app_palette.dart'; +import '../../../../shared/theme/app_design_tokens.dart'; +import 'task_create_button.dart'; import '../../../../l10n/l10n.dart'; class TaskEmptyState extends StatelessWidget { - const TaskEmptyState({super.key, this.message}); + const TaskEmptyState({super.key, this.message, this.onCreateTask}); final String? message; + final VoidCallback? onCreateTask; @override Widget build(BuildContext context) { final palette = AppPalette.of(context); return Center( - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 360), - child: Padding( - padding: const EdgeInsets.all(24), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - CustomPaint( - key: const ValueKey('task-empty-illustration'), - size: const Size(148, 108), - painter: _TaskEmptyIllustrationPainter(palette: palette), - ), - const SizedBox(height: 18), - Text( - message ?? context.l10n.emptyTaskList, - style: TextStyle(color: palette.textPrimary, fontSize: 17, fontWeight: FontWeight.w700), - ), - ], + child: SingleChildScrollView( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 360), + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + CustomPaint( + key: const ValueKey('task-empty-illustration'), + size: const Size(148, 108), + painter: _TaskEmptyIllustrationPainter(palette: palette), + ), + const SizedBox(height: 18), + Text( + message ?? context.l10n.emptyTaskList, + textAlign: TextAlign.center, + style: TextStyle(color: palette.textPrimary, fontSize: 17, fontWeight: FontWeight.w700), + ), + if (onCreateTask != null) ...[ + const SizedBox(height: AppDesignTokens.space24), + TaskCreateButton( + key: const ValueKey('tasks-empty-create-button'), + onPressed: onCreateTask!, + minHeight: 48, + ), + ], + ], + ), ), ), ), diff --git a/ui/flutter/test/widget_test.dart b/ui/flutter/test/widget_test.dart index cf6098e4..d47b99b1 100644 --- a/ui/flutter/test/widget_test.dart +++ b/ui/flutter/test/widget_test.dart @@ -117,7 +117,7 @@ void main() { await LocalAppCapabilities.instance.registry.invoke(NavigationMethods.showDownloadingTasks.name, {}); await tester.pumpAndSettle(); expect(find.text('done.zip'), findsNothing); - expect(find.text('Create Task'), findsOneWidget); + expect(find.text('Create Task'), findsNWidgets(2)); GoRouter.of(AppRouter.rootNavigatorKey.currentContext!).go('/settings'); await tester.pumpAndSettle(); @@ -125,7 +125,7 @@ void main() { await tester.pumpAndSettle(); expect(GoRouter.of(AppRouter.rootNavigatorKey.currentContext!).routeInformationProvider.value.uri.path, '/'); expect(find.text('done.zip'), findsNothing); - expect(find.text('Create Task'), findsOneWidget); + expect(find.text('Create Task'), findsNWidgets(2)); }); test('Web MCP endpoint excludes page path, query, and hash route', () { @@ -227,14 +227,14 @@ void main() { expect(find.text('Downloading'), findsOneWidget); expect(find.text('Completed'), findsOneWidget); expect(find.text('Failed'), findsOneWidget); - expect(find.text('Create Task'), findsOneWidget); + expect(find.text('Create Task'), findsNWidgets(2)); expect(find.text('No tasks in this list'), findsOneWidget); expect(find.text('Add Task'), findsNothing); expect(find.byKey(const ValueKey('task-empty-illustration')), findsOneWidget); expect(find.text('Create a task or refresh the list.'), findsNothing); expect(find.text('Refresh'), findsNothing); expect(find.textContaining('ACTIVE CONNECTIONS'), findsNothing); - expect(find.ancestor(of: find.text('Create Task'), matching: find.byType(AppPrimaryButton)), findsOneWidget); + expect(find.ancestor(of: find.text('Create Task'), matching: find.byType(AppPrimaryButton)), findsNWidgets(2)); final taskActions = find.byKey(const ValueKey('tasks-top-action-buttons')); final taskSearch = find.byKey(const ValueKey('tasks-search-field-container')); final createButton = find.byKey(const ValueKey('tasks-create-button-container')); @@ -411,6 +411,7 @@ void main() { await tester.pump(); expect(find.text('No matching tasks found'), findsOneWidget); + expect(find.byKey(const ValueKey('tasks-empty-create-button')), findsNothing); expect(find.text('No tasks in this list'), findsNothing); }); @@ -437,6 +438,16 @@ void main() { expect(find.text('Downloading 0'), findsOneWidget); expect(find.text('Completed 0'), findsOneWidget); expect(find.text('Failed 0'), findsOneWidget); + expect(find.text('Create Task'), findsNWidgets(2)); + final headerCreate = find.byKey(const ValueKey('tasks-mobile-create-button')); + expect(tester.getSize(headerCreate).height, greaterThanOrEqualTo(44)); + expect(tester.getBottomLeft(headerCreate).dy, lessThan(tester.getTopLeft(find.text('Downloading 0')).dy)); + for (final size in [const Size(320, 568), const Size(768, 1024)]) { + tester.view.physicalSize = size; + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + expect(find.byKey(const ValueKey('tasks-empty-create-button')), findsOneWidget); + } }); testWidgets('extensions grid adds columns from a minimum card width and keeps desktop toolbar aligned', (