Merge PR #1505: emphasize task creation and empty-list actions

This commit is contained in:
Levi committed 2026-09-12 16:55:20 +08:00
commit 2d239495a0
5 files changed
+100 -49

No files matched your search

@@ -30,6 +30,7 @@ import '../../../tasks/presentation/widgets/task_drawer.dart';
import '../../../tasks/presentation/widgets/task_context_menu.dart'; import '../../../tasks/presentation/widgets/task_context_menu.dart';
import '../../../tasks/presentation/widgets/task_delete_dialog.dart'; import '../../../tasks/presentation/widgets/task_delete_dialog.dart';
import '../../../tasks/presentation/widgets/task_empty_state.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_file_browser_dialog.dart';
import '../../../tasks/presentation/widgets/task_update_url_dialog.dart'; import '../../../tasks/presentation/widgets/task_update_url_dialog.dart';
import '../widgets/primary_rail.dart'; import '../widgets/primary_rail.dart';
@@ -172,7 +173,10 @@ class _HomePageState extends ConsumerState<HomePage> {
loading: () => const Center(child: shad.CircularProgressIndicator()), loading: () => const Center(child: shad.CircularProgressIndicator()),
error: (error, _) => const TaskEmptyState(), error: (error, _) => const TaskEmptyState(),
data: (_) => filteredTasks.isEmpty data: (_) => filteredTasks.isEmpty
? TaskEmptyState(message: emptyStateMessage) ? TaskEmptyState(
message: emptyStateMessage,
onCreateTask: _searchController.text.trim().isEmpty ? _handleAddTask : null,
)
: ListView.separated( : ListView.separated(
padding: const EdgeInsets.only(bottom: 16), padding: const EdgeInsets.only(bottom: 16),
itemCount: filteredTasks.length, itemCount: filteredTasks.length,
@@ -260,6 +264,7 @@ class _HomePageState extends ConsumerState<HomePage> {
data: (_) => _MobileTasksView( data: (_) => _MobileTasksView(
tasks: filteredTasks, tasks: filteredTasks,
emptyStateMessage: emptyStateMessage, emptyStateMessage: emptyStateMessage,
onCreateTask: _searchController.text.trim().isEmpty ? _handleAddTask : null,
batchMode: _batchMode, batchMode: _batchMode,
batchSelection: _batchSelection, batchSelection: _batchSelection,
onToggleSelectAll: _toggleSelectAll, onToggleSelectAll: _toggleSelectAll,
@@ -685,6 +690,7 @@ class _MobileTasksView extends StatelessWidget {
required this.contextActionsForTask, required this.contextActionsForTask,
required this.pendingUpdateTaskId, required this.pendingUpdateTaskId,
this.emptyStateMessage, this.emptyStateMessage,
this.onCreateTask,
this.loading = false, this.loading = false,
}); });
@@ -705,6 +711,7 @@ class _MobileTasksView extends StatelessWidget {
final TaskContextActions Function(TaskRecord task, bool selected, bool allSelected) contextActionsForTask; final TaskContextActions Function(TaskRecord task, bool selected, bool allSelected) contextActionsForTask;
final String? pendingUpdateTaskId; final String? pendingUpdateTaskId;
final String? emptyStateMessage; final String? emptyStateMessage;
final VoidCallback? onCreateTask;
final bool loading; final bool loading;
@override @override
@@ -745,7 +752,7 @@ class _MobileTasksView extends StatelessWidget {
child: loading child: loading
? const Center(child: shad.CircularProgressIndicator()) ? const Center(child: shad.CircularProgressIndicator())
: tasks.isEmpty : tasks.isEmpty
? TaskEmptyState(message: emptyStateMessage) ? TaskEmptyState(message: emptyStateMessage, onCreateTask: onCreateTask)
: ListView.separated( : ListView.separated(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 12), padding: const EdgeInsets.fromLTRB(16, 8, 16, 12),
itemCount: tasks.length, itemCount: tasks.length,
@@ -814,7 +821,15 @@ class _MobileTasksHeader extends StatelessWidget {
style: TextStyle(color: palette.textPrimary, fontSize: 22, fontWeight: FontWeight.w800), 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), const SizedBox(width: 8),
_MobileHeaderIconButton( _MobileHeaderIconButton(
icon: Icons.checklist_rtl_outlined, icon: Icons.checklist_rtl_outlined,
@@ -4,7 +4,7 @@ import 'package:shadcn_flutter/shadcn_flutter.dart' as shad;
import '../../../../shared/theme/app_design_tokens.dart'; import '../../../../shared/theme/app_design_tokens.dart';
import '../../../../shared/theme/app_palette.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 '../../../../shared/widgets/app_text_field.dart';
import '../../../../l10n/l10n.dart'; import '../../../../l10n/l10n.dart';
@@ -62,7 +62,7 @@ class TasksTopBar extends StatelessWidget {
ConstrainedBox( ConstrainedBox(
key: const ValueKey('tasks-create-button-container'), key: const ValueKey('tasks-create-button-container'),
constraints: const BoxConstraints(minWidth: 120, maxWidth: 190), constraints: const BoxConstraints(minWidth: 120, maxWidth: 190),
child: _AddTaskButton(onPressed: onAddTask), child: TaskCreateButton(onPressed: onAddTask),
), ),
const SizedBox(width: 16), const SizedBox(width: 16),
Row( 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 { class _ActionIconButton extends StatelessWidget {
const _ActionIconButton({required this.icon, required this.onPressed}); const _ActionIconButton({required this.icon, required this.onPressed});
@@ -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)),
],
),
),
);
}
}
@@ -1,35 +1,49 @@
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
import '../../../../shared/theme/app_palette.dart'; import '../../../../shared/theme/app_palette.dart';
import '../../../../shared/theme/app_design_tokens.dart';
import 'task_create_button.dart';
import '../../../../l10n/l10n.dart'; import '../../../../l10n/l10n.dart';
class TaskEmptyState extends StatelessWidget { class TaskEmptyState extends StatelessWidget {
const TaskEmptyState({super.key, this.message}); const TaskEmptyState({super.key, this.message, this.onCreateTask});
final String? message; final String? message;
final VoidCallback? onCreateTask;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final palette = AppPalette.of(context); final palette = AppPalette.of(context);
return Center( return Center(
child: ConstrainedBox( child: SingleChildScrollView(
constraints: const BoxConstraints(maxWidth: 360), child: ConstrainedBox(
child: Padding( constraints: const BoxConstraints(maxWidth: 360),
padding: const EdgeInsets.all(24), child: Padding(
child: Column( padding: const EdgeInsets.all(24),
mainAxisSize: MainAxisSize.min, child: Column(
children: [ mainAxisSize: MainAxisSize.min,
CustomPaint( children: [
key: const ValueKey('task-empty-illustration'), CustomPaint(
size: const Size(148, 108), key: const ValueKey('task-empty-illustration'),
painter: _TaskEmptyIllustrationPainter(palette: palette), size: const Size(148, 108),
), painter: _TaskEmptyIllustrationPainter(palette: palette),
const SizedBox(height: 18), ),
Text( const SizedBox(height: 18),
message ?? context.l10n.emptyTaskList, Text(
style: TextStyle(color: palette.textPrimary, fontSize: 17, fontWeight: FontWeight.w700), 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,
),
],
],
),
), ),
), ),
), ),
+15 -4
View File
@@ -117,7 +117,7 @@ void main() {
await LocalAppCapabilities.instance.registry.invoke(NavigationMethods.showDownloadingTasks.name, {}); await LocalAppCapabilities.instance.registry.invoke(NavigationMethods.showDownloadingTasks.name, {});
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.text('done.zip'), findsNothing); 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'); GoRouter.of(AppRouter.rootNavigatorKey.currentContext!).go('/settings');
await tester.pumpAndSettle(); await tester.pumpAndSettle();
@@ -125,7 +125,7 @@ void main() {
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(GoRouter.of(AppRouter.rootNavigatorKey.currentContext!).routeInformationProvider.value.uri.path, '/'); expect(GoRouter.of(AppRouter.rootNavigatorKey.currentContext!).routeInformationProvider.value.uri.path, '/');
expect(find.text('done.zip'), findsNothing); 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', () { 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('Downloading'), findsOneWidget);
expect(find.text('Completed'), findsOneWidget); expect(find.text('Completed'), findsOneWidget);
expect(find.text('Failed'), 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('No tasks in this list'), findsOneWidget);
expect(find.text('Add Task'), findsNothing); expect(find.text('Add Task'), findsNothing);
expect(find.byKey(const ValueKey('task-empty-illustration')), findsOneWidget); expect(find.byKey(const ValueKey('task-empty-illustration')), findsOneWidget);
expect(find.text('Create a task or refresh the list.'), findsNothing); expect(find.text('Create a task or refresh the list.'), findsNothing);
expect(find.text('Refresh'), findsNothing); expect(find.text('Refresh'), findsNothing);
expect(find.textContaining('ACTIVE CONNECTIONS'), 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 taskActions = find.byKey(const ValueKey('tasks-top-action-buttons'));
final taskSearch = find.byKey(const ValueKey('tasks-search-field-container')); final taskSearch = find.byKey(const ValueKey('tasks-search-field-container'));
final createButton = find.byKey(const ValueKey('tasks-create-button-container')); final createButton = find.byKey(const ValueKey('tasks-create-button-container'));
@@ -411,6 +411,7 @@ void main() {
await tester.pump(); await tester.pump();
expect(find.text('No matching tasks found'), findsOneWidget); 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); expect(find.text('No tasks in this list'), findsNothing);
}); });
@@ -437,6 +438,16 @@ void main() {
expect(find.text('Downloading 0'), findsOneWidget); expect(find.text('Downloading 0'), findsOneWidget);
expect(find.text('Completed 0'), findsOneWidget); expect(find.text('Completed 0'), findsOneWidget);
expect(find.text('Failed 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', ( testWidgets('extensions grid adds columns from a minimum card width and keeps desktop toolbar aligned', (