From ff8f61423e30923d46f9e7122404a8d0715888f4 Mon Sep 17 00:00:00 2001 From: Levi Date: Sat, 12 Sep 2026 07:25:37 +0800 Subject: [PATCH 1/9] fix(ui): refine app update dialog layout and scrolling (#1503) --- .../widgets/app_update_dialog.dart | 28 ++++----- .../widgets/app_update_notes_viewport.dart | 59 +++++++++++++++++++ ui/flutter/test/updater_test.dart | 39 ++++++++++++ 3 files changed, 111 insertions(+), 15 deletions(-) create mode 100644 ui/flutter/lib/features/settings/presentation/widgets/app_update_notes_viewport.dart diff --git a/ui/flutter/lib/features/settings/presentation/widgets/app_update_dialog.dart b/ui/flutter/lib/features/settings/presentation/widgets/app_update_dialog.dart index 7367c609..35a29293 100644 --- a/ui/flutter/lib/features/settings/presentation/widgets/app_update_dialog.dart +++ b/ui/flutter/lib/features/settings/presentation/widgets/app_update_dialog.dart @@ -10,6 +10,7 @@ import '../../../../util/package_info.dart'; import '../../../../util/updater.dart'; import '../../../../util/util.dart'; import '../../../../l10n/l10n.dart'; +import 'app_update_notes_viewport.dart'; typedef StartAppUpdate = Future Function(VersionInfo versionInfo, UpdateProgressCallback onProgress); @@ -74,11 +75,18 @@ Future showAppUpdateDialog( key: const ValueKey('app-update-heading'), textAlign: TextAlign.center, ), - const SizedBox(height: 3), + const SizedBox(height: 8), Text( '$appVersion → ${versionInfo.version}', textAlign: TextAlign.center, - style: TextStyle(color: palette.textSecondary, fontSize: 12, fontWeight: FontWeight.w400), + style: DefaultTextStyle.of(dialogContext).style.copyWith( + inherit: false, + color: palette.textSecondary, + fontSize: 12, + fontWeight: FontWeight.w400, + height: 1.4, + decoration: TextDecoration.none, + ), ), ], ), @@ -90,18 +98,9 @@ Future showAppUpdateDialog( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Text( - dialogContext.l10n.releaseNotes, - style: TextStyle(color: palette.textPrimary, fontSize: 12, fontWeight: FontWeight.w700), - ), - const SizedBox(height: 10), - ConstrainedBox( - constraints: const BoxConstraints(maxHeight: 210), - child: SingleChildScrollView( - padding: const EdgeInsets.symmetric(horizontal: 4), - child: _ReleaseNotes( - notes: releaseNotes.isEmpty ? dialogContext.l10n.noReleaseNotes : releaseNotes, - ), + AppUpdateNotesViewport( + child: _ReleaseNotes( + notes: releaseNotes.isEmpty ? dialogContext.l10n.noReleaseNotes : releaseNotes, ), ), if (updating) ...[ @@ -157,7 +156,6 @@ Future showAppUpdateDialog( onPressed: () => unawaited(update()), loading: updating, variant: AppLoadingButtonVariant.primary, - icon: const Icon(Icons.system_update_alt_outlined, size: 17), child: Text(dialogContext.l10n.newVersionUpdate), ), ], diff --git a/ui/flutter/lib/features/settings/presentation/widgets/app_update_notes_viewport.dart b/ui/flutter/lib/features/settings/presentation/widgets/app_update_notes_viewport.dart new file mode 100644 index 00000000..51dbe968 --- /dev/null +++ b/ui/flutter/lib/features/settings/presentation/widgets/app_update_notes_viewport.dart @@ -0,0 +1,59 @@ +import 'package:flutter/widgets.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart' as shad; + +import '../../../../shared/theme/app_design_tokens.dart'; + +class AppUpdateNotesViewport extends StatefulWidget { + const AppUpdateNotesViewport({super.key, required this.child}); + + final Widget child; + + @override + State createState() => _AppUpdateNotesViewportState(); +} + +class _AppUpdateNotesViewportState extends State { + final _controller = ScrollController(); + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + const fadeHeight = AppDesignTokens.space24; + return ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 240), + child: shad.Scrollbar( + controller: _controller, + thumbVisibility: true, + child: ShaderMask( + blendMode: BlendMode.dstIn, + shaderCallback: (bounds) => LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + // Mask colors control opacity only; they do not tint the content. + colors: const [shad.Colors.white, shad.Colors.white, shad.Colors.transparent], + stops: [0, (1 - fadeHeight / bounds.height).clamp(0.0, 1.0), 1], + ).createShader(bounds), + child: ScrollConfiguration( + behavior: ScrollConfiguration.of(context).copyWith(scrollbars: false), + child: SingleChildScrollView( + controller: _controller, + padding: const EdgeInsetsDirectional.fromSTEB( + AppDesignTokens.space4, + 0, + AppDesignTokens.space16, + // Keep the final line above the fade at the end of the scroll. + fadeHeight, + ), + child: widget.child, + ), + ), + ), + ), + ); + } +} diff --git a/ui/flutter/test/updater_test.dart b/ui/flutter/test/updater_test.dart index 6ec81862..6ea25fad 100644 --- a/ui/flutter/test/updater_test.dart +++ b/ui/flutter/test/updater_test.dart @@ -1,7 +1,9 @@ +import 'package:flutter/material.dart' show Icons; import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:gopeed/api/model/downloader_config.dart'; import 'package:gopeed/features/settings/presentation/widgets/app_update_dialog.dart'; +import 'package:gopeed/features/settings/presentation/widgets/app_update_notes_viewport.dart'; import 'package:gopeed/shared/theme/app_theme.dart'; import 'package:gopeed/util/arch/arch.dart'; import 'package:gopeed/util/github_mirror.dart'; @@ -193,10 +195,47 @@ void main() { expect(find.text('下载更快'), findsNothing); expect(find.text('Later'), findsOneWidget); expect(find.text('Update Now'), findsOneWidget); + expect(find.text("What's New"), findsNothing); + final versionText = tester.widget(find.text('2.0.0-beta.1 → 2.0.0-beta.2')); + expect(versionText.style?.decoration, TextDecoration.none); + expect(versionText.style?.inherit, isFalse); + expect(find.byIcon(Icons.system_update_alt_outlined), findsNothing); final dialog = tester.getRect(find.byKey(const ValueKey('app-update-dialog'))); expect(dialog.width, lessThan(320)); expect(tester.getCenter(find.byKey(const ValueKey('app-update-heading'))).dx, closeTo(dialog.center.dx, 0.01)); expect(tester.getCenter(find.byKey(const ValueKey('app-update-actions'))).dx, closeTo(dialog.center.dx, 0.01)); expect(tester.takeException(), isNull); }); + + testWidgets('release notes keep the last line above the fade when scrolled to the end', (tester) async { + await tester.pumpWidget( + shad.ShadcnApp( + theme: AppTheme.dark(), + materialTheme: AppTheme.materialDark(), + home: Center( + child: SizedBox( + width: 280, + child: AppUpdateNotesViewport( + child: Column(children: [for (var i = 0; i < 30; i++) Text('Release note $i')]), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + final scrollbar = tester.widget(find.byType(shad.Scrollbar)); + expect(scrollbar.thumbVisibility, isTrue); + final controller = scrollbar.controller!; + expect(controller.position.maxScrollExtent, greaterThan(0)); + controller.jumpTo(controller.position.maxScrollExtent); + await tester.pumpAndSettle(); + final viewport = tester.getRect(find.byType(AppUpdateNotesViewport)); + final lastLine = tester.getRect(find.text('Release note 29')); + expect(lastLine.bottom, lessThanOrEqualTo(viewport.bottom - 24)); + expect(lastLine.top, greaterThan(viewport.top)); + expect(tester.takeException(), isNull); + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + }); } From 091e7b8936d738544abefead2d411fdc845ed060 Mon Sep 17 00:00:00 2001 From: Levi Date: Sat, 12 Sep 2026 09:28:04 +0800 Subject: [PATCH 2/9] fix(ui): keep mobile create task actions in safe area (#1504) --- .../pages/create_task_window_page.dart | 39 ++++++++++--------- .../create_task_window_page_test.dart | 6 ++- 2 files changed, 26 insertions(+), 19 deletions(-) diff --git a/ui/flutter/lib/features/tasks/presentation/pages/create_task_window_page.dart b/ui/flutter/lib/features/tasks/presentation/pages/create_task_window_page.dart index 55fb3ad1..0e242ec8 100644 --- a/ui/flutter/lib/features/tasks/presentation/pages/create_task_window_page.dart +++ b/ui/flutter/lib/features/tasks/presentation/pages/create_task_window_page.dart @@ -687,24 +687,27 @@ class _CreateTaskWindowPageState extends ConsumerState { ), ), ), - Container( - padding: const EdgeInsets.fromLTRB(24, 12, 24, 20), - decoration: BoxDecoration( - border: Border(top: BorderSide(color: palette.border)), - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - CreateTaskActionButtons( - submitting: _creating, - onCancel: _closeWindow, - onSubmit: _confirm, - cancelLabel: context.l10n.cancel, - submitLabel: context.l10n.confirm, - cancelButtonKey: const ValueKey('create-task-cancel-button'), - submitButtonKey: const ValueKey('create-task-confirm-button'), - ), - ], + SafeArea( + top: false, + child: Container( + padding: const EdgeInsets.fromLTRB(24, 12, 24, 20), + decoration: BoxDecoration( + border: Border(top: BorderSide(color: palette.border)), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + CreateTaskActionButtons( + submitting: _creating, + onCancel: _closeWindow, + onSubmit: _confirm, + cancelLabel: context.l10n.cancel, + submitLabel: context.l10n.confirm, + cancelButtonKey: const ValueKey('create-task-cancel-button'), + submitButtonKey: const ValueKey('create-task-confirm-button'), + ), + ], + ), ), ), ], diff --git a/ui/flutter/test/features/tasks/presentation/create_task_window_page_test.dart b/ui/flutter/test/features/tasks/presentation/create_task_window_page_test.dart index 2ca8e88f..c9114bcc 100644 --- a/ui/flutter/test/features/tasks/presentation/create_task_window_page_test.dart +++ b/ui/flutter/test/features/tasks/presentation/create_task_window_page_test.dart @@ -165,7 +165,7 @@ void main() { } }); - testWidgets('mobile create task page starts below the system status bar', (WidgetTester tester) async { + testWidgets('mobile create task page stays within the system safe areas', (WidgetTester tester) async { debugDefaultTargetPlatformOverride = TargetPlatform.android; tester.view.physicalSize = const Size(700, 760); tester.view.devicePixelRatio = 1; @@ -193,6 +193,10 @@ void main() { final contentPadding = tester.widget(find.byKey(const ValueKey('create-task-safe-content'))); expect((contentPadding.padding as EdgeInsets).top, 32); + expect( + tester.getBottomRight(find.byKey(const ValueKey('create-task-confirm-button'))).dy, + lessThanOrEqualTo(tester.view.physicalSize.height / tester.view.devicePixelRatio - 24), + ); } finally { debugDefaultTargetPlatformOverride = null; } From 0d52b840a2636d5f0d371deee244cdb67a571e4a Mon Sep 17 00:00:00 2001 From: Levi Date: Sat, 12 Sep 2026 18:09:36 +0800 Subject: [PATCH 3/9] feat(ui): emphasize task creation and add empty-list actions (#1505) --- .../home/presentation/pages/home_page.dart | 31 ++++++++++-- .../presentation/widgets/tasks_top_bar.dart | 25 +--------- .../widgets/task_create_button.dart | 42 ++++++++++++++++ .../widgets/task_empty_create_hint.dart | 49 +++++++++++++++++++ .../widgets/task_empty_state.dart | 48 +++++++++++------- ui/flutter/lib/l10n/app_ar.arb | 3 +- ui/flutter/lib/l10n/app_az.arb | 3 +- ui/flutter/lib/l10n/app_ca.arb | 3 +- ui/flutter/lib/l10n/app_de.arb | 3 +- ui/flutter/lib/l10n/app_en.arb | 11 ++++- ui/flutter/lib/l10n/app_es.arb | 3 +- ui/flutter/lib/l10n/app_fa.arb | 3 +- ui/flutter/lib/l10n/app_fr.arb | 3 +- ui/flutter/lib/l10n/app_hu.arb | 3 +- ui/flutter/lib/l10n/app_id.arb | 3 +- ui/flutter/lib/l10n/app_it.arb | 3 +- ui/flutter/lib/l10n/app_ja.arb | 3 +- ui/flutter/lib/l10n/app_ko.arb | 3 +- ui/flutter/lib/l10n/app_pl.arb | 3 +- ui/flutter/lib/l10n/app_pt.arb | 3 +- ui/flutter/lib/l10n/app_ru.arb | 3 +- ui/flutter/lib/l10n/app_ta.arb | 3 +- ui/flutter/lib/l10n/app_tr.arb | 3 +- ui/flutter/lib/l10n/app_uk.arb | 3 +- ui/flutter/lib/l10n/app_vi.arb | 3 +- ui/flutter/lib/l10n/app_zh.arb | 3 +- ui/flutter/lib/l10n/app_zh_TW.arb | 3 +- ui/flutter/test/widget_test.dart | 15 ++++++ 28 files changed, 216 insertions(+), 68 deletions(-) create mode 100644 ui/flutter/lib/features/tasks/presentation/widgets/task_create_button.dart create mode 100644 ui/flutter/lib/features/tasks/presentation/widgets/task_empty_create_hint.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..a4b3b2aa 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,9 +821,19 @@ 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: 34, + compact: true, + ), + ), const SizedBox(width: 8), _MobileHeaderIconButton( + key: const ValueKey('tasks-mobile-batch-button'), icon: Icons.checklist_rtl_outlined, active: batchMode, badge: batchMode && selectedCount > 0 ? selectedCount.toString() : null, @@ -903,7 +920,13 @@ class _MobileBatchToolbar extends StatelessWidget { } class _MobileHeaderIconButton extends StatelessWidget { - const _MobileHeaderIconButton({required this.icon, required this.onPressed, this.active = false, this.badge}); + const _MobileHeaderIconButton({ + super.key, + required this.icon, + required this.onPressed, + this.active = false, + this.badge, + }); final IconData icon; final VoidCallback onPressed; 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..465b7c2c --- /dev/null +++ b/ui/flutter/lib/features/tasks/presentation/widgets/task_create_button.dart @@ -0,0 +1,42 @@ +import 'package:shadcn_flutter/shadcn_flutter.dart' as shad; +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, this.compact = false}); + + final VoidCallback onPressed; + final double minHeight; + final bool compact; + + @override + Widget build(BuildContext context) { + return ConstrainedBox( + constraints: BoxConstraints(minHeight: minHeight), + child: AppPrimaryButton( + onPressed: onPressed, + size: compact ? shad.ButtonSize.small : shad.ButtonSize.normal, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.add, size: compact ? 16 : null), + SizedBox(width: compact ? AppDesignTokens.space4 : AppDesignTokens.space8), + Flexible( + child: Text( + context.l10n.create, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: compact ? const TextStyle(fontSize: 13) : null, + ), + ), + ], + ), + ), + ); + } +} diff --git a/ui/flutter/lib/features/tasks/presentation/widgets/task_empty_create_hint.dart b/ui/flutter/lib/features/tasks/presentation/widgets/task_empty_create_hint.dart new file mode 100644 index 00000000..0ddd8189 --- /dev/null +++ b/ui/flutter/lib/features/tasks/presentation/widgets/task_empty_create_hint.dart @@ -0,0 +1,49 @@ +import 'package:shadcn_flutter/shadcn_flutter.dart'; + +import '../../../../l10n/l10n.dart'; +import '../../../../shared/theme/app_palette.dart'; +import '../../../../shared/widgets/app_tooltip.dart'; + +/// Keeps the action inside the localized sentence, including RTL word order. +class TaskEmptyCreateHint extends StatelessWidget { + const TaskEmptyCreateHint({super.key, required this.onCreateTask}); + + final VoidCallback onCreateTask; + + @override + Widget build(BuildContext context) { + const actionMarker = '\uFFFC'; + final parts = context.l10n.emptyTaskCreateHint(actionMarker).split(actionMarker); + return Text.rich( + TextSpan( + children: [ + TextSpan(text: parts.first.trimRight()), + WidgetSpan( + alignment: PlaceholderAlignment.middle, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: AppTooltip( + message: context.l10n.create, + child: Semantics( + label: context.l10n.create, + child: SizedBox.square( + dimension: 30, + child: IconButton.outline( + key: const ValueKey('tasks-empty-create-button'), + density: ButtonDensity.compact, + onPressed: onCreateTask, + icon: const Icon(Icons.add, size: 16), + ), + ), + ), + ), + ), + ), + TextSpan(text: parts.last.trimLeft()), + ], + ), + textAlign: TextAlign.center, + style: TextStyle(color: AppPalette.of(context).textMuted, fontSize: 13), + ); + } +} 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..be086dd6 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,45 @@ import 'package:flutter/widgets.dart'; import '../../../../shared/theme/app_palette.dart'; +import '../../../../shared/theme/app_design_tokens.dart'; +import 'task_empty_create_hint.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), + TaskEmptyCreateHint(onCreateTask: onCreateTask!), + ], + ], + ), ), ), ), diff --git a/ui/flutter/lib/l10n/app_ar.arb b/ui/flutter/lib/l10n/app_ar.arb index 3b7c407d..174cb2d1 100644 --- a/ui/flutter/lib/l10n/app_ar.arb +++ b/ui/flutter/lib/l10n/app_ar.arb @@ -442,5 +442,6 @@ "webApiConfigReadOnlyHelp": "لا يدعم إصدار الويب تعديل إعدادات API مباشرة. عدّل خيارات بدء تشغيل الخادم أو ملف الإعدادات، ثم أعد تشغيل الخدمة لتطبيق التغييرات.", "webMcpApiTokenHint": "إذا قمت بتكوين رمز API، فيجب تمرير معلومات المصادقة عند ربط الوكيل", "useMcpApiTokenTemplate": "التبديل إلى إعدادات المصادقة", - "mcpRequiresTcp": "تتوفر إرشادات ربط الوكيل عندما يكون بروتوكول API هو TCP." + "mcpRequiresTcp": "تتوفر إرشادات ربط الوكيل عندما يكون بروتوكول API هو TCP.", + "emptyTaskCreateHint": "اضغط على {action} لبدء التنزيل" } diff --git a/ui/flutter/lib/l10n/app_az.arb b/ui/flutter/lib/l10n/app_az.arb index 4fccb3de..4e9ba6ec 100644 --- a/ui/flutter/lib/l10n/app_az.arb +++ b/ui/flutter/lib/l10n/app_az.arb @@ -626,5 +626,6 @@ "webMcpApiTokenHint": "Əgər API açarı konfiqurasiya etmisinizsə, agenti qoşarkən doğrulama məlumatı tələb olunur", "useMcpApiTokenTemplate": "Doğrulamalı konfiqurasiyaya keç", "webApiConfigReadOnlyHelp": "Veb versiyası API parametrlərinin onlayn dəyişdirilməsini dəstəkləmir. Server başlanğıc seçimlərini və ya konfiqurasiya faylını yeniləyin, sonra dəyişikliklərin qüvvəyə minməsi üçün xidməti yenidən başladın.", - "mcpRequiresTcp": "Agent əlaqə təlimatları API protokolu TCP olduqda mövcuddur." + "mcpRequiresTcp": "Agent əlaqə təlimatları API protokolu TCP olduqda mövcuddur.", + "emptyTaskCreateHint": "Endirməyə başlamaq üçün {action} düyməsinə klikləyin" } diff --git a/ui/flutter/lib/l10n/app_ca.arb b/ui/flutter/lib/l10n/app_ca.arb index bb030cf5..dc3f81f7 100644 --- a/ui/flutter/lib/l10n/app_ca.arb +++ b/ui/flutter/lib/l10n/app_ca.arb @@ -442,5 +442,6 @@ "webApiConfigReadOnlyHelp": "La versió web no permet modificar la configuració de l’API en línia. Actualitza les opcions d’inici del servidor o el fitxer de configuració i reinicia el servei perquè els canvis tinguin efecte.", "webMcpApiTokenHint": "Si heu configurat un token d’API, cal proporcionar la informació d’autenticació en connectar l’agent", "useMcpApiTokenTemplate": "Canvia a la configuració autenticada", - "mcpRequiresTcp": "Les instruccions de connexió d’agents estan disponibles quan el protocol de l’API és TCP." + "mcpRequiresTcp": "Les instruccions de connexió d’agents estan disponibles quan el protocol de l’API és TCP.", + "emptyTaskCreateHint": "Fes clic a {action} per començar a baixar" } diff --git a/ui/flutter/lib/l10n/app_de.arb b/ui/flutter/lib/l10n/app_de.arb index 64d7683c..a27d800c 100644 --- a/ui/flutter/lib/l10n/app_de.arb +++ b/ui/flutter/lib/l10n/app_de.arb @@ -442,5 +442,6 @@ "webApiConfigReadOnlyHelp": "In der Web-Version können API-Einstellungen nicht online geändert werden. Ändere die Server-Startoptionen oder die Konfigurationsdatei und starte den Dienst neu, damit die Änderungen wirksam werden.", "webMcpApiTokenHint": "Wenn du ein API-Token konfiguriert hast, müssen beim Verbinden des Agents Authentifizierungsinformationen übermittelt werden", "useMcpApiTokenTemplate": "Zur authentifizierten Konfiguration wechseln", - "mcpRequiresTcp": "Anweisungen zur Agent-Verbindung sind verfügbar, wenn das API-Protokoll TCP ist." + "mcpRequiresTcp": "Anweisungen zur Agent-Verbindung sind verfügbar, wenn das API-Protokoll TCP ist.", + "emptyTaskCreateHint": "Klicke auf {action}, um den Download zu starten" } diff --git a/ui/flutter/lib/l10n/app_en.arb b/ui/flutter/lib/l10n/app_en.arb index 5aab7de5..d1897ce8 100644 --- a/ui/flutter/lib/l10n/app_en.arb +++ b/ui/flutter/lib/l10n/app_en.arb @@ -626,5 +626,14 @@ "webMcpApiTokenHint": "If you configured an API token, authentication information is required when connecting the agent", "useMcpApiTokenTemplate": "Switch to authenticated configuration", "webApiConfigReadOnlyHelp": "The web version cannot modify API settings online. Update the server startup options or configuration file, then restart the service for changes to take effect.", - "mcpRequiresTcp": "Agent connection instructions are available when the API protocol is TCP." + "mcpRequiresTcp": "Agent connection instructions are available when the API protocol is TCP.", + "emptyTaskCreateHint": "Click {action} to start downloading", + "@emptyTaskCreateHint": { + "description": "Empty-list guidance; action is replaced with an inline create button.", + "placeholders": { + "action": { + "type": "String" + } + } + } } diff --git a/ui/flutter/lib/l10n/app_es.arb b/ui/flutter/lib/l10n/app_es.arb index 89ed2ad0..34d2343c 100644 --- a/ui/flutter/lib/l10n/app_es.arb +++ b/ui/flutter/lib/l10n/app_es.arb @@ -442,5 +442,6 @@ "webApiConfigReadOnlyHelp": "La versión web no permite modificar la configuración de la API en línea. Actualiza las opciones de inicio del servidor o el archivo de configuración y reinicia el servicio para aplicar los cambios.", "webMcpApiTokenHint": "Si has configurado un token de API, debes proporcionar la información de autenticación al conectar el agente", "useMcpApiTokenTemplate": "Cambiar a la configuración autenticada", - "mcpRequiresTcp": "Las instrucciones de conexión de agentes están disponibles cuando el protocolo de la API es TCP." + "mcpRequiresTcp": "Las instrucciones de conexión de agentes están disponibles cuando el protocolo de la API es TCP.", + "emptyTaskCreateHint": "Haz clic en {action} para empezar a descargar" } diff --git a/ui/flutter/lib/l10n/app_fa.arb b/ui/flutter/lib/l10n/app_fa.arb index 6486d00d..bafb1bb3 100644 --- a/ui/flutter/lib/l10n/app_fa.arb +++ b/ui/flutter/lib/l10n/app_fa.arb @@ -442,5 +442,6 @@ "webApiConfigReadOnlyHelp": "نسخه وب از ویرایش آنلاین تنظیمات API پشتیبانی نمی‌کند. گزینه‌های راه‌اندازی سرور یا فایل پیکربندی را تغییر دهید و برای اعمال تغییرات سرویس را دوباره راه‌اندازی کنید.", "webMcpApiTokenHint": "اگر توکن API را پیکربندی کرده‌اید، هنگام اتصال عامل باید اطلاعات احراز هویت را ارسال کنید", "useMcpApiTokenTemplate": "تغییر به پیکربندی احراز هویت", - "mcpRequiresTcp": "راهنمای اتصال عامل زمانی در دسترس است که پروتکل API روی TCP باشد." + "mcpRequiresTcp": "راهنمای اتصال عامل زمانی در دسترس است که پروتکل API روی TCP باشد.", + "emptyTaskCreateHint": "برای شروع دانلود روی {action} کلیک کنید" } diff --git a/ui/flutter/lib/l10n/app_fr.arb b/ui/flutter/lib/l10n/app_fr.arb index cd764419..e708aca4 100644 --- a/ui/flutter/lib/l10n/app_fr.arb +++ b/ui/flutter/lib/l10n/app_fr.arb @@ -442,5 +442,6 @@ "webApiConfigReadOnlyHelp": "La version web ne permet pas de modifier les paramètres de l’API en ligne. Modifiez les options de démarrage du serveur ou le fichier de configuration, puis redémarrez le service pour appliquer les changements.", "webMcpApiTokenHint": "Si vous avez configuré un jeton API, les informations d’authentification doivent être transmises lors de la connexion de l’agent", "useMcpApiTokenTemplate": "Passer à la configuration authentifiée", - "mcpRequiresTcp": "Les instructions de connexion des agents sont disponibles lorsque le protocole de l’API est TCP." + "mcpRequiresTcp": "Les instructions de connexion des agents sont disponibles lorsque le protocole de l’API est TCP.", + "emptyTaskCreateHint": "Cliquez sur {action} pour commencer le téléchargement" } diff --git a/ui/flutter/lib/l10n/app_hu.arb b/ui/flutter/lib/l10n/app_hu.arb index 034eff81..2823ae6d 100644 --- a/ui/flutter/lib/l10n/app_hu.arb +++ b/ui/flutter/lib/l10n/app_hu.arb @@ -442,5 +442,6 @@ "webApiConfigReadOnlyHelp": "A webes verzióban az API-beállítások nem módosíthatók online. Módosítsd a kiszolgáló indítási beállításait vagy a konfigurációs fájlt, majd indítsd újra a szolgáltatást.", "webMcpApiTokenHint": "Ha API-tokent állítottál be, az ügynök csatlakoztatásakor meg kell adni a hitelesítési adatokat", "useMcpApiTokenTemplate": "Váltás hitelesített konfigurációra", - "mcpRequiresTcp": "Az ügynökcsatlakoztatási útmutató akkor érhető el, ha az API protokollja TCP." + "mcpRequiresTcp": "Az ügynökcsatlakoztatási útmutató akkor érhető el, ha az API protokollja TCP.", + "emptyTaskCreateHint": "Kattints a {action} gombra a letöltés indításához" } diff --git a/ui/flutter/lib/l10n/app_id.arb b/ui/flutter/lib/l10n/app_id.arb index 40acc714..43a57bdc 100644 --- a/ui/flutter/lib/l10n/app_id.arb +++ b/ui/flutter/lib/l10n/app_id.arb @@ -442,5 +442,6 @@ "webApiConfigReadOnlyHelp": "Versi web tidak dapat mengubah pengaturan API secara online. Perbarui opsi startup server atau file konfigurasi, lalu mulai ulang layanan agar perubahan diterapkan.", "webMcpApiTokenHint": "Jika Anda telah mengatur token API, informasi autentikasi harus disertakan saat menghubungkan agen", "useMcpApiTokenTemplate": "Beralih ke konfigurasi terautentikasi", - "mcpRequiresTcp": "Petunjuk koneksi agen tersedia saat protokol API menggunakan TCP." + "mcpRequiresTcp": "Petunjuk koneksi agen tersedia saat protokol API menggunakan TCP.", + "emptyTaskCreateHint": "Klik {action} untuk mulai mengunduh" } diff --git a/ui/flutter/lib/l10n/app_it.arb b/ui/flutter/lib/l10n/app_it.arb index aba9620f..8d0f62b9 100644 --- a/ui/flutter/lib/l10n/app_it.arb +++ b/ui/flutter/lib/l10n/app_it.arb @@ -442,5 +442,6 @@ "webApiConfigReadOnlyHelp": "La versione web non consente di modificare online le impostazioni API. Aggiorna le opzioni di avvio del server o il file di configurazione, quindi riavvia il servizio per applicare le modifiche.", "webMcpApiTokenHint": "Se hai configurato un token API, devi fornire le informazioni di autenticazione quando colleghi l’agente", "useMcpApiTokenTemplate": "Passa alla configurazione autenticata", - "mcpRequiresTcp": "Le istruzioni per connettere gli agenti sono disponibili quando il protocollo API è TCP." + "mcpRequiresTcp": "Le istruzioni per connettere gli agenti sono disponibili quando il protocollo API è TCP.", + "emptyTaskCreateHint": "Fai clic su {action} per iniziare il download" } diff --git a/ui/flutter/lib/l10n/app_ja.arb b/ui/flutter/lib/l10n/app_ja.arb index 8f15b2d1..097488ef 100644 --- a/ui/flutter/lib/l10n/app_ja.arb +++ b/ui/flutter/lib/l10n/app_ja.arb @@ -442,5 +442,6 @@ "webApiConfigReadOnlyHelp": "Web 版では API 設定をオンラインで変更できません。サーバーの起動オプションまたは設定ファイルを変更し、サービスを再起動して反映してください。", "webMcpApiTokenHint": "API トークンを設定している場合、エージェント接続時に認証情報を渡す必要があります", "useMcpApiTokenTemplate": "認証付き設定に切り替える", - "mcpRequiresTcp": "API プロトコルが TCP の場合にエージェント接続手順を利用できます。" + "mcpRequiresTcp": "API プロトコルが TCP の場合にエージェント接続手順を利用できます。", + "emptyTaskCreateHint": "{action}をクリックしてダウンロードを開始" } diff --git a/ui/flutter/lib/l10n/app_ko.arb b/ui/flutter/lib/l10n/app_ko.arb index 23c42a68..b7c66da1 100644 --- a/ui/flutter/lib/l10n/app_ko.arb +++ b/ui/flutter/lib/l10n/app_ko.arb @@ -442,5 +442,6 @@ "webApiConfigReadOnlyHelp": "웹 버전에서는 API 설정을 온라인으로 수정할 수 없습니다. 서버 시작 옵션이나 구성 파일을 변경한 후 서비스를 다시 시작해야 적용됩니다.", "webMcpApiTokenHint": "API 토큰을 설정한 경우 에이전트 연결 시 인증 정보를 전달해야 합니다", "useMcpApiTokenTemplate": "인증 구성으로 전환", - "mcpRequiresTcp": "API 프로토콜이 TCP일 때 에이전트 연결 안내를 사용할 수 있습니다." + "mcpRequiresTcp": "API 프로토콜이 TCP일 때 에이전트 연결 안내를 사용할 수 있습니다.", + "emptyTaskCreateHint": "{action}을 눌러 다운로드 시작" } diff --git a/ui/flutter/lib/l10n/app_pl.arb b/ui/flutter/lib/l10n/app_pl.arb index 85b61144..ff542d50 100644 --- a/ui/flutter/lib/l10n/app_pl.arb +++ b/ui/flutter/lib/l10n/app_pl.arb @@ -442,5 +442,6 @@ "webApiConfigReadOnlyHelp": "Wersja webowa nie obsługuje edycji ustawień API online. Zmień opcje uruchamiania serwera lub plik konfiguracyjny, a następnie uruchom usługę ponownie.", "webMcpApiTokenHint": "Jeśli skonfigurowano token API, podczas łączenia agenta należy przekazać dane uwierzytelniające", "useMcpApiTokenTemplate": "Przełącz na konfigurację z uwierzytelnianiem", - "mcpRequiresTcp": "Instrukcje łączenia agentów są dostępne, gdy protokół API to TCP." + "mcpRequiresTcp": "Instrukcje łączenia agentów są dostępne, gdy protokół API to TCP.", + "emptyTaskCreateHint": "Kliknij {action}, aby rozpocząć pobieranie" } diff --git a/ui/flutter/lib/l10n/app_pt.arb b/ui/flutter/lib/l10n/app_pt.arb index 46674d2c..c0d66de9 100644 --- a/ui/flutter/lib/l10n/app_pt.arb +++ b/ui/flutter/lib/l10n/app_pt.arb @@ -442,5 +442,6 @@ "webApiConfigReadOnlyHelp": "A versão web não permite alterar as definições da API online. Atualize as opções de arranque do servidor ou o ficheiro de configuração e reinicie o serviço para aplicar as alterações.", "webMcpApiTokenHint": "Se configurou um token de API, é necessário fornecer as informações de autenticação ao ligar o agente", "useMcpApiTokenTemplate": "Mudar para a configuração autenticada", - "mcpRequiresTcp": "As instruções de conexão de agentes estão disponíveis quando o protocolo da API é TCP." + "mcpRequiresTcp": "As instruções de conexão de agentes estão disponíveis quando o protocolo da API é TCP.", + "emptyTaskCreateHint": "Clique em {action} para começar a baixar" } diff --git a/ui/flutter/lib/l10n/app_ru.arb b/ui/flutter/lib/l10n/app_ru.arb index 53f5f2f6..67e42710 100644 --- a/ui/flutter/lib/l10n/app_ru.arb +++ b/ui/flutter/lib/l10n/app_ru.arb @@ -442,5 +442,6 @@ "webApiConfigReadOnlyHelp": "В веб-версии нельзя изменить настройки API онлайн. Измените параметры запуска сервера или файл конфигурации, затем перезапустите службу.", "webMcpApiTokenHint": "Если настроен API-токен, при подключении агента необходимо передать данные аутентификации", "useMcpApiTokenTemplate": "Переключиться на конфигурацию с аутентификацией", - "mcpRequiresTcp": "Инструкции по подключению агентов доступны, когда протокол API — TCP." + "mcpRequiresTcp": "Инструкции по подключению агентов доступны, когда протокол API — TCP.", + "emptyTaskCreateHint": "Нажмите {action}, чтобы начать загрузку" } diff --git a/ui/flutter/lib/l10n/app_ta.arb b/ui/flutter/lib/l10n/app_ta.arb index bccefc16..51ee4819 100644 --- a/ui/flutter/lib/l10n/app_ta.arb +++ b/ui/flutter/lib/l10n/app_ta.arb @@ -442,5 +442,6 @@ "webApiConfigReadOnlyHelp": "இணையப் பதிப்பில் API அமைப்புகளை ஆன்லைனில் மாற்ற முடியாது. சேவையக தொடக்க விருப்பங்கள் அல்லது உள்ளமைவு கோப்பை மாற்றி, மாற்றங்கள் செயல்பட சேவையை மறுதொடக்கம் செய்யவும்.", "webMcpApiTokenHint": "API டோக்கனை அமைத்திருந்தால், முகவரை இணைக்கும்போது அங்கீகாரத் தகவலை அனுப்ப வேண்டும்", "useMcpApiTokenTemplate": "அங்கீகார உள்ளமைவுக்கு மாறு", - "mcpRequiresTcp": "API நெறிமுறை TCP ஆக இருக்கும்போது முகவர் இணைப்பு வழிமுறைகள் கிடைக்கும்." + "mcpRequiresTcp": "API நெறிமுறை TCP ஆக இருக்கும்போது முகவர் இணைப்பு வழிமுறைகள் கிடைக்கும்.", + "emptyTaskCreateHint": "பதிவிறக்கத்தைத் தொடங்க {action} ஐ அழுத்தவும்" } diff --git a/ui/flutter/lib/l10n/app_tr.arb b/ui/flutter/lib/l10n/app_tr.arb index 9b3b4954..0b160d9b 100644 --- a/ui/flutter/lib/l10n/app_tr.arb +++ b/ui/flutter/lib/l10n/app_tr.arb @@ -442,5 +442,6 @@ "webApiConfigReadOnlyHelp": "Web sürümü API ayarlarının çevrimiçi değiştirilmesini desteklemez. Sunucu başlatma seçeneklerini veya yapılandırma dosyasını güncelleyin ve değişikliklerin etkili olması için hizmeti yeniden başlatın.", "webMcpApiTokenHint": "Bir API anahtarı yapılandırdıysanız, ajan bağlanırken kimlik doğrulama bilgileri iletilmelidir", "useMcpApiTokenTemplate": "Kimlik doğrulamalı yapılandırmaya geç", - "mcpRequiresTcp": "Ajan bağlantı yönergeleri API protokolü TCP olduğunda kullanılabilir." + "mcpRequiresTcp": "Ajan bağlantı yönergeleri API protokolü TCP olduğunda kullanılabilir.", + "emptyTaskCreateHint": "İndirmeye başlamak için {action} düğmesine tıklayın" } diff --git a/ui/flutter/lib/l10n/app_uk.arb b/ui/flutter/lib/l10n/app_uk.arb index e4b3e923..8632668f 100644 --- a/ui/flutter/lib/l10n/app_uk.arb +++ b/ui/flutter/lib/l10n/app_uk.arb @@ -442,5 +442,6 @@ "webApiConfigReadOnlyHelp": "У вебверсії не можна змінювати налаштування API онлайн. Змініть параметри запуску сервера або файл конфігурації, а потім перезапустіть службу.", "webMcpApiTokenHint": "Якщо налаштовано API-токен, під час підключення агента потрібно передати дані автентифікації", "useMcpApiTokenTemplate": "Перейти до конфігурації з автентифікацією", - "mcpRequiresTcp": "Інструкції з підключення агентів доступні, коли протокол API — TCP." + "mcpRequiresTcp": "Інструкції з підключення агентів доступні, коли протокол API — TCP.", + "emptyTaskCreateHint": "Натисніть {action}, щоб почати завантаження" } diff --git a/ui/flutter/lib/l10n/app_vi.arb b/ui/flutter/lib/l10n/app_vi.arb index f7a6db52..ab4171ab 100644 --- a/ui/flutter/lib/l10n/app_vi.arb +++ b/ui/flutter/lib/l10n/app_vi.arb @@ -442,5 +442,6 @@ "webApiConfigReadOnlyHelp": "Phiên bản web không hỗ trợ sửa cấu hình API trực tuyến. Hãy cập nhật tùy chọn khởi động máy chủ hoặc tệp cấu hình rồi khởi động lại dịch vụ để áp dụng thay đổi.", "webMcpApiTokenHint": "Nếu đã cấu hình API Token, bạn cần truyền thông tin xác thực khi kết nối tác nhân", "useMcpApiTokenTemplate": "Chuyển sang cấu hình có xác thực", - "mcpRequiresTcp": "Hướng dẫn kết nối tác nhân khả dụng khi giao thức API là TCP." + "mcpRequiresTcp": "Hướng dẫn kết nối tác nhân khả dụng khi giao thức API là TCP.", + "emptyTaskCreateHint": "Nhấn {action} để bắt đầu tải xuống" } diff --git a/ui/flutter/lib/l10n/app_zh.arb b/ui/flutter/lib/l10n/app_zh.arb index 0bd33ecd..0b801d25 100644 --- a/ui/flutter/lib/l10n/app_zh.arb +++ b/ui/flutter/lib/l10n/app_zh.arb @@ -442,5 +442,6 @@ "webMcpApiTokenHint": "若已配置 API Token,接入 Agent 时需要传递认证信息", "useMcpApiTokenTemplate": "切换为认证配置", "webApiConfigReadOnlyHelp": "Web 版本不支持在线修改 API 配置。请修改服务端启动参数或配置文件,并重启服务后使配置生效。", - "mcpRequiresTcp": "API 通讯协议为 TCP 时才能使用 Agent 接入指引。" + "mcpRequiresTcp": "API 通讯协议为 TCP 时才能使用 Agent 接入指引。", + "emptyTaskCreateHint": "点击{action}开始下载" } diff --git a/ui/flutter/lib/l10n/app_zh_TW.arb b/ui/flutter/lib/l10n/app_zh_TW.arb index 8220df52..21a2bc72 100644 --- a/ui/flutter/lib/l10n/app_zh_TW.arb +++ b/ui/flutter/lib/l10n/app_zh_TW.arb @@ -442,5 +442,6 @@ "webApiConfigReadOnlyHelp": "Web 版本不支援線上修改 API 設定。請修改伺服器啟動參數或設定檔,並重新啟動服務後使設定生效。", "webMcpApiTokenHint": "若已設定 API Token,接入 Agent 時需要傳遞認證資訊", "useMcpApiTokenTemplate": "切換為認證設定", - "mcpRequiresTcp": "API 通訊協定為 TCP 時才能使用 Agent 接入指引。" + "mcpRequiresTcp": "API 通訊協定為 TCP 時才能使用 Agent 接入指引。", + "emptyTaskCreateHint": "點擊{action}開始下載" } diff --git a/ui/flutter/test/widget_test.dart b/ui/flutter/test/widget_test.dart index cf6098e4..7d95ab0e 100644 --- a/ui/flutter/test/widget_test.dart +++ b/ui/flutter/test/widget_test.dart @@ -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,20 @@ 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'), findsOneWidget); + final headerCreate = find.byKey(const ValueKey('tasks-mobile-create-button')); + expect(tester.getSize(headerCreate).height, 34); + final batchButton = find.byKey(const ValueKey('tasks-mobile-batch-button')); + expect(tester.getRect(headerCreate).height, tester.getRect(batchButton).height); + expect(tester.getRect(headerCreate).top, tester.getRect(batchButton).top); + expect(tester.getRect(headerCreate).bottom, tester.getRect(batchButton).bottom); + 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', ( From 46675f26af44fa3e66ab3425f1d62a23a131edad Mon Sep 17 00:00:00 2001 From: Levi Date: Sat, 12 Sep 2026 20:04:28 +0800 Subject: [PATCH 4/9] fix(ui): align history search placeholder and prevent clipping (#1506) --- .../pages/create_task_window_page.dart | 18 ++---- .../widgets/history_search_field.dart | 38 ++++++++++++ .../history_search_field_test.dart | 59 +++++++++++++++++++ 3 files changed, 101 insertions(+), 14 deletions(-) create mode 100644 ui/flutter/lib/features/tasks/presentation/widgets/history_search_field.dart create mode 100644 ui/flutter/test/features/tasks/presentation/history_search_field_test.dart diff --git a/ui/flutter/lib/features/tasks/presentation/pages/create_task_window_page.dart b/ui/flutter/lib/features/tasks/presentation/pages/create_task_window_page.dart index 0e242ec8..5819af27 100644 --- a/ui/flutter/lib/features/tasks/presentation/pages/create_task_window_page.dart +++ b/ui/flutter/lib/features/tasks/presentation/pages/create_task_window_page.dart @@ -37,6 +37,7 @@ import '../../../../shared/widgets/app_toast.dart'; import '../../../../l10n/l10n.dart'; import '../../../../util/util.dart'; import '../../application/pending_create_task.dart'; +import '../widgets/history_search_field.dart'; import '../widgets/create_task_action_buttons.dart'; import '../widgets/resolve_file_tree.dart'; @@ -1247,20 +1248,9 @@ class _CreateTaskWindowPageState extends ConsumerState { height: screenSize.height < 560 ? screenSize.height * 0.55 : 360, child: Column( children: [ - SizedBox( - height: 38, - child: AppTextField( - key: const ValueKey('create-history-filter'), - controller: filterController, - placeholder: Text( - dialogContext.l10n.searchHistory, - style: TextStyle(color: palette.searchHint, fontSize: 13), - ), - features: [ - InputFeature.leading(Icon(Icons.search_rounded, size: 15, color: palette.textMuted)), - ], - onChanged: (value) => setDialogState(() => filterQuery = value), - ), + HistorySearchField( + controller: filterController, + onChanged: (value) => setDialogState(() => filterQuery = value), ), const SizedBox(height: 10), Expanded( diff --git a/ui/flutter/lib/features/tasks/presentation/widgets/history_search_field.dart b/ui/flutter/lib/features/tasks/presentation/widgets/history_search_field.dart new file mode 100644 index 00000000..b46485f0 --- /dev/null +++ b/ui/flutter/lib/features/tasks/presentation/widgets/history_search_field.dart @@ -0,0 +1,38 @@ +import 'package:shadcn_flutter/shadcn_flutter.dart'; + +import '../../../../l10n/l10n.dart'; +import '../../../../shared/theme/app_design_tokens.dart'; +import '../../../../shared/theme/app_palette.dart'; +import '../../../../shared/widgets/app_text_field.dart'; + +class HistorySearchField extends StatelessWidget { + const HistorySearchField({super.key, required this.controller, required this.onChanged}); + + final TextEditingController controller; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + final palette = AppPalette.of(context); + const textStyle = TextStyle(fontSize: 13); + return ConstrainedBox( + // Allow large accessibility text to grow instead of clipping the line. + constraints: const BoxConstraints(minHeight: 38), + child: AppTextField( + key: const ValueKey('create-history-filter'), + controller: controller, + style: textStyle, + padding: AppDesignTokens.compactTextFieldPadding, + placeholder: Text( + context.l10n.searchHistory, + // Match the editable text metrics so the cursor and hint align. + style: textStyle.copyWith(color: palette.searchHint), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + features: [InputFeature.leading(Icon(Icons.search_rounded, size: 15, color: palette.textMuted))], + onChanged: onChanged, + ), + ); + } +} diff --git a/ui/flutter/test/features/tasks/presentation/history_search_field_test.dart b/ui/flutter/test/features/tasks/presentation/history_search_field_test.dart new file mode 100644 index 00000000..0bf81cc1 --- /dev/null +++ b/ui/flutter/test/features/tasks/presentation/history_search_field_test.dart @@ -0,0 +1,59 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:gopeed/features/tasks/presentation/widgets/history_search_field.dart'; +import 'package:gopeed/l10n/l10n.dart'; +import 'package:gopeed/shared/theme/app_component_themes.dart'; +import 'package:gopeed/shared/theme/app_theme.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart' as shad; + +void main() { + for (final platform in [TargetPlatform.android, TargetPlatform.iOS, TargetPlatform.macOS]) { + for (final scale in [1.0, 1.5, 2.0]) { + testWidgets('history hint and input line align on $platform at scale $scale', (tester) async { + debugDefaultTargetPlatformOverride = platform; + addTearDown(() => debugDefaultTargetPlatformOverride = null); + final controller = TextEditingController(); + addTearDown(controller.dispose); + String? query; + for (final width in [280.0, 360.0, 600.0]) { + await tester.pumpWidget( + shad.ShadcnApp( + theme: AppTheme.light(), + materialTheme: AppTheme.materialLight(), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: AppComponentThemes( + child: Builder( + builder: (context) => Localizations.override( + context: context, + locale: const Locale('zh'), + child: MediaQuery( + data: MediaQueryData(textScaler: TextScaler.linear(scale)), + child: Center( + child: SizedBox( + width: width, + child: HistorySearchField(controller: controller, onChanged: (value) => query = value), + ), + ), + ), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + final editable = tester.state(find.byType(EditableText)).renderEditable; + final hint = tester.renderObject(find.text('搜索历史记录…')); + expect(editable.preferredLineHeight, lessThanOrEqualTo(editable.size.height)); + expect(hint.size.height, closeTo(editable.preferredLineHeight, 0.1)); + expect(hint.localToGlobal(Offset.zero).dy, closeTo(editable.localToGlobal(Offset.zero).dy, 0.1)); + expect(tester.takeException(), isNull); + } + await tester.enterText(find.byType(EditableText), 'example'); + expect(query, 'example'); + debugDefaultTargetPlatformOverride = null; + }); + } + } +} From 8fa9a1e9021503ae57048c8018d8fbae2d5479a8 Mon Sep 17 00:00:00 2001 From: Levi Date: Sat, 12 Sep 2026 21:44:27 +0800 Subject: [PATCH 5/9] fix(ui): preserve mobile home route and confirm Android exit (#1507) --- ui/flutter/lib/app/router/app_router.dart | 84 +++++---- .../lib/app/router/mobile_exit_guard.dart | 61 +++++++ ui/flutter/lib/app/router/runtime_page.dart | 30 ++++ .../lib/app/router/shells/main_shell.dart | 46 +---- .../app_exit_confirmation_controller.dart | 38 ++++- ui/flutter/lib/shared/widgets/app_toast.dart | 43 +++-- .../test/app/router/app_router_test.dart | 36 ++++ .../app/router/app_startup_back_test.dart | 65 +++++++ .../app/router/mobile_exit_guard_test.dart | 161 ++++++++++++++++++ ui/flutter/test/widget_test.dart | 16 +- 10 files changed, 484 insertions(+), 96 deletions(-) create mode 100644 ui/flutter/lib/app/router/mobile_exit_guard.dart create mode 100644 ui/flutter/lib/app/router/runtime_page.dart create mode 100644 ui/flutter/test/app/router/app_router_test.dart create mode 100644 ui/flutter/test/app/router/app_startup_back_test.dart create mode 100644 ui/flutter/test/app/router/mobile_exit_guard_test.dart diff --git a/ui/flutter/lib/app/router/app_router.dart b/ui/flutter/lib/app/router/app_router.dart index 05056135..c1d7ee62 100644 --- a/ui/flutter/lib/app/router/app_router.dart +++ b/ui/flutter/lib/app/router/app_router.dart @@ -1,3 +1,4 @@ +import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; import 'package:go_router/go_router.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -15,6 +16,8 @@ import '../../features/tasks/domain/task_record.dart'; import '../../features/tasks/presentation/pages/task_details_page.dart'; import '../../features/tasks/presentation/pages/task_files_page.dart'; import 'shells/main_shell.dart'; +import 'mobile_exit_guard.dart'; +import 'runtime_page.dart'; class AppRouter { static final GlobalKey rootNavigatorKey = GlobalKey(); @@ -27,6 +30,36 @@ class AppRouter { } static GoRouter build(WebAuthController webAuthController) { + // Keep desktop and web sections as peers; native mobile retains home below them. + final isMobile = + !kIsWeb && (defaultTargetPlatform == TargetPlatform.android || defaultTargetPlatform == TargetPlatform.iOS); + final sectionRoutes = [ + GoRoute( + path: isMobile ? 'extensions' : '/extensions', + builder: _runtimePage((context, state) => const ExtensionsPage()), + routes: [ + GoRoute( + path: ':id', + builder: _runtimePage( + (context, state) => ExtensionDetailsPage( + extensionId: state.pathParameters['id'] ?? '', + initialItem: state.extra is ExtensionListItem ? state.extra as ExtensionListItem : null, + ), + ), + ), + ], + ), + GoRoute( + path: isMobile ? 'settings' : '/settings', + builder: _runtimePage((context, state) => const SettingsPage()), + routes: [ + GoRoute( + path: ':section', + builder: _runtimePage((context, state) => SettingsPage(sectionKey: state.pathParameters['section'])), + ), + ], + ), + ]; return GoRouter( navigatorKey: rootNavigatorKey, refreshListenable: webAuthController, @@ -44,60 +77,47 @@ class AppRouter { routes: [ GoRoute(path: '/login', parentNavigatorKey: rootNavigatorKey, builder: (context, state) => const LoginPage()), ShellRoute( - builder: (context, state, child) => MainShell(child: child), + builder: (context, state, child) => MobileExitGuard(child: MainShell(child: child)), routes: [ GoRoute( path: '/', - builder: (context, state) => const HomePage(), + builder: _runtimePage((context, state) => const HomePage()), routes: [ - GoRoute(path: 'create', builder: (context, state) => const CreateTaskWindowPage()), + GoRoute(path: 'create', builder: _runtimePage((context, state) => const CreateTaskWindowPage())), GoRoute( path: 'tasks/:id', - builder: (context, state) => TaskDetailsPage( - taskId: state.pathParameters['id'] ?? '', - initialTask: state.extra is TaskRecord ? state.extra as TaskRecord : null, + builder: _runtimePage( + (context, state) => TaskDetailsPage( + taskId: state.pathParameters['id'] ?? '', + initialTask: state.extra is TaskRecord ? state.extra as TaskRecord : null, + ), ), routes: [ GoRoute( path: 'files', - builder: (context, state) => TaskFilesPage( - taskId: state.pathParameters['id'] ?? '', - initialTask: state.extra is TaskRecord ? state.extra as TaskRecord : null, + builder: _runtimePage( + (context, state) => TaskFilesPage( + taskId: state.pathParameters['id'] ?? '', + initialTask: state.extra is TaskRecord ? state.extra as TaskRecord : null, + ), ), ), ], ), + if (isMobile) ...sectionRoutes, ], ), - GoRoute( - path: '/extensions', - builder: (context, state) => const ExtensionsPage(), - routes: [ - GoRoute( - path: ':id', - builder: (context, state) => ExtensionDetailsPage( - extensionId: state.pathParameters['id'] ?? '', - initialItem: state.extra is ExtensionListItem ? state.extra as ExtensionListItem : null, - ), - ), - ], - ), - GoRoute( - path: '/settings', - builder: (context, state) => const SettingsPage(), - routes: [ - GoRoute( - path: ':section', - builder: (context, state) => SettingsPage(sectionKey: state.pathParameters['section']), - ), - ], - ), + if (!isMobile) ...sectionRoutes, ], ), ], ); } + static GoRouterWidgetBuilder _runtimePage(GoRouterWidgetBuilder builder) { + return (context, state) => RuntimePage(builder: (context) => builder(context, state)); + } + static String _safeReturnLocation(String? location) { if (location == null || location.isEmpty) return '/'; final uri = Uri.tryParse(location); diff --git a/ui/flutter/lib/app/router/mobile_exit_guard.dart b/ui/flutter/lib/app/router/mobile_exit_guard.dart new file mode 100644 index 00000000..5f792dbc --- /dev/null +++ b/ui/flutter/lib/app/router/mobile_exit_guard.dart @@ -0,0 +1,61 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; + +import '../../shared/navigation/app_exit_confirmation_controller.dart'; + +/// Handles exit at the shell route after the nested Navigator declines a pop. +/// Child routes and dialogs still pop through GoRouter before reaching this guard. +class MobileExitGuard extends StatefulWidget { + const MobileExitGuard({super.key, required this.child}); + + final Widget child; + + @override + State createState() => _MobileExitGuardState(); +} + +class _MobileExitGuardState extends State with WidgetsBindingObserver { + final _controller = AppExitConfirmationController(); + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addObserver(this); + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + if (state != AppLifecycleState.resumed) _controller.dispose(); + } + + @override + void dispose() { + WidgetsBinding.instance.removeObserver(this); + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + if (kIsWeb || defaultTargetPlatform != TargetPlatform.android) return widget.child; + + return PopScope( + canPop: false, + onPopInvokedWithResult: (didPop, _) { + if (!didPop) unawaited(_controller.handleBack(context)); + }, + child: NotificationListener( + onNotification: (notification) { + if (notification.canHandlePop) return false; + // An empty nested back stack still has this shell's exit guard. + // Otherwise its startup notification disables Android's back callback. + const NavigationNotification(canHandlePop: true).dispatch(context); + return true; + }, + child: widget.child, + ), + ); + } +} diff --git a/ui/flutter/lib/app/router/runtime_page.dart b/ui/flutter/lib/app/router/runtime_page.dart new file mode 100644 index 00000000..e63fdfed --- /dev/null +++ b/ui/flutter/lib/app/router/runtime_page.dart @@ -0,0 +1,30 @@ +import 'package:shadcn_flutter/shadcn_flutter.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../application/app_runtime_controller.dart'; +import '../../l10n/l10n.dart'; + +/// Gates page content without unmounting the Navigator during startup. +class RuntimePage extends ConsumerWidget { + const RuntimePage({super.key, required this.builder}); + + final WidgetBuilder builder; + + @override + Widget build(BuildContext context, WidgetRef ref) { + return ref + .watch(appRuntimeControllerProvider) + .when( + loading: () => const Scaffold(child: Center(child: CircularProgressIndicator())), + error: (error, _) => Scaffold( + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 420), + child: Text(context.l10n.runtimeInitializationFailed(error.toString()), textAlign: TextAlign.center), + ), + ), + ), + data: (_) => builder(context), + ); + } +} diff --git a/ui/flutter/lib/app/router/shells/main_shell.dart b/ui/flutter/lib/app/router/shells/main_shell.dart index 7b5e560c..4af3f1de 100644 --- a/ui/flutter/lib/app/router/shells/main_shell.dart +++ b/ui/flutter/lib/app/router/shells/main_shell.dart @@ -4,7 +4,6 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart' as shad; import '../../../api/model/create_task.dart'; import '../../application/app_deep_link_controller.dart'; @@ -16,10 +15,7 @@ import '../../../features/tasks/application/pending_create_task.dart'; import '../../../features/tasks/application/pending_update_task.dart'; import '../../../features/tasks/application/tasks_controller.dart'; import '../../../features/tasks/presentation/widgets/pending_update_dialog.dart'; -import '../../../l10n/l10n.dart'; import '../../../core/window/app_window_launcher.dart'; -import '../../../core/utils/breakpoints.dart'; -import '../../../shared/navigation/app_exit_confirmation_controller.dart'; import '../../../shared/widgets/app_toast.dart'; class MainShell extends ConsumerStatefulWidget { @@ -34,7 +30,6 @@ class MainShell extends ConsumerStatefulWidget { class _MainShellState extends ConsumerState { String? _shownUpdateVersion; bool _handlingPendingUpdateRequest = false; - final _exitConfirmationController = AppExitConfirmationController(); @override Widget build(BuildContext context) { @@ -65,40 +60,13 @@ class _MainShellState extends ConsumerState { } }); final runtime = ref.watch(appRuntimeControllerProvider); - final content = runtime.when( - loading: () => const shad.Scaffold(child: Center(child: shad.CircularProgressIndicator())), - error: (error, _) => shad.Scaffold( - child: Center( - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 420), - child: Text(context.l10n.runtimeInitializationFailed(error.toString()), textAlign: TextAlign.center), - ), - ), - ), - data: (_) { - ref.watch(appPlatformControllerProvider); - ref.watch(appDeepLinkControllerProvider); - ref.watch(appNotificationControllerProvider); - return widget.child; - }, - ); - return _buildMobileBackGuard(context, content); - } - - Widget _buildMobileBackGuard(BuildContext context, Widget child) { - final isAndroidMobile = - !kIsWeb && - defaultTargetPlatform == TargetPlatform.android && - MediaQuery.sizeOf(context).width < Breakpoints.mobile; - if (!isAndroidMobile || context.canPop()) return child; - - return PopScope( - canPop: false, - onPopInvokedWithResult: (didPop, _) { - if (!didPop) unawaited(_exitConfirmationController.handleBack(context)); - }, - child: child, - ); + if (runtime.hasValue) { + ref.watch(appPlatformControllerProvider); + ref.watch(appDeepLinkControllerProvider); + ref.watch(appNotificationControllerProvider); + } + // Keep the nested Navigator mounted even while the runtime is starting. + return widget.child; } void _schedulePendingUpdateRequest(PendingUpdateRequest request) { diff --git a/ui/flutter/lib/shared/navigation/app_exit_confirmation_controller.dart b/ui/flutter/lib/shared/navigation/app_exit_confirmation_controller.dart index 4673ec49..87357fa0 100644 --- a/ui/flutter/lib/shared/navigation/app_exit_confirmation_controller.dart +++ b/ui/flutter/lib/shared/navigation/app_exit_confirmation_controller.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/services.dart'; import 'package:flutter/widgets.dart'; @@ -7,18 +9,40 @@ import '../widgets/app_toast.dart'; class AppExitConfirmationController { static const confirmationWindow = Duration(seconds: 2); - DateTime? _lastBackAt; + OverlayEntry? _toast; + Timer? _timer; Future handleBack(BuildContext context) async { - final now = DateTime.now(); - final lastBackAt = _lastBackAt; - if (lastBackAt != null && now.difference(lastBackAt) <= confirmationWindow) { - _lastBackAt = null; + if (_toast != null) { + dispose(); await SystemNavigator.pop(); return; } - _lastBackAt = now; - showAppToast(context, context.l10n.pressBackAgainToExit); + final message = context.l10n.pressBackAgainToExit; + final themes = InheritedTheme.capture(from: context, to: Overlay.of(context, rootOverlay: true).context); + _toast = OverlayEntry( + builder: (_) => IgnorePointer( + child: SafeArea( + child: Align( + alignment: Alignment.bottomCenter, + child: Padding( + padding: const EdgeInsets.all(24), + child: themes.wrap(AppToastContent(message: message)), + ), + ), + ), + ), + ); + Overlay.of(context, rootOverlay: true).insert(_toast!); + _timer = Timer(confirmationWindow, dispose); + } + + void dispose() { + _timer?.cancel(); + _timer = null; + _toast?.remove(); + _toast?.dispose(); + _toast = null; } } diff --git a/ui/flutter/lib/shared/widgets/app_toast.dart b/ui/flutter/lib/shared/widgets/app_toast.dart index eb896516..265bd2f0 100644 --- a/ui/flutter/lib/shared/widgets/app_toast.dart +++ b/ui/flutter/lib/shared/widgets/app_toast.dart @@ -11,11 +11,6 @@ enum AppToastType { info, success, error } /// short messages stay compact while long messages wrap before becoming wider /// than the configured maximum. Errors and normal notices use the same layout. void showAppToast(BuildContext context, String message, {AppToastType type = AppToastType.info}) { - final palette = AppPalette.of(context); - final borderColor = switch (type) { - AppToastType.error => palette.error.withValues(alpha: 0.55), - AppToastType.success || AppToastType.info => palette.border, - }; shad.showToast( context: context, location: shad.ToastLocation.topCenter, @@ -23,16 +18,34 @@ void showAppToast(BuildContext context, String message, {AppToastType type = App alignment: Alignment.topCenter, widthFactor: 1, heightFactor: 1, - child: Container( - key: const ValueKey('app-toast-content'), - padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), - decoration: BoxDecoration( - color: palette.cardBg, - borderRadius: BorderRadius.circular(8), - border: Border.all(color: borderColor), - ), - child: Text(message, style: TextStyle(color: palette.textPrimary, fontSize: 13, height: 1.2)), - ), + child: AppToastContent(message: message, type: type), ), ); } + +/// Shared toast surface for edge notifications and centered exit prompts. +class AppToastContent extends StatelessWidget { + const AppToastContent({super.key, required this.message, this.type = AppToastType.info}); + + final String message; + final AppToastType type; + + @override + Widget build(BuildContext context) { + final palette = AppPalette.of(context); + final borderColor = switch (type) { + AppToastType.error => palette.error.withValues(alpha: 0.55), + AppToastType.success || AppToastType.info => palette.border, + }; + return Container( + key: const ValueKey('app-toast-content'), + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), + decoration: BoxDecoration( + color: palette.cardBg, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: borderColor), + ), + child: Text(message, style: TextStyle(color: palette.textPrimary, fontSize: 13, height: 1.2)), + ); + } +} diff --git a/ui/flutter/test/app/router/app_router_test.dart b/ui/flutter/test/app/router/app_router_test.dart new file mode 100644 index 00000000..bc0bea1c --- /dev/null +++ b/ui/flutter/test/app/router/app_router_test.dart @@ -0,0 +1,36 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.dart'; +import 'package:gopeed/app/router/app_router.dart'; +import 'package:gopeed/features/auth/application/web_auth_controller.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + for (final platform in TargetPlatform.values) { + test('$platform preserves the expected section route stacks', () { + debugDefaultTargetPlatformOverride = platform; + addTearDown(() => debugDefaultTargetPlatformOverride = null); + final auth = WebAuthController(); + final router = AppRouter.build(auth); + addTearDown(router.dispose); + addTearDown(auth.dispose); + final isMobile = platform == TargetPlatform.android || platform == TargetPlatform.iOS; + + for (final entry in { + '/': ['/'], + '/extensions': isMobile ? ['/', 'extensions'] : ['/extensions'], + '/extensions/example': isMobile ? ['/', 'extensions', ':id'] : ['/extensions', ':id'], + '/settings': isMobile ? ['/', 'settings'] : ['/settings'], + '/settings/general': isMobile ? ['/', 'settings', ':section'] : ['/settings', ':section'], + '/tasks/example': ['/', 'tasks/:id'], + '/create': ['/', 'create'], + }.entries) { + final match = router.configuration.findMatch(Uri.parse(entry.key)); + expect(match.isError, isFalse); + final shell = match.matches.single as ShellRouteMatch; + expect(shell.matches.map((match) => (match.route as GoRoute).path), entry.value); + } + }); + } +} diff --git a/ui/flutter/test/app/router/app_startup_back_test.dart b/ui/flutter/test/app/router/app_startup_back_test.dart new file mode 100644 index 00000000..6cf89992 --- /dev/null +++ b/ui/flutter/test/app/router/app_startup_back_test.dart @@ -0,0 +1,65 @@ +import 'dart:async'; + +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:gopeed/app/application/app_runtime_controller.dart'; +import 'package:gopeed/app/router/app_router.dart'; +import 'package:gopeed/features/auth/application/web_auth_controller.dart'; +import 'package:gopeed/shared/theme/app_theme.dart'; +import 'package:gopeed/shared/widgets/app_toast.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart' as shad; + +class _StartingRuntime extends AppRuntimeController { + final pending = Completer(); + + @override + Future build() => pending.future; +} + +void main() { + testWidgets( + 'cold startup registers Android back and confirms exit before runtime is ready', + (tester) async { + final platformCalls = []; + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.platform, (call) async { + platformCalls.add(call); + return null; + }); + addTearDown(() => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.platform, null)); + final runtime = _StartingRuntime(); + final auth = WebAuthController(); + final router = AppRouter.build(auth); + addTearDown(router.dispose); + addTearDown(auth.dispose); + tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed); + await tester.pumpWidget( + ProviderScope( + overrides: [appRuntimeControllerProvider.overrideWith(() => runtime)], + child: shad.ShadcnApp.router( + theme: AppTheme.dark(), + materialTheme: AppTheme.materialDark(), + routerConfig: router, + ), + ), + ); + await tester.pump(); + await tester.pump(); + expect(find.byType(shad.CircularProgressIndicator), findsOneWidget); + expect( + platformCalls.where((call) => call.method == 'SystemNavigator.setFrameworkHandlesBack').last.arguments, + isTrue, + ); + await tester.binding.handlePopRoute(); + await tester.pump(); + expect(platformCalls.where((call) => call.method == 'SystemNavigator.pop'), isEmpty); + expect(find.byType(AppToastContent), findsOneWidget); + await tester.binding.handlePopRoute(); + await tester.pump(); + expect(platformCalls.where((call) => call.method == 'SystemNavigator.pop'), hasLength(1)); + await tester.pumpWidget(const SizedBox()); + }, + variant: TargetPlatformVariant.only(TargetPlatform.android), + ); +} diff --git a/ui/flutter/test/app/router/mobile_exit_guard_test.dart b/ui/flutter/test/app/router/mobile_exit_guard_test.dart new file mode 100644 index 00000000..1d70561a --- /dev/null +++ b/ui/flutter/test/app/router/mobile_exit_guard_test.dart @@ -0,0 +1,161 @@ +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.dart'; +import 'package:gopeed/app/router/mobile_exit_guard.dart'; +import 'package:gopeed/shared/navigation/app_exit_confirmation_controller.dart'; +import 'package:gopeed/shared/theme/app_theme.dart'; +import 'package:gopeed/shared/widgets/app_toast.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart' as shad; + +void main() { + late List exits; + late GoRouter router; + + setUp(() { + exits = []; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.platform, + (call) async { + if (call.method == 'SystemNavigator.pop') exits.add(call); + return null; + }, + ); + router = GoRouter( + routes: [ + ShellRoute( + builder: (_, _, child) => MobileExitGuard(child: child), + routes: [ + GoRoute( + path: '/', + builder: (_, _) => const SizedBox.expand(), + routes: [ + GoRoute(path: 'details', builder: (_, _) => const SizedBox.expand()), + for (final path in ['extensions', 'settings']) + GoRoute(path: path, builder: (_, _) => const SizedBox.expand()), + ], + ), + ], + ), + ], + ); + }); + + tearDown(() { + router.dispose(); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.platform, + null, + ); + }); + + Future mount(WidgetTester tester) async { + await tester.pumpWidget( + shad.ShadcnApp.router(theme: AppTheme.dark(), materialTheme: AppTheme.materialDark(), routerConfig: router), + ); + await tester.pumpAndSettle(); + } + + testWidgets('root back shows bottom prompt, second back exits', (tester) async { + await mount(tester); + await tester.binding.handlePopRoute(); + await tester.pump(); + expect(exits, isEmpty); + expect(find.byType(AppToastContent), findsOneWidget); + expect(tester.getCenter(find.byType(AppToastContent)).dx, 400); + expect(tester.getBottomLeft(find.byType(AppToastContent)).dy, 576); + await tester.pump(const Duration(milliseconds: 1900)); + await tester.binding.handlePopRoute(); + await tester.pump(); + expect(exits, hasLength(1)); + expect(find.byType(AppToastContent), findsNothing); + }, variant: TargetPlatformVariant.only(TargetPlatform.android)); + + testWidgets('expired prompt requires a fresh pair of back actions', (tester) async { + await mount(tester); + await tester.binding.handlePopRoute(); + await tester.pump(); + await tester.pump(AppExitConfirmationController.confirmationWindow); + expect(find.byType(AppToastContent), findsNothing); + await tester.binding.handlePopRoute(); + await tester.pump(); + expect(exits, isEmpty); + expect(find.byType(AppToastContent), findsOneWidget); + await tester.pumpWidget(const SizedBox()); + }, variant: TargetPlatformVariant.only(TargetPlatform.android)); + + testWidgets('nested page pops without prompting or exiting', (tester) async { + await mount(tester); + router.push('/details'); + await tester.pumpAndSettle(); + await tester.binding.handlePopRoute(); + await tester.pumpAndSettle(); + expect(router.routeInformationProvider.value.uri.path, '/'); + expect(find.byType(AppToastContent), findsNothing); + expect(exits, isEmpty); + }, variant: TargetPlatformVariant.only(TargetPlatform.android)); + testWidgets('backgrounding clears the exit confirmation', (tester) async { + await mount(tester); + await tester.binding.handlePopRoute(); + await tester.pump(); + tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.inactive); + tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed); + await tester.pump(); + expect(find.byType(AppToastContent), findsNothing); + await tester.binding.handlePopRoute(); + await tester.pump(); + expect(exits, isEmpty); + expect(find.byType(AppToastContent), findsOneWidget); + await tester.pumpWidget(const SizedBox()); + }, variant: TargetPlatformVariant.only(TargetPlatform.android)); + for (final path in ['/extensions', '/settings']) { + testWidgets('$path returns to tasks before confirming exit', (tester) async { + await mount(tester); + router.go(path); + await tester.pumpAndSettle(); + expect(router.canPop(), isTrue); + await tester.binding.handlePopRoute(); + await tester.pumpAndSettle(); + expect(router.routeInformationProvider.value.uri.path, '/'); + expect(find.byType(AppToastContent), findsNothing); + expect(exits, isEmpty); + await tester.binding.handlePopRoute(); + await tester.pump(); + expect(find.byType(AppToastContent), findsOneWidget); + expect(exits, isEmpty); + await tester.binding.handlePopRoute(); + await tester.pump(); + expect(exits, hasLength(1)); + }, variant: TargetPlatformVariant.only(TargetPlatform.android)); + } + testWidgets( + 'dialog back dismisses the dialog before exit confirmation', + (tester) async { + await mount(tester); + final context = router.routerDelegate.navigatorKey.currentContext!; + showGeneralDialog( + context: context, + pageBuilder: (_, _, _) => const Center(child: Text('Dialog content')), + ); + await tester.pumpAndSettle(); + await tester.binding.handlePopRoute(); + await tester.pumpAndSettle(); + expect(find.text('Dialog content'), findsNothing); + expect(find.byType(AppToastContent), findsNothing); + expect(exits, isEmpty); + }, + variant: TargetPlatformVariant.only(TargetPlatform.android), + ); + + testWidgets('bottom prompt stays above the Android safe area', (tester) async { + tester.view.padding = const FakeViewPadding(bottom: 48); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPadding); + addTearDown(tester.view.resetDevicePixelRatio); + await mount(tester); + await tester.binding.handlePopRoute(); + await tester.pump(); + expect(tester.getBottomLeft(find.byType(AppToastContent)).dy, tester.view.physicalSize.height - 48 - 24); + await tester.pumpWidget(const SizedBox()); + }, variant: TargetPlatformVariant.only(TargetPlatform.android)); +} diff --git a/ui/flutter/test/widget_test.dart b/ui/flutter/test/widget_test.dart index 7d95ab0e..4db9414e 100644 --- a/ui/flutter/test/widget_test.dart +++ b/ui/flutter/test/widget_test.dart @@ -12,6 +12,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:go_router/go_router.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart' as shad; import 'package:window_manager/window_manager.dart'; +import 'package:gopeed/features/home/presentation/pages/home_page.dart'; import 'package:gopeed/app/app.dart'; import 'package:gopeed/app/router/app_router.dart'; import 'package:gopeed/core/capabilities/app_capabilities.dart'; @@ -2269,15 +2270,16 @@ void main() { expect(tester.takeException(), isNull); }); - testWidgets('settings mobile back returns one level before requiring a second back to exit', ( - WidgetTester tester, - ) async { + testWidgets('settings mobile back returns through home before confirming exit', (WidgetTester tester) async { await _setTestSize(tester, const Size(390, 760)); debugDefaultTargetPlatformOverride = TargetPlatform.android; addTearDown(() => debugDefaultTargetPlatformOverride = null); var systemPopCalls = 0; + bool? frameworkHandlesBack; + tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed); tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.platform, (call) async { if (call.method == 'SystemNavigator.pop') systemPopCalls++; + if (call.method == 'SystemNavigator.setFrameworkHandlesBack') frameworkHandlesBack = call.arguments as bool; return null; }); addTearDown(() { @@ -2296,6 +2298,7 @@ void main() { ); await tester.pumpAndSettle(); + expect(frameworkHandlesBack, isTrue); await tester.tap(find.text('Settings')); await tester.pumpAndSettle(); await tester.tap(find.text('Basic')); @@ -2309,6 +2312,13 @@ void main() { expect(find.byKey(const ValueKey('theme-mode-system')), findsNothing); expect(systemPopCalls, 0); + await tester.binding.handlePopRoute(); + await tester.pumpAndSettle(); + expect(find.byType(HomePage), findsOneWidget); + expect(find.text('Press back again to exit'), findsNothing); + expect(systemPopCalls, 0); + expect(frameworkHandlesBack, isTrue); + await tester.binding.handlePopRoute(); await tester.pump(); expect(find.text('Press back again to exit'), findsOneWidget); From 62e8ab3cefcb6e2c231e76f1b42628723307570c Mon Sep 17 00:00:00 2001 From: Levi Date: Sun, 13 Sep 2026 11:57:26 +0800 Subject: [PATCH 6/9] fix(ui): show downloaded bytes for tasks with unknown size (#1508) --- .../presentation/widgets/task_card/task_card_parts.dart | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/ui/flutter/lib/features/tasks/presentation/widgets/task_card/task_card_parts.dart b/ui/flutter/lib/features/tasks/presentation/widgets/task_card/task_card_parts.dart index 19b34abe..ac08f324 100644 --- a/ui/flutter/lib/features/tasks/presentation/widgets/task_card/task_card_parts.dart +++ b/ui/flutter/lib/features/tasks/presentation/widgets/task_card/task_card_parts.dart @@ -95,7 +95,11 @@ class TaskCardFooter extends StatelessWidget { children: [ Expanded( child: Text( - task.total != null ? '${task.downloaded} / ${task.total}' : context.l10n.unknownSize, + task.total != null + ? '${task.downloaded} / ${task.total}' + : task.downloadedBytes == 0 + ? context.l10n.unknownSize + : task.downloaded, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(color: leftColor, fontSize: 10, height: 1), From a29f4bb42a4c8c73a986946abf15de9ce6546771 Mon Sep 17 00:00:00 2001 From: Levi Date: Sun, 13 Sep 2026 14:29:46 +0800 Subject: [PATCH 7/9] fix(ui): use typed controls for extension settings (#1509) --- .../presentation/pages/extensions_page.dart | 36 +------- .../widgets/extension_setting_field.dart | 87 +++++++++++++++++++ .../widgets/extension_setting_field_test.dart | 79 +++++++++++++++++ 3 files changed, 168 insertions(+), 34 deletions(-) create mode 100644 ui/flutter/lib/features/extensions/presentation/widgets/extension_setting_field.dart create mode 100644 ui/flutter/test/features/extensions/presentation/widgets/extension_setting_field_test.dart diff --git a/ui/flutter/lib/features/extensions/presentation/pages/extensions_page.dart b/ui/flutter/lib/features/extensions/presentation/pages/extensions_page.dart index 5aa57e6a..b77cbe21 100644 --- a/ui/flutter/lib/features/extensions/presentation/pages/extensions_page.dart +++ b/ui/flutter/lib/features/extensions/presentation/pages/extensions_page.dart @@ -24,6 +24,7 @@ import '../../application/extensions_controller.dart'; import '../../application/pending_extension_install.dart'; import '../widgets/extension_detail_view.dart'; import '../widgets/extension_icon.dart'; +import '../widgets/extension_setting_field.dart'; const _extensionCardMinWidth = 280.0; const _extensionGridSpacing = 10.0; @@ -1174,7 +1175,7 @@ class _ExtensionSettingsPanel extends StatelessWidget { separatorBuilder: (_, _) => const SizedBox(height: 14), itemBuilder: (context, index) { final setting = settings[index]; - return _ExtensionSettingField(setting: setting, controller: controllers[setting.name]!); + return ExtensionSettingField(setting: setting, controller: controllers[setting.name]!); }, ), ), @@ -1202,39 +1203,6 @@ class _ExtensionSettingsPanel extends StatelessWidget { } } -class _ExtensionSettingField extends StatelessWidget { - const _ExtensionSettingField({required this.setting, required this.controller}); - - final api_extension.Setting setting; - final TextEditingController controller; - - @override - Widget build(BuildContext context) { - final palette = AppPalette.of(context); - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - setting.title, - style: TextStyle(color: palette.textPrimary, fontSize: 13, fontWeight: FontWeight.w700), - ), - if (setting.description.isNotEmpty) ...[ - const SizedBox(height: 4), - Text(setting.description, style: TextStyle(color: palette.textSecondary, fontSize: 12, height: 1.35)), - ], - const SizedBox(height: 8), - if (setting.type == api_extension.SettingType.boolean) - AppTextField(controller: controller, placeholder: Text(context.l10n.booleanValueHint)) - else - AppTextField( - controller: controller, - keyboardType: setting.type == api_extension.SettingType.number ? TextInputType.number : TextInputType.text, - ), - ], - ); - } -} - class _ErrorState extends StatelessWidget { const _ErrorState({required this.error, required this.onRetry}); diff --git a/ui/flutter/lib/features/extensions/presentation/widgets/extension_setting_field.dart b/ui/flutter/lib/features/extensions/presentation/widgets/extension_setting_field.dart new file mode 100644 index 00000000..eb0d8846 --- /dev/null +++ b/ui/flutter/lib/features/extensions/presentation/widgets/extension_setting_field.dart @@ -0,0 +1,87 @@ +import 'package:shadcn_flutter/shadcn_flutter.dart'; +import 'package:flutter/services.dart'; + +import '../../../../api/model/extension.dart'; +import '../../../../shared/theme/app_palette.dart'; +import '../../../../shared/widgets/app_text_field.dart'; + +class ExtensionSettingField extends StatelessWidget { + const ExtensionSettingField({super.key, required this.setting, required this.controller}); + + final Setting setting; + final TextEditingController controller; + + @override + Widget build(BuildContext context) { + final palette = AppPalette.of(context); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + setting.title, + style: TextStyle(color: palette.textPrimary, fontSize: 13, fontWeight: FontWeight.w700), + ), + if (setting.description.isNotEmpty) ...[ + const SizedBox(height: 4), + Text(setting.description, style: TextStyle(color: palette.textSecondary, fontSize: 12, height: 1.35)), + ], + const SizedBox(height: 8), + _buildInput(), + ], + ); + } + + Widget _buildInput() { + if (setting.type == SettingType.boolean) { + return ValueListenableBuilder( + valueListenable: controller, + builder: (context, value, _) => + Switch(value: value.text == 'true', onChanged: (enabled) => controller.text = enabled.toString()), + ); + } + + final options = setting.options; + if (options != null && options.isNotEmpty) { + return ValueListenableBuilder( + valueListenable: controller, + builder: (context, value, _) => LayoutBuilder( + builder: (context, constraints) => Select( + value: options.any((option) => option.value.toString() == value.text) ? value.text : null, + placeholder: Text(setting.title), + constraints: BoxConstraints.tightFor(width: constraints.maxWidth), + popupConstraints: const BoxConstraints(maxHeight: 240), + itemBuilder: (context, selectedValue) => Text( + options.firstWhere((option) => option.value.toString() == selectedValue).label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + popup: (context) => SelectPopup( + items: SelectItemList( + children: [ + for (final option in options) + SelectItemButton(value: option.value.toString(), child: Text(option.label)), + ], + ), + ), + onChanged: (selectedValue) { + if (selectedValue != null) controller.text = selectedValue; + }, + ), + ), + ); + } + + final isNumber = setting.type == SettingType.number; + return AppTextField( + controller: controller, + keyboardType: isNumber ? const TextInputType.numberWithOptions(decimal: true, signed: true) : TextInputType.text, + inputFormatters: isNumber + ? [ + TextInputFormatter.withFunction( + (oldValue, newValue) => RegExp(r'^-?\d*\.?\d*$').hasMatch(newValue.text) ? newValue : oldValue, + ), + ] + : null, + ); + } +} diff --git a/ui/flutter/test/features/extensions/presentation/widgets/extension_setting_field_test.dart b/ui/flutter/test/features/extensions/presentation/widgets/extension_setting_field_test.dart new file mode 100644 index 00000000..8db50b74 --- /dev/null +++ b/ui/flutter/test/features/extensions/presentation/widgets/extension_setting_field_test.dart @@ -0,0 +1,79 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; +import 'package:gopeed/api/model/extension.dart'; +import 'package:gopeed/features/extensions/presentation/widgets/extension_setting_field.dart'; +import 'package:gopeed/shared/widgets/app_text_field.dart'; + +void main() { + Future pumpField(WidgetTester tester, Setting setting, TextEditingController controller) async { + await tester.pumpWidget( + ShadcnApp( + home: Scaffold( + child: Center( + child: SizedBox( + width: 280, + child: ExtensionSettingField(setting: setting, controller: controller), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + } + + Setting setting(SettingType type) => + Setting(name: 'test', title: 'Test setting', description: 'Description', required: false, type: type); + + testWidgets('boolean switch loads and updates the saved value', (tester) async { + final controller = TextEditingController(text: 'true'); + addTearDown(controller.dispose); + await pumpField(tester, setting(SettingType.boolean), controller); + expect(find.byType(AppTextField), findsNothing); + expect(tester.widget(find.byType(Switch)).value, isTrue); + await tester.tap(find.byType(Switch)); + await tester.pumpAndSettle(); + expect(controller.text, 'false'); + expect(tester.widget(find.byType(Switch)).value, isFalse); + }); + + for (final type in [SettingType.string, SettingType.number]) { + testWidgets( + '$type options show labels and save selected values', + (tester) async { + final field = setting(type)..options = [Option(label: 'First', value: 1), Option(label: 'Second', value: 2)]; + final controller = TextEditingController(text: '1'); + addTearDown(controller.dispose); + await pumpField(tester, field, controller); + expect(find.byType(AppTextField), findsNothing); + expect(find.text('First'), findsOneWidget); + await tester.tap(find.byType(Select)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); + await tester.tap(find.text('Second')); + await tester.pumpAndSettle(); + expect(controller.text, '2'); + expect(find.text('Second'), findsOneWidget); + expect(tester.takeException(), isNull); + }, + variant: TargetPlatformVariant({TargetPlatform.android, TargetPlatform.macOS}), + ); + } + + testWidgets('number field accepts decimals and rejects nonnumeric input', (tester) async { + final controller = TextEditingController(); + addTearDown(controller.dispose); + await pumpField(tester, setting(SettingType.number), controller); + await tester.enterText(find.byType(AppTextField), '-12.5'); + expect(controller.text, '-12.5'); + await tester.enterText(find.byType(AppTextField), 'invalid'); + expect(controller.text, '-12.5'); + }); + + testWidgets('string with empty options remains a text field', (tester) async { + final controller = TextEditingController(text: 'original'); + addTearDown(controller.dispose); + await pumpField(tester, setting(SettingType.string)..options = [], controller); + await tester.enterText(find.byType(AppTextField), 'updated'); + expect(controller.text, 'updated'); + }); +} From a760290213a5e8ee17dedee9cf21ee3d9843eb97 Mon Sep 17 00:00:00 2001 From: Levi Date: Sun, 13 Sep 2026 23:30:45 +0800 Subject: [PATCH 8/9] fix(ui): enable task detail text selection and compact extension stats (#1511) --- .../core/utils/compact_count_formatter.dart | 23 +++ .../presentation/pages/extensions_page.dart | 162 +++++++++++------- .../widgets/extension_detail_view.dart | 5 +- .../presentation/widgets/task_drawer.dart | 5 +- .../utils/compact_count_formatter_test.dart | 26 +++ ui/flutter/test/widget_test.dart | 86 +++++++++- 6 files changed, 236 insertions(+), 71 deletions(-) create mode 100644 ui/flutter/lib/core/utils/compact_count_formatter.dart create mode 100644 ui/flutter/test/core/utils/compact_count_formatter_test.dart diff --git a/ui/flutter/lib/core/utils/compact_count_formatter.dart b/ui/flutter/lib/core/utils/compact_count_formatter.dart new file mode 100644 index 00000000..f0aadfe1 --- /dev/null +++ b/ui/flutter/lib/core/utils/compact_count_formatter.dart @@ -0,0 +1,23 @@ +abstract final class CompactCountFormatter { + // Counts start compacting at ten thousand by product convention, then use + // progressively larger decimal units so the rendered label stays bounded. + static const _units = [ + (divisor: 1000000000000000000, suffix: 'qi'), + (divisor: 1000000000000000, suffix: 'q'), + (divisor: 1000000000000, suffix: 't'), + (divisor: 1000000000, suffix: 'b'), + (divisor: 1000000, suffix: 'm'), + (divisor: 10000, suffix: 'w'), + ]; + + static String format(int count) { + if (count <= 0) return '0'; + if (count < _units.last.divisor) return count.toString(); + + final unit = _units.firstWhere((unit) => count >= unit.divisor); + final whole = count ~/ unit.divisor; + final tenth = (count % unit.divisor) ~/ (unit.divisor ~/ 10); + final value = whole < 10 && tenth > 0 ? '$whole.$tenth' : '$whole'; + return '$value${unit.suffix}'; + } +} diff --git a/ui/flutter/lib/features/extensions/presentation/pages/extensions_page.dart b/ui/flutter/lib/features/extensions/presentation/pages/extensions_page.dart index b77cbe21..6ccf05c5 100644 --- a/ui/flutter/lib/features/extensions/presentation/pages/extensions_page.dart +++ b/ui/flutter/lib/features/extensions/presentation/pages/extensions_page.dart @@ -11,6 +11,7 @@ import 'package:url_launcher/url_launcher.dart'; import '../../../../api/model/extension.dart' as api_extension; import '../../../../api/model/store_extension.dart'; import '../../../../core/utils/breakpoints.dart'; +import '../../../../core/utils/compact_count_formatter.dart'; import '../../../../core/window/app_window_chrome.dart'; import '../../../../shared/theme/app_design_tokens.dart'; import '../../../../shared/theme/app_palette.dart'; @@ -26,7 +27,7 @@ import '../widgets/extension_detail_view.dart'; import '../widgets/extension_icon.dart'; import '../widgets/extension_setting_field.dart'; -const _extensionCardMinWidth = 280.0; +const _extensionCardMinWidth = 290.0; const _extensionGridSpacing = 10.0; int _extensionGridColumnCount(double width) { @@ -995,71 +996,100 @@ class _ExtensionCard extends ConsumerWidget { const Spacer(), Row( children: [ - if (item.store != null) ...[ - Icon(Icons.star_rounded, size: 15, color: palette.textMuted), - const SizedBox(width: 3), - Text('${item.stars}', style: TextStyle(color: palette.textSecondary, fontSize: 12)), - const SizedBox(width: 10), - Icon(Icons.download_outlined, size: 15, color: palette.textMuted), - const SizedBox(width: 3), - Text('${item.installCount}', style: TextStyle(color: palette.textSecondary, fontSize: 12)), - ], - const Spacer(), - if (canUpdate && installed != null) - shad.GhostButton( - density: shad.ButtonDensity.icon, - onPressed: busy - ? null - : () => - onAction(() => ref.read(extensionsControllerProvider.notifier).upgradeExtension(installed)), - child: const Icon(Icons.refresh), - ), - if (installed == null && item.store != null) - shad.GhostButton( - key: ValueKey('install-store-extension-${item.store!.id}'), - density: shad.ButtonDensity.icon, - onPressed: busy - ? null - : () => onAction( - () => ref.read(extensionsControllerProvider.notifier).installFromStore(item.store!), - ), - child: busy - ? const SizedBox.square(dimension: 14, child: shad.CircularProgressIndicator()) - : const Icon(Icons.download), - ), - if ((item.homepage ?? '').isNotEmpty) - shad.GhostButton( - density: shad.ButtonDensity.icon, - onPressed: () => unawaited(launchUrl(Uri.parse(item.homepage!))), - child: const Icon(Icons.home_outlined), - ), - if ((item.repoUrl ?? '').isNotEmpty) - shad.GhostButton( - density: shad.ButtonDensity.icon, - onPressed: () => unawaited(launchUrl(Uri.parse(item.repoUrl!))), - child: const Icon(Icons.code), - ), - if (installed?.settings?.isNotEmpty == true) - shad.GhostButton( - density: shad.ButtonDensity.icon, - onPressed: busy ? null : () => onOpenSettings(installed!), - child: const Icon(Icons.settings_outlined), - ), - if (installed != null) - shad.GhostButton( - key: ValueKey('remove-extension-${installed.identity}'), - density: shad.ButtonDensity.icon, - onPressed: busy - ? null - : () async { - final confirmed = await _showRemoveExtensionDialog(context, installed.title); - if (!confirmed || !context.mounted) return; - await onAction( - () => ref.read(extensionsControllerProvider.notifier).removeExtension(installed), - ); - }, - child: const Icon(Icons.delete_outline), - ), + if (item.store != null) + Expanded( + child: Align( + alignment: Alignment.centerLeft, + child: FittedBox( + fit: BoxFit.scaleDown, + alignment: Alignment.centerLeft, + child: Row( + key: ValueKey('extension-card-stats-${item.id}'), + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.star_rounded, size: 15, color: palette.textMuted), + const SizedBox(width: 3), + Text( + CompactCountFormatter.format(item.stars), + style: TextStyle(color: palette.textSecondary, fontSize: 12), + ), + const SizedBox(width: 10), + Icon(Icons.download_outlined, size: 15, color: palette.textMuted), + const SizedBox(width: 3), + Text( + CompactCountFormatter.format(item.installCount), + style: TextStyle(color: palette.textSecondary, fontSize: 12), + ), + ], + ), + ), + ), + ) + else + const Spacer(), + const SizedBox(width: 8), + Row( + key: ValueKey('extension-card-actions-${item.id}'), + mainAxisSize: MainAxisSize.min, + children: [ + if (canUpdate && installed != null) + shad.GhostButton( + density: shad.ButtonDensity.icon, + onPressed: busy + ? null + : () => onAction( + () => ref.read(extensionsControllerProvider.notifier).upgradeExtension(installed), + ), + child: const Icon(Icons.refresh), + ), + if (installed == null && item.store != null) + shad.GhostButton( + key: ValueKey('install-store-extension-${item.store!.id}'), + density: shad.ButtonDensity.icon, + onPressed: busy + ? null + : () => onAction( + () => ref.read(extensionsControllerProvider.notifier).installFromStore(item.store!), + ), + child: busy + ? const SizedBox.square(dimension: 14, child: shad.CircularProgressIndicator()) + : const Icon(Icons.download), + ), + if ((item.homepage ?? '').isNotEmpty) + shad.GhostButton( + density: shad.ButtonDensity.icon, + onPressed: () => unawaited(launchUrl(Uri.parse(item.homepage!))), + child: const Icon(Icons.home_outlined), + ), + if ((item.repoUrl ?? '').isNotEmpty) + shad.GhostButton( + density: shad.ButtonDensity.icon, + onPressed: () => unawaited(launchUrl(Uri.parse(item.repoUrl!))), + child: const Icon(Icons.code), + ), + if (installed?.settings?.isNotEmpty == true) + shad.GhostButton( + density: shad.ButtonDensity.icon, + onPressed: busy ? null : () => onOpenSettings(installed!), + child: const Icon(Icons.settings_outlined), + ), + if (installed != null) + shad.GhostButton( + key: ValueKey('remove-extension-${installed.identity}'), + density: shad.ButtonDensity.icon, + onPressed: busy + ? null + : () async { + final confirmed = await _showRemoveExtensionDialog(context, installed.title); + if (!confirmed || !context.mounted) return; + await onAction( + () => ref.read(extensionsControllerProvider.notifier).removeExtension(installed), + ); + }, + child: const Icon(Icons.delete_outline), + ), + ], + ), ], ), ], diff --git a/ui/flutter/lib/features/extensions/presentation/widgets/extension_detail_view.dart b/ui/flutter/lib/features/extensions/presentation/widgets/extension_detail_view.dart index 886be8ef..eed9ec79 100644 --- a/ui/flutter/lib/features/extensions/presentation/widgets/extension_detail_view.dart +++ b/ui/flutter/lib/features/extensions/presentation/widgets/extension_detail_view.dart @@ -10,6 +10,7 @@ import 'package:shadcn_flutter/shadcn_flutter.dart' as shad; import 'package:url_launcher/url_launcher.dart'; import '../../../../api/model/store_extension.dart'; +import '../../../../core/utils/compact_count_formatter.dart'; import '../../../../l10n/l10n.dart'; import '../../../../shared/theme/app_design_tokens.dart'; import '../../../../shared/theme/app_palette.dart'; @@ -338,9 +339,9 @@ class _ExtensionStats extends StatelessWidget { key: const ValueKey('extension-details-stats'), mainAxisSize: MainAxisSize.min, children: [ - _MetadataItem(icon: Icons.star_rounded, value: store.stars.toString()), + _MetadataItem(icon: Icons.star_rounded, value: CompactCountFormatter.format(store.stars)), const SizedBox(width: 12), - _MetadataItem(icon: Icons.download_outlined, value: store.installCount.toString()), + _MetadataItem(icon: Icons.download_outlined, value: CompactCountFormatter.format(store.installCount)), ], ); } diff --git a/ui/flutter/lib/features/tasks/presentation/widgets/task_drawer.dart b/ui/flutter/lib/features/tasks/presentation/widgets/task_drawer.dart index 0c96de5c..49de9689 100644 --- a/ui/flutter/lib/features/tasks/presentation/widgets/task_drawer.dart +++ b/ui/flutter/lib/features/tasks/presentation/widgets/task_drawer.dart @@ -1,7 +1,7 @@ import 'dart:async'; import 'package:flutter/foundation.dart' show TargetPlatform, defaultTargetPlatform, kIsWeb; -import 'package:flutter/material.dart' show Colors, Icons; +import 'package:flutter/material.dart' show Colors, Icons, SelectionArea; import 'package:flutter/widgets.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart' as shad; @@ -122,7 +122,7 @@ class _TaskDetailsViewState extends ConsumerState { ? AppDesignTokens.taskDetailsMobilePadding : AppDesignTokens.taskDetailsDesktopPadding; final runtimeStatus = _tabIndex == 1 ? ref.watch(taskRuntimeStatusProvider(widget.task.id)).value : null; - return Column( + final content = Column( children: [ Container( height: 48, @@ -204,6 +204,7 @@ class _TaskDetailsViewState extends ConsumerState { ), ], ); + return SelectionArea(key: const ValueKey('task-details-selection-area'), child: content); } bool get _canOpenStoragePath { diff --git a/ui/flutter/test/core/utils/compact_count_formatter_test.dart b/ui/flutter/test/core/utils/compact_count_formatter_test.dart new file mode 100644 index 00000000..0cf064ca --- /dev/null +++ b/ui/flutter/test/core/utils/compact_count_formatter_test.dart @@ -0,0 +1,26 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:gopeed/core/utils/compact_count_formatter.dart'; + +void main() { + test('keeps small counts exact and compacts larger counts to a bounded label', () { + expect(CompactCountFormatter.format(-1), '0'); + expect(CompactCountFormatter.format(0), '0'); + expect(CompactCountFormatter.format(9999), '9999'); + expect(CompactCountFormatter.format(10000), '1w'); + expect(CompactCountFormatter.format(12345), '1.2w'); + expect(CompactCountFormatter.format(99999), '9.9w'); + expect(CompactCountFormatter.format(123456), '12w'); + expect(CompactCountFormatter.format(1000000), '1m'); + expect(CompactCountFormatter.format(1234567), '1.2m'); + expect(CompactCountFormatter.format(10000000), '10m'); + expect(CompactCountFormatter.format(12345678), '12m'); + expect(CompactCountFormatter.format(100000000), '100m'); + expect(CompactCountFormatter.format(123456789), '123m'); + expect(CompactCountFormatter.format(1000000000), '1b'); + expect(CompactCountFormatter.format(1234567890), '1.2b'); + expect(CompactCountFormatter.format(12345678901), '12b'); + expect(CompactCountFormatter.format(1234567890123), '1.2t'); + expect(CompactCountFormatter.format(1234567890123456), '1.2q'); + expect(CompactCountFormatter.format(9223372036854775807), '9.2qi'); + }); +} diff --git a/ui/flutter/test/widget_test.dart b/ui/flutter/test/widget_test.dart index 4db9414e..3a6c087c 100644 --- a/ui/flutter/test/widget_test.dart +++ b/ui/flutter/test/widget_test.dart @@ -2,7 +2,7 @@ import 'dart:async'; import 'dart:io'; import 'package:flutter/foundation.dart' show TargetPlatform, debugDefaultTargetPlatformOverride; -import 'package:flutter/material.dart' show Icons, Scrollbar; +import 'package:flutter/material.dart' show Icons, Scrollbar, SelectionArea; import 'package:flutter/gestures.dart' show PointerDeviceKind, kDoubleTapMinTime, kSecondaryMouseButton; import 'package:flutter/rendering.dart' show RenderParagraph; import 'package:flutter/services.dart' show MethodChannel, SystemChannels; @@ -504,6 +504,11 @@ void main() { closeTo(tester.getRect(find.byKey(const ValueKey('extension-card-extension-2'))).right, 0.01), ); + tester.view.physicalSize = const Size(1024, 768); + await tester.pumpAndSettle(); + expect(gridColumns(), 3); + expect(tester.getSize(find.byKey(const ValueKey('extension-card-extension-0'))).width, greaterThanOrEqualTo(290)); + tester.view.physicalSize = const Size(1028, 608); await tester.pumpAndSettle(); expect(gridColumns(), 3); @@ -536,6 +541,31 @@ void main() { expect(tester.takeException(), isNull); }); + testWidgets('extension cards compact large counts without crowding their actions', (WidgetTester tester) async { + await _setTestSize(tester, const Size(722, 608)); + await tester.pumpWidget( + ProviderScope( + overrides: [extensionsControllerProvider.overrideWith(CrowdedExtensionCardController.new)], + child: shad.ShadcnApp( + theme: AppTheme.light(), + materialTheme: AppTheme.materialLight(), + home: const AppComponentThemes(child: ExtensionsPage()), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('1.2w'), findsOneWidget); + expect(find.text('1.2b'), findsOneWidget); + final cardRect = tester.getRect(find.byKey(const ValueKey('extension-card-crowded-extension'))); + final statsRect = tester.getRect(find.byKey(const ValueKey('extension-card-stats-crowded-extension'))); + final actionsRect = tester.getRect(find.byKey(const ValueKey('extension-card-actions-crowded-extension'))); + expect(cardRect.width, closeTo(290, 0.01)); + expect(statsRect.right, lessThanOrEqualTo(actionsRect.left - 8)); + expect(actionsRect.right, lessThanOrEqualTo(cardRect.right - 14)); + expect(tester.takeException(), isNull); + }); + testWidgets('extension details reuse the desktop detail drawer', (WidgetTester tester) async { await _setTestSize(tester, const Size(1100, 900)); final controller = FakeExtensionsController(); @@ -3546,6 +3576,14 @@ void main() { expect(urlValue.data, contains('\u200B')); expect(urlValue.data!.replaceAll('\u200B', ''), task.url); expect(urlValue.semanticsLabel, task.url); + final selectionArea = find.byKey(const ValueKey('task-details-selection-area')); + expect(selectionArea, findsOneWidget); + expect(tester.widget(selectionArea), isA()); + expect(find.ancestor(of: find.text('details.zip'), matching: selectionArea), findsOneWidget); + expect( + find.ancestor(of: find.byKey(const ValueKey('task-details-url-value')), matching: selectionArea), + findsOneWidget, + ); expect(find.text('Task Details'), findsOneWidget); expect(find.text('Task Name'), findsNothing); final detailHeader = tester.widget(find.byKey(const ValueKey('app-detail-drawer-header'))); @@ -5043,6 +5081,52 @@ class FakeExtensionsController extends ExtensionsController { } } +class CrowdedExtensionCardController extends ExtensionsController { + @override + Future build() async { + final installed = + api_extension.Extension( + identity: 'crowded-extension', + name: 'crowded-extension', + author: 'Gopeed', + title: 'Crowded extension', + description: 'An extension with every available card action.', + icon: '', + version: '1.0.0', + homepage: 'https://gopeed.com', + repository: api_extension.Repository(url: 'https://github.com/GopeedLab/gopeed', directory: ''), + disabled: false, + devMode: false, + devPath: '', + ) + ..settings = [ + api_extension.Setting( + name: 'enabled', + title: 'Enabled', + description: 'Enable the extension.', + required: false, + type: api_extension.SettingType.boolean, + ), + ]; + final store = StoreExtension( + id: 'crowded-extension', + repoFullName: 'GopeedLab/gopeed', + repoUrl: 'https://github.com/GopeedLab/gopeed', + name: 'crowded-extension', + author: 'Gopeed', + title: 'Crowded extension', + description: 'An extension with every available card action.', + readme: '# Crowded extension', + homepage: 'https://gopeed.com', + version: '1.1.0', + installCount: 1234567890, + stars: 12345, + topics: ['download'], + ); + return ExtensionsState(installedExtensions: [installed], storeExtensions: [store]); + } +} + class DevReadmeExtensionsController extends FakeExtensionsController { DevReadmeExtensionsController(this.extensionDirectory); From cee5f3336ef0910c4198ad59a1f2f0cf4b910d24 Mon Sep 17 00:00:00 2001 From: Levi Date: Sun, 13 Sep 2026 23:40:59 +0800 Subject: [PATCH 9/9] fix: extension install by deeplink (#1510) --- ui/flutter/ios/Runner/Info.plist | 3 + ui/flutter/ios/Runner/SceneDelegate.swift | 21 + .../application/app_deep_link_controller.dart | 2 +- .../lib/core/utils/file_explorer_native.dart | 2 +- .../presentation/pages/extensions_page.dart | 101 ++- .../widgets/extension_detail_view.dart | 9 +- .../widgets/extension_update_dialog.dart | 90 +++ .../features/tasks/domain/task_record.dart | 2 +- .../pages/create_task_window_page.dart | 3 + .../widgets/task_card/task_card_desktop.dart | 11 +- .../widgets/task_card/task_card_mobile.dart | 6 +- .../widgets/task_card/task_card_parts.dart | 2 +- ui/flutter/lib/l10n/app_ar.arb | 1 + ui/flutter/lib/l10n/app_az.arb | 1 + ui/flutter/lib/l10n/app_ca.arb | 1 + ui/flutter/lib/l10n/app_de.arb | 1 + ui/flutter/lib/l10n/app_en.arb | 8 + ui/flutter/lib/l10n/app_es.arb | 1 + ui/flutter/lib/l10n/app_fa.arb | 1 + ui/flutter/lib/l10n/app_fr.arb | 1 + ui/flutter/lib/l10n/app_hu.arb | 1 + ui/flutter/lib/l10n/app_id.arb | 1 + ui/flutter/lib/l10n/app_it.arb | 1 + ui/flutter/lib/l10n/app_ja.arb | 1 + ui/flutter/lib/l10n/app_ko.arb | 1 + ui/flutter/lib/l10n/app_pl.arb | 1 + ui/flutter/lib/l10n/app_pt.arb | 1 + ui/flutter/lib/l10n/app_ru.arb | 1 + ui/flutter/lib/l10n/app_ta.arb | 1 + ui/flutter/lib/l10n/app_tr.arb | 1 + ui/flutter/lib/l10n/app_uk.arb | 1 + ui/flutter/lib/l10n/app_vi.arb | 1 + ui/flutter/lib/l10n/app_zh.arb | 1 + ui/flutter/lib/l10n/app_zh_TW.arb | 1 + .../test/app_deep_link_controller_test.dart | 48 +- ui/flutter/test/widget_test.dart | 683 +++++++++++++++--- 36 files changed, 869 insertions(+), 143 deletions(-) create mode 100644 ui/flutter/lib/features/extensions/presentation/widgets/extension_update_dialog.dart diff --git a/ui/flutter/ios/Runner/Info.plist b/ui/flutter/ios/Runner/Info.plist index fd2ce83a..4c19e631 100644 --- a/ui/flutter/ios/Runner/Info.plist +++ b/ui/flutter/ios/Runner/Info.plist @@ -24,6 +24,9 @@ ???? CFBundleVersion $(FLUTTER_BUILD_NUMBER) + + FlutterDeepLinkingEnabled + CFBundleURLTypes diff --git a/ui/flutter/ios/Runner/SceneDelegate.swift b/ui/flutter/ios/Runner/SceneDelegate.swift index b9ce8ea2..8b34f6cb 100644 --- a/ui/flutter/ios/Runner/SceneDelegate.swift +++ b/ui/flutter/ios/Runner/SceneDelegate.swift @@ -1,6 +1,27 @@ import Flutter import UIKit +import app_links class SceneDelegate: FlutterSceneDelegate { + override func scene( + _ scene: UIScene, + willConnectTo session: UISceneSession, + options connectionOptions: UIScene.ConnectionOptions + ) { + super.scene(scene, willConnectTo: session, options: connectionOptions) + forwardGopeedLinks(connectionOptions.urlContexts) + } + override func scene(_ scene: UIScene, openURLContexts URLContexts: Set) { + super.scene(scene, openURLContexts: URLContexts) + forwardGopeedLinks(URLContexts) + } + + private func forwardGopeedLinks(_ contexts: Set) { + // app_links 6.x only registers AppDelegate callbacks. UIScene delivers + // both cold-start and running-app scheme links here instead. + for context in contexts where context.url.scheme?.lowercased() == "gopeed" { + AppLinks.shared.handleLink(url: context.url) + } + } } diff --git a/ui/flutter/lib/app/application/app_deep_link_controller.dart b/ui/flutter/lib/app/application/app_deep_link_controller.dart index 548287c9..f960dcbd 100644 --- a/ui/flutter/lib/app/application/app_deep_link_controller.dart +++ b/ui/flutter/lib/app/application/app_deep_link_controller.dart @@ -144,7 +144,7 @@ class AppDeepLinkController extends AsyncNotifier { Map _decodeParams(String params) { final safeParams = params.replaceAll('"', '').replaceAll(' ', '+'); - final paramsJson = String.fromCharCodes(base64Decode(base64.normalize(safeParams))); + final paramsJson = utf8.decode(base64Decode(base64.normalize(safeParams))); return jsonDecode(paramsJson) as Map; } diff --git a/ui/flutter/lib/core/utils/file_explorer_native.dart b/ui/flutter/lib/core/utils/file_explorer_native.dart index f3d7b9fa..715d7fb5 100644 --- a/ui/flutter/lib/core/utils/file_explorer_native.dart +++ b/ui/flutter/lib/core/utils/file_explorer_native.dart @@ -19,7 +19,7 @@ Future open(String filePath) async { Future reveal(String filePath) async { try { if (!(Platform.isWindows || Platform.isMacOS || Platform.isLinux)) { - return open(filePath); + return false; } if (await FileSystemEntity.isFile(filePath)) { diff --git a/ui/flutter/lib/features/extensions/presentation/pages/extensions_page.dart b/ui/flutter/lib/features/extensions/presentation/pages/extensions_page.dart index 6ccf05c5..d4effbec 100644 --- a/ui/flutter/lib/features/extensions/presentation/pages/extensions_page.dart +++ b/ui/flutter/lib/features/extensions/presentation/pages/extensions_page.dart @@ -26,6 +26,7 @@ import '../../application/pending_extension_install.dart'; import '../widgets/extension_detail_view.dart'; import '../widgets/extension_icon.dart'; import '../widgets/extension_setting_field.dart'; +import '../widgets/extension_update_dialog.dart'; const _extensionCardMinWidth = 290.0; const _extensionGridSpacing = 10.0; @@ -47,6 +48,9 @@ class ExtensionsPage extends ConsumerStatefulWidget { class _ExtensionsPageState extends ConsumerState { final _searchController = TextEditingController(); final _installController = TextEditingController(); + final _installAnchorKey = GlobalKey(); + final _installPopoverKey = GlobalKey<_InstallPopoverState>(); + bool _installDevMode = false; final _listScrollController = ScrollController(); final Map _settingControllers = {}; ExtensionListItem? _detailItem; @@ -78,15 +82,28 @@ class _ExtensionsPageState extends ConsumerState { Widget build(BuildContext context) { final palette = AppPalette.of(context); final isDesktop = MediaQuery.sizeOf(context).width >= Breakpoints.mobile; - ref.listen(pendingExtensionInstallProvider, (previous, next) { - if (next == null) return; - ref.read(pendingExtensionInstallProvider.notifier).clear(); - unawaited( - _runAction( - () => ref.read(extensionsControllerProvider.notifier).installFromUrl(next.url, devInstall: next.devMode), - ), - ); - }); + final pendingInstall = ref.watch(pendingExtensionInstallProvider); + if (pendingInstall != null) { + // The request can arrive before this page is mounted. Consume it only + // after the toolbar is laid out, so cold and warm links share the same UI. + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted || !identical(ref.read(pendingExtensionInstallProvider), pendingInstall)) return; + if (_installPopoverKey.currentState?._installing == true) return; + final anchorContext = _installAnchorKey.currentContext; + if (anchorContext == null) return; + _installController.text = pendingInstall.url; + _installDevMode = pendingInstall.devMode; + _showInstallPopover(anchorContext); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted || !identical(ref.read(pendingExtensionInstallProvider), pendingInstall)) return; + final popover = _installPopoverKey.currentState; + if (popover == null || popover._installing) return; + ref.read(pendingExtensionInstallProvider.notifier).clear(); + unawaited(popover._install()); + }); + WidgetsBinding.instance.ensureVisualUpdate(); + }); + } final stateAsync = ref.watch(extensionsControllerProvider); final body = Stack( children: [ @@ -154,6 +171,7 @@ class _ExtensionsPageState extends ConsumerState { onSearch: (query) => _runAction(() => ref.read(extensionsControllerProvider.notifier).searchStore(query)), onSort: (sort) => _runAction(() => ref.read(extensionsControllerProvider.notifier).changeSort(sort)), onFilter: ref.read(extensionsControllerProvider.notifier).changeFilter, + installAnchorKey: _installAnchorKey, onOpenInstall: _openInstallPopover, onDevelopExtension: _openExtensionDevelopmentDocs, onInstallFolder: _installFromFolder, @@ -169,7 +187,9 @@ class _ExtensionsPageState extends ConsumerState { Future _installFromUrl() async { final url = _installController.text.trim(); if (url.isEmpty) return; - await _runAction(() => ref.read(extensionsControllerProvider.notifier).installFromUrl(url)); + await _runAction( + () => ref.read(extensionsControllerProvider.notifier).installFromUrl(url, devInstall: _installDevMode), + ); } Future _loadNextPageIfNeeded() async { @@ -192,7 +212,14 @@ class _ExtensionsPageState extends ConsumerState { } void _openInstallPopover(BuildContext anchorContext) { - ref.read(extensionsControllerProvider.notifier).tryOpenDevMode(); + if (AppWindowChrome.isDesktopWindow) { + ref.read(extensionsControllerProvider.notifier).tryOpenDevMode(); + } + _installDevMode = false; + _showInstallPopover(anchorContext); + } + + void _showInstallPopover(BuildContext anchorContext) { if (_installPopover != null) return; final overlay = const shad.PopoverOverlayHandler().show( context: anchorContext, @@ -201,7 +228,17 @@ class _ExtensionsPageState extends ConsumerState { offset: const Offset(0, 8), modal: false, consumeOutsideTaps: false, - builder: (context) => _InstallPopover(controller: _installController, onInstallUrl: _installFromUrl), + // Flutter's native selection toolbar uses the EditableText tap group. + // Pasting from it must not dismiss the form and restore search focus. + regionGroupId: EditableText, + builder: (context) => _InstallPopover( + key: _installPopoverKey, + controller: _installController, + onInstallUrl: _installFromUrl, + onFinished: () { + if (mounted && ref.read(pendingExtensionInstallProvider) != null) setState(() {}); + }, + ), ); _installPopover = overlay; unawaited( @@ -284,6 +321,7 @@ class _Content extends StatelessWidget { required this.onSearch, required this.onSort, required this.onFilter, + required this.installAnchorKey, required this.onOpenInstall, required this.onDevelopExtension, required this.onInstallFolder, @@ -301,6 +339,7 @@ class _Content extends StatelessWidget { final ValueChanged onSearch; final ValueChanged onSort; final ValueChanged onFilter; + final GlobalKey installAnchorKey; final ValueChanged onOpenInstall; final VoidCallback onDevelopExtension; final VoidCallback onInstallFolder; @@ -322,6 +361,7 @@ class _Content extends StatelessWidget { onSearch: onSearch, onSort: onSort, onRefresh: onRefresh, + installAnchorKey: installAnchorKey, onOpenInstall: onOpenInstall, onDevelopExtension: onDevelopExtension, onInstallFolder: onInstallFolder, @@ -614,6 +654,7 @@ class _Toolbar extends StatelessWidget { required this.onSearch, required this.onSort, required this.onRefresh, + required this.installAnchorKey, required this.onOpenInstall, required this.onDevelopExtension, required this.onInstallFolder, @@ -624,6 +665,7 @@ class _Toolbar extends StatelessWidget { final ValueChanged onSearch; final ValueChanged onSort; final VoidCallback onRefresh; + final GlobalKey installAnchorKey; final ValueChanged onOpenInstall; final VoidCallback onDevelopExtension; final VoidCallback onInstallFolder; @@ -631,7 +673,6 @@ class _Toolbar extends StatelessWidget { @override Widget build(BuildContext context) { final palette = AppPalette.of(context); - final manualInstallBusy = state.busyExtensionIds.contains(ExtensionsController.manualInstallBusyKey); final search = AppTextField( key: const ValueKey('extension-search-input'), controller: searchController, @@ -673,22 +714,21 @@ class _Toolbar extends StatelessWidget { onPressed: onDevelopExtension, ), const SizedBox(width: 8), - if (state.devMode) ...[ + if (AppWindowChrome.isDesktopWindow && state.devMode) ...[ _OutlineToolbarIconButton( key: const ValueKey('load-local-extension-button'), tooltip: context.l10n.extensionLoadLocal, icon: Icons.folder_open_outlined, - loading: manualInstallBusy, onPressed: onInstallFolder, ), const SizedBox(width: 8), ], Builder( + key: installAnchorKey, builder: (buttonContext) => _OutlineToolbarIconButton( key: const ValueKey('install-extension-button'), tooltip: context.l10n.extensionInstallFromUrl, icon: Icons.add_link, - loading: manualInstallBusy, onPressed: () => onOpenInstall(buttonContext), ), ), @@ -764,18 +804,11 @@ class _FilterBar extends StatelessWidget { } class _OutlineToolbarIconButton extends StatelessWidget { - const _OutlineToolbarIconButton({ - super.key, - required this.tooltip, - required this.icon, - required this.onPressed, - this.loading = false, - }); + const _OutlineToolbarIconButton({super.key, required this.tooltip, required this.icon, required this.onPressed}); final String tooltip; final IconData icon; final VoidCallback? onPressed; - final bool loading; @override Widget build(BuildContext context) { @@ -783,23 +816,18 @@ class _OutlineToolbarIconButton extends StatelessWidget { message: tooltip, child: SizedBox.square( dimension: 32, - child: shad.IconButton.outline( - size: shad.ButtonSize.xSmall, - onPressed: loading ? null : onPressed, - icon: loading - ? const SizedBox.square(dimension: 13, child: shad.CircularProgressIndicator()) - : Icon(icon, size: 17), - ), + child: shad.IconButton.outline(size: shad.ButtonSize.xSmall, onPressed: onPressed, icon: Icon(icon, size: 17)), ), ); } } class _InstallPopover extends StatefulWidget { - const _InstallPopover({required this.controller, required this.onInstallUrl}); + const _InstallPopover({super.key, required this.controller, required this.onInstallUrl, required this.onFinished}); final TextEditingController controller; final Future Function() onInstallUrl; + final VoidCallback onFinished; @override State<_InstallPopover> createState() => _InstallPopoverState(); @@ -861,8 +889,8 @@ class _InstallPopoverState extends State<_InstallPopover> { setState(() => _installing = true); await widget.onInstallUrl(); if (!mounted) return; - setState(() => _installing = false); await shad.closeOverlay(context); + widget.onFinished(); } } @@ -1035,11 +1063,8 @@ class _ExtensionCard extends ConsumerWidget { if (canUpdate && installed != null) shad.GhostButton( density: shad.ButtonDensity.icon, - onPressed: busy - ? null - : () => onAction( - () => ref.read(extensionsControllerProvider.notifier).upgradeExtension(installed), - ), + key: ValueKey('update-extension-${installed.identity}'), + onPressed: busy ? null : () => showExtensionUpdateDialog(context, installed), child: const Icon(Icons.refresh), ), if (installed == null && item.store != null) diff --git a/ui/flutter/lib/features/extensions/presentation/widgets/extension_detail_view.dart b/ui/flutter/lib/features/extensions/presentation/widgets/extension_detail_view.dart index eed9ec79..87837560 100644 --- a/ui/flutter/lib/features/extensions/presentation/widgets/extension_detail_view.dart +++ b/ui/flutter/lib/features/extensions/presentation/widgets/extension_detail_view.dart @@ -20,6 +20,7 @@ import '../../../../shared/widgets/detail/app_detail_surface.dart'; import '../../../../util/util.dart'; import '../../application/extensions_controller.dart'; import 'extension_icon.dart'; +import 'extension_update_dialog.dart'; class ExtensionDetailDrawer extends StatelessWidget { const ExtensionDetailDrawer({super.key, required this.item, required this.onClose}); @@ -132,14 +133,10 @@ class ExtensionDetailView extends ConsumerWidget { if (installed != null && canUpdate) AppLoadingButton( key: const ValueKey('extension-details-update'), - loading: busy, + loading: false, variant: AppLoadingButtonVariant.primary, icon: const Icon(Icons.refresh, size: 16), - onPressed: () => _runAction( - context, - () => ref.read(extensionsControllerProvider.notifier).upgradeExtension(installed), - successMessage: context.l10n.extensionUpdateSuccess, - ), + onPressed: busy ? null : () => showExtensionUpdateDialog(context, installed), child: Text(context.l10n.newVersionUpdate), ), if (installed != null && !canUpdate) diff --git a/ui/flutter/lib/features/extensions/presentation/widgets/extension_update_dialog.dart b/ui/flutter/lib/features/extensions/presentation/widgets/extension_update_dialog.dart new file mode 100644 index 00000000..107a46bc --- /dev/null +++ b/ui/flutter/lib/features/extensions/presentation/widgets/extension_update_dialog.dart @@ -0,0 +1,90 @@ +import 'package:shadcn_flutter/shadcn_flutter.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../../api/model/extension.dart' as api; +import '../../../../l10n/l10n.dart'; +import '../../../../shared/widgets/app_loading_button.dart'; +import '../../../../shared/widgets/app_toast.dart'; +import '../../application/extensions_controller.dart'; + +Future showExtensionUpdateDialog(BuildContext context, api.Extension extension) async { + final overlay = const DialogOverlayHandler().show( + context: context, + alignment: Alignment.center, + barrierDismissable: false, + builder: (_) => _ExtensionUpdateDialog(extension: extension), + ); + final updated = await overlay.future; + if (updated == true && context.mounted) { + showAppToast(context, context.l10n.extensionUpdateSuccess); + } +} + +class _ExtensionUpdateDialog extends ConsumerStatefulWidget { + const _ExtensionUpdateDialog({required this.extension}); + + final api.Extension extension; + + @override + ConsumerState<_ExtensionUpdateDialog> createState() => _ExtensionUpdateDialogState(); +} + +class _ExtensionUpdateDialogState extends ConsumerState<_ExtensionUpdateDialog> { + bool _updating = false; + bool _failed = false; + + Future _update() async { + if (_updating) return; + setState(() { + _updating = true; + _failed = false; + }); + try { + await ref.read(extensionsControllerProvider.notifier).upgradeExtension(widget.extension); + } catch (_) { + if (!mounted) return; + setState(() { + _updating = false; + _failed = true; + }); + return; + } + if (mounted) await closeOverlay(context, true); + } + + @override + Widget build(BuildContext context) { + return PopScope( + canPop: !_updating, + child: AlertDialog( + key: const ValueKey('extension-update-dialog'), + title: Text(context.l10n.extensionCanUpdate), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(context.l10n.extensionUpdateConfirm(widget.extension.title)), + if (_failed) ...[ + const SizedBox(height: 12), + Text(context.l10n.updateFailed, key: const ValueKey('extension-update-error')), + ], + ], + ), + actions: [ + SecondaryButton( + key: const ValueKey('cancel-update-extension-button'), + onPressed: _updating ? null : () => closeOverlay(context, false), + child: Text(context.l10n.cancel), + ), + AppLoadingButton( + key: const ValueKey('confirm-update-extension-button'), + variant: AppLoadingButtonVariant.primary, + loading: _updating, + onPressed: _update, + child: Text(context.l10n.newVersionUpdate), + ), + ], + ), + ); + } +} diff --git a/ui/flutter/lib/features/tasks/domain/task_record.dart b/ui/flutter/lib/features/tasks/domain/task_record.dart index 44a5a426..47aa0cd4 100644 --- a/ui/flutter/lib/features/tasks/domain/task_record.dart +++ b/ui/flutter/lib/features/tasks/domain/task_record.dart @@ -133,7 +133,7 @@ class TaskRecord { status: status, downloaded: _formatBytes(downloadedBytes), total: totalBytes > 0 ? _formatBytes(totalBytes) : null, - speed: task.progress.speed > 0 ? TransferRateFormatter.format(task.progress.speed).text : null, + speed: totalBytes > 0 || task.progress.speed > 0 ? TransferRateFormatter.format(task.progress.speed).text : null, uploadSpeed: task.uploading ? TransferRateFormatter.format(task.progress.uploadSpeed).text : null, remainingSeconds: _remainingSeconds(task, totalBytes, downloadedBytes), downloadDuration: task.progress.used > 0 ? Duration(microseconds: (task.progress.used + 999) ~/ 1000) : null, diff --git a/ui/flutter/lib/features/tasks/presentation/pages/create_task_window_page.dart b/ui/flutter/lib/features/tasks/presentation/pages/create_task_window_page.dart index 5819af27..44d19ac2 100644 --- a/ui/flutter/lib/features/tasks/presentation/pages/create_task_window_page.dart +++ b/ui/flutter/lib/features/tasks/presentation/pages/create_task_window_page.dart @@ -37,6 +37,7 @@ import '../../../../shared/widgets/app_toast.dart'; import '../../../../l10n/l10n.dart'; import '../../../../util/util.dart'; import '../../application/pending_create_task.dart'; +import '../../application/task_list_navigation.dart'; import '../widgets/history_search_field.dart'; import '../widgets/create_task_action_buttons.dart'; import '../widgets/resolve_file_tree.dart'; @@ -736,6 +737,8 @@ class _CreateTaskWindowPageState extends ConsumerState { } catch (error) { debugPrint('Unable to select downloading tasks: $error'); } + } else { + ref.read(taskListNavigationProvider.notifier).showDownloading(); } await _closeWindow(); } diff --git a/ui/flutter/lib/features/tasks/presentation/widgets/task_card/task_card_desktop.dart b/ui/flutter/lib/features/tasks/presentation/widgets/task_card/task_card_desktop.dart index 1dded739..480531ee 100644 --- a/ui/flutter/lib/features/tasks/presentation/widgets/task_card/task_card_desktop.dart +++ b/ui/flutter/lib/features/tasks/presentation/widgets/task_card/task_card_desktop.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart' show Colors, Icons; import 'package:flutter/widgets.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart' as shad; +import '../../../../../core/window/app_window_chrome.dart'; import '../../../../../shared/theme/app_design_tokens.dart'; import '../../../../../shared/theme/app_palette.dart'; import '../../../domain/task_record.dart'; @@ -237,7 +238,10 @@ class _ProgressContent extends StatelessWidget { TaskStatus.downloading => [(Icons.pause, onPause), (Icons.close, onDelete)], TaskStatus.paused => [(Icons.play_arrow, onResume), (Icons.close, onDelete)], TaskStatus.failed => [(Icons.refresh, onResume), (Icons.delete_outline, onDelete)], - TaskStatus.completed => [(Icons.folder_open_outlined, null), (Icons.delete_outline, onDelete)], + TaskStatus.completed => [ + if (AppWindowChrome.isDesktopWindow) (Icons.folder_open_outlined, null), + (Icons.delete_outline, onDelete), + ], }; return actions @@ -306,7 +310,10 @@ class _CompletedContent extends StatelessWidget { TaskStatus.downloading => [(Icons.pause, null), (Icons.close, onDelete)], TaskStatus.paused => [(Icons.play_arrow, null), (Icons.close, onDelete)], TaskStatus.failed => [(Icons.refresh, null), (Icons.delete_outline, onDelete)], - TaskStatus.completed => [(Icons.folder_open_outlined, onReveal), (Icons.delete_outline, onDelete)], + TaskStatus.completed => [ + if (AppWindowChrome.isDesktopWindow) (Icons.folder_open_outlined, onReveal), + (Icons.delete_outline, onDelete), + ], }; return actions diff --git a/ui/flutter/lib/features/tasks/presentation/widgets/task_card/task_card_mobile.dart b/ui/flutter/lib/features/tasks/presentation/widgets/task_card/task_card_mobile.dart index 2700956b..59af346f 100644 --- a/ui/flutter/lib/features/tasks/presentation/widgets/task_card/task_card_mobile.dart +++ b/ui/flutter/lib/features/tasks/presentation/widgets/task_card/task_card_mobile.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart' show Colors, Icons; import 'package:flutter/widgets.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart' as shad; +import '../../../../../core/window/app_window_chrome.dart'; import '../../../../../shared/theme/app_design_tokens.dart'; import '../../../../../shared/theme/app_palette.dart'; import '../../../domain/task_record.dart'; @@ -171,7 +172,10 @@ class _TaskCardMobileState extends State { TaskStatus.downloading => [(Icons.pause, widget.onPause), (Icons.close, widget.onDelete)], TaskStatus.paused => [(Icons.play_arrow, widget.onResume), (Icons.close, widget.onDelete)], TaskStatus.failed => [(Icons.refresh, widget.onResume), (Icons.delete_outline, widget.onDelete)], - TaskStatus.completed => [(Icons.folder_open_outlined, widget.onReveal), (Icons.delete_outline, widget.onDelete)], + TaskStatus.completed => [ + if (AppWindowChrome.isDesktopWindow) (Icons.folder_open_outlined, widget.onReveal), + (Icons.delete_outline, widget.onDelete), + ], }; return actions diff --git a/ui/flutter/lib/features/tasks/presentation/widgets/task_card/task_card_parts.dart b/ui/flutter/lib/features/tasks/presentation/widgets/task_card/task_card_parts.dart index ac08f324..51c27537 100644 --- a/ui/flutter/lib/features/tasks/presentation/widgets/task_card/task_card_parts.dart +++ b/ui/flutter/lib/features/tasks/presentation/widgets/task_card/task_card_parts.dart @@ -106,7 +106,7 @@ class TaskCardFooter extends StatelessWidget { ), ), const SizedBox(width: 12), - if (task.speed != null && !isError) ...[ + if (task.speed != null) ...[ _TaskTransferSpeed(icon: Icons.south, value: task.speed!, color: metaColor), const SizedBox(width: 8), TickDivider(color: palette.progressTrack), diff --git a/ui/flutter/lib/l10n/app_ar.arb b/ui/flutter/lib/l10n/app_ar.arb index 174cb2d1..372a5777 100644 --- a/ui/flutter/lib/l10n/app_ar.arb +++ b/ui/flutter/lib/l10n/app_ar.arb @@ -103,6 +103,7 @@ "extensions": "الإضافات", "extensionInstallUrl": "عنوان التثبيت", "extensionInstallSuccess": "تم التثبيت بنجاح", + "extensionUpdateConfirm": "هل تريد تحديث «{name}» إلى أحدث إصدار؟", "extensionUpdateSuccess": "تم التحديث بنجاح", "extensionDelete": "حذف الإضافة", "extensionAlreadyLatest": "هذا هو أحدث إصدار بالفعل", diff --git a/ui/flutter/lib/l10n/app_az.arb b/ui/flutter/lib/l10n/app_az.arb index 4e9ba6ec..564f9999 100644 --- a/ui/flutter/lib/l10n/app_az.arb +++ b/ui/flutter/lib/l10n/app_az.arb @@ -110,6 +110,7 @@ "extensions": "Uzantılar", "extensionInstallUrl": "Quraşdırma URL-i", "extensionInstallSuccess": "Uğurla quraşdırıldı", + "extensionUpdateConfirm": "“{name}” ən son versiyaya yenilənsin?", "extensionUpdateSuccess": "Uğurla yeniləndi", "extensionDelete": "Uzantını Sil", "extensionAlreadyLatest": "Artıq son versiyadır", diff --git a/ui/flutter/lib/l10n/app_ca.arb b/ui/flutter/lib/l10n/app_ca.arb index dc3f81f7..bee2f371 100644 --- a/ui/flutter/lib/l10n/app_ca.arb +++ b/ui/flutter/lib/l10n/app_ca.arb @@ -103,6 +103,7 @@ "extensions": "Extensions", "extensionInstallUrl": "URL d'instal·lació", "extensionInstallSuccess": "S'ha instal·lat amb èxit", + "extensionUpdateConfirm": "Vols actualitzar «{name}» a la versió més recent?", "extensionUpdateSuccess": "S'ha actualitzat amb èxit", "extensionDelete": "Suprimeix l'extensió", "extensionAlreadyLatest": "Ja està actualitzada", diff --git a/ui/flutter/lib/l10n/app_de.arb b/ui/flutter/lib/l10n/app_de.arb index a27d800c..5c95653f 100644 --- a/ui/flutter/lib/l10n/app_de.arb +++ b/ui/flutter/lib/l10n/app_de.arb @@ -103,6 +103,7 @@ "extensions": "Erweiterungen", "extensionInstallUrl": "Installations-URL", "extensionInstallSuccess": "Erfolgreich installiert", + "extensionUpdateConfirm": "„{name}“ auf die neueste Version aktualisieren?", "extensionUpdateSuccess": "Erfolgreich aktualisiert", "extensionDelete": "Erweiterung löschen", "extensionAlreadyLatest": "Es ist bereits die neueste Version", diff --git a/ui/flutter/lib/l10n/app_en.arb b/ui/flutter/lib/l10n/app_en.arb index d1897ce8..c483375b 100644 --- a/ui/flutter/lib/l10n/app_en.arb +++ b/ui/flutter/lib/l10n/app_en.arb @@ -110,6 +110,14 @@ "extensions": "Extensions", "extensionInstallUrl": "Install URL", "extensionInstallSuccess": "Installed successfully", + "extensionUpdateConfirm": "Update “{name}” to the latest version?", + "@extensionUpdateConfirm": { + "placeholders": { + "name": { + "type": "String" + } + } + }, "extensionUpdateSuccess": "Updated successfully", "extensionDelete": "Delete Extension", "extensionAlreadyLatest": "It's already the latest version", diff --git a/ui/flutter/lib/l10n/app_es.arb b/ui/flutter/lib/l10n/app_es.arb index 34d2343c..a9588f9a 100644 --- a/ui/flutter/lib/l10n/app_es.arb +++ b/ui/flutter/lib/l10n/app_es.arb @@ -103,6 +103,7 @@ "extensions": "Extensiones", "extensionInstallUrl": "URL de instalación", "extensionInstallSuccess": "Instalado con éxito", + "extensionUpdateConfirm": "¿Actualizar «{name}» a la última versión?", "extensionUpdateSuccess": "Actualizado con éxito", "extensionDelete": "Eliminar Extensión", "extensionAlreadyLatest": "Ya es la versión más reciente", diff --git a/ui/flutter/lib/l10n/app_fa.arb b/ui/flutter/lib/l10n/app_fa.arb index bafb1bb3..66235220 100644 --- a/ui/flutter/lib/l10n/app_fa.arb +++ b/ui/flutter/lib/l10n/app_fa.arb @@ -103,6 +103,7 @@ "extensions": "افزونه‌ها", "extensionInstallUrl": "نشانی نصب", "extensionInstallSuccess": "نصب شده با موفقیت", + "extensionUpdateConfirm": "«{name}» به آخرین نسخه به‌روزرسانی شود؟", "extensionUpdateSuccess": "به روز رسانی موفقیت آمیز", "extensionDelete": "حذف افزونه", "extensionAlreadyLatest": "این نسخه در حال حاضر آخرین نسخه است", diff --git a/ui/flutter/lib/l10n/app_fr.arb b/ui/flutter/lib/l10n/app_fr.arb index e708aca4..de714190 100644 --- a/ui/flutter/lib/l10n/app_fr.arb +++ b/ui/flutter/lib/l10n/app_fr.arb @@ -103,6 +103,7 @@ "extensions": "Extensions", "extensionInstallUrl": "URL d'installation", "extensionInstallSuccess": "Installé avec succès", + "extensionUpdateConfirm": "Mettre à jour « {name} » vers la dernière version ?", "extensionUpdateSuccess": "Mis à jour avec succès", "extensionDelete": "Supprimer l'extension", "extensionAlreadyLatest": "C'est déjà la dernière version", diff --git a/ui/flutter/lib/l10n/app_hu.arb b/ui/flutter/lib/l10n/app_hu.arb index 2823ae6d..7b24e524 100644 --- a/ui/flutter/lib/l10n/app_hu.arb +++ b/ui/flutter/lib/l10n/app_hu.arb @@ -103,6 +103,7 @@ "extensions": "Bővítmények", "extensionInstallUrl": "Telepítési URL", "extensionInstallSuccess": "Sikeresen telepítve", + "extensionUpdateConfirm": "Frissíti a(z) „{name}” bővítményt a legújabb verzióra?", "extensionUpdateSuccess": "Sikeresen frissítve", "extensionDelete": "Bővítmény törlése", "extensionAlreadyLatest": "Ez már a legfrissebb verzió", diff --git a/ui/flutter/lib/l10n/app_id.arb b/ui/flutter/lib/l10n/app_id.arb index 43a57bdc..4f883708 100644 --- a/ui/flutter/lib/l10n/app_id.arb +++ b/ui/flutter/lib/l10n/app_id.arb @@ -103,6 +103,7 @@ "extensions": "Ekstensi", "extensionInstallUrl": "URL Instal", "extensionInstallSuccess": "Diinstal dengan sukses", + "extensionUpdateConfirm": "Perbarui “{name}” ke versi terbaru?", "extensionUpdateSuccess": "Diperbaruhi dengan sukses", "extensionDelete": "Hapus Ekstensi", "extensionAlreadyLatest": "Ini sudah versi terbaru", diff --git a/ui/flutter/lib/l10n/app_it.arb b/ui/flutter/lib/l10n/app_it.arb index 8d0f62b9..168e2fd6 100644 --- a/ui/flutter/lib/l10n/app_it.arb +++ b/ui/flutter/lib/l10n/app_it.arb @@ -103,6 +103,7 @@ "extensions": "Estensioni", "extensionInstallUrl": "Installa URL", "extensionInstallSuccess": "Installato con successo", + "extensionUpdateConfirm": "Aggiornare «{name}» alla versione più recente?", "extensionUpdateSuccess": "Aggiornato con successo", "extensionDelete": "Elimina estensione", "extensionAlreadyLatest": "È già l'ultima versione", diff --git a/ui/flutter/lib/l10n/app_ja.arb b/ui/flutter/lib/l10n/app_ja.arb index 097488ef..98ea7b15 100644 --- a/ui/flutter/lib/l10n/app_ja.arb +++ b/ui/flutter/lib/l10n/app_ja.arb @@ -103,6 +103,7 @@ "extensions": "拡張機能", "extensionInstallUrl": "インストールURL", "extensionInstallSuccess": "正常にインストールされました", + "extensionUpdateConfirm": "「{name}」を最新バージョンに更新しますか?", "extensionUpdateSuccess": "正常に更新されました", "extensionDelete": "拡張機能を削除", "extensionAlreadyLatest": "既に最新バージョンです", diff --git a/ui/flutter/lib/l10n/app_ko.arb b/ui/flutter/lib/l10n/app_ko.arb index b7c66da1..28d64a32 100644 --- a/ui/flutter/lib/l10n/app_ko.arb +++ b/ui/flutter/lib/l10n/app_ko.arb @@ -103,6 +103,7 @@ "extensions": "확장 프로그램", "extensionInstallUrl": "설치 URL", "extensionInstallSuccess": "설치되었습니다", + "extensionUpdateConfirm": "“{name}”을(를) 최신 버전으로 업데이트할까요?", "extensionUpdateSuccess": "업데이트되었습니다", "extensionDelete": "확장 프로그램 삭제", "extensionAlreadyLatest": "이미 최신 버전입니다", diff --git a/ui/flutter/lib/l10n/app_pl.arb b/ui/flutter/lib/l10n/app_pl.arb index ff542d50..6256937f 100644 --- a/ui/flutter/lib/l10n/app_pl.arb +++ b/ui/flutter/lib/l10n/app_pl.arb @@ -103,6 +103,7 @@ "extensions": "Wtyczki", "extensionInstallUrl": "Instaluj z url", "extensionInstallSuccess": "Instalacja zakończona", + "extensionUpdateConfirm": "Zaktualizować „{name}” do najnowszej wersji?", "extensionUpdateSuccess": "Aktualizacja zakończona", "extensionDelete": "Usuń wtyczkę", "extensionAlreadyLatest": "Wtyczka w aktualnej wersji", diff --git a/ui/flutter/lib/l10n/app_pt.arb b/ui/flutter/lib/l10n/app_pt.arb index c0d66de9..eca4a034 100644 --- a/ui/flutter/lib/l10n/app_pt.arb +++ b/ui/flutter/lib/l10n/app_pt.arb @@ -103,6 +103,7 @@ "extensions": "Extensões", "extensionInstallUrl": "Link de Instalação", "extensionInstallSuccess": "Instalado com Sucesso", + "extensionUpdateConfirm": "Atualizar “{name}” para a versão mais recente?", "extensionUpdateSuccess": "Atualizado com Sucesso", "extensionDelete": "Excluir Extensão", "extensionAlreadyLatest": "Já é a última versão", diff --git a/ui/flutter/lib/l10n/app_ru.arb b/ui/flutter/lib/l10n/app_ru.arb index 67e42710..08811c7d 100644 --- a/ui/flutter/lib/l10n/app_ru.arb +++ b/ui/flutter/lib/l10n/app_ru.arb @@ -103,6 +103,7 @@ "extensions": "Расширения", "extensionInstallUrl": "URL для установки", "extensionInstallSuccess": "Установка завершена", + "extensionUpdateConfirm": "Обновить «{name}» до последней версии?", "extensionUpdateSuccess": "Обновление завершено", "extensionDelete": "Удалить расширение", "extensionAlreadyLatest": "Это последняя версия", diff --git a/ui/flutter/lib/l10n/app_ta.arb b/ui/flutter/lib/l10n/app_ta.arb index 51ee4819..a4544b27 100644 --- a/ui/flutter/lib/l10n/app_ta.arb +++ b/ui/flutter/lib/l10n/app_ta.arb @@ -103,6 +103,7 @@ "extensions": "நீட்டிப்புகள்", "extensionInstallUrl": "நிறுவப்படும் URL", "extensionInstallSuccess": "வெற்றிகரமாக நிறுவப்பட்டது", + "extensionUpdateConfirm": "“{name}” ஐ சமீபத்திய பதிப்பிற்குப் புதுப்பிக்கவா?", "extensionUpdateSuccess": "வெற்றிகரமாக புதுப்பிக்கப்பட்டது", "extensionDelete": "நீட்டிப்பை நீக்கு", "extensionAlreadyLatest": "இது ஏற்கனவே சமீபத்திய பதிப்பாகும்", diff --git a/ui/flutter/lib/l10n/app_tr.arb b/ui/flutter/lib/l10n/app_tr.arb index 0b160d9b..e96f138a 100644 --- a/ui/flutter/lib/l10n/app_tr.arb +++ b/ui/flutter/lib/l10n/app_tr.arb @@ -103,6 +103,7 @@ "extensions": "Eklentiler", "extensionInstallUrl": "Kurulum URL'si", "extensionInstallSuccess": "Başarıyla kuruldu", + "extensionUpdateConfirm": "“{name}” en son sürüme güncellensin mi?", "extensionUpdateSuccess": "Başarıyla güncellendi", "extensionDelete": "Eklentiyi Sil", "extensionAlreadyLatest": "Zaten en son sürüm", diff --git a/ui/flutter/lib/l10n/app_uk.arb b/ui/flutter/lib/l10n/app_uk.arb index 8632668f..2c659f04 100644 --- a/ui/flutter/lib/l10n/app_uk.arb +++ b/ui/flutter/lib/l10n/app_uk.arb @@ -103,6 +103,7 @@ "extensions": "Розширення", "extensionInstallUrl": "URL для встановлення", "extensionInstallSuccess": "Встановлено успішно", + "extensionUpdateConfirm": "Оновити «{name}» до останньої версії?", "extensionUpdateSuccess": "Оновлено успішно", "extensionDelete": "Видалити розширення", "extensionAlreadyLatest": "Вже остання версія", diff --git a/ui/flutter/lib/l10n/app_vi.arb b/ui/flutter/lib/l10n/app_vi.arb index ab4171ab..8e3e36f5 100644 --- a/ui/flutter/lib/l10n/app_vi.arb +++ b/ui/flutter/lib/l10n/app_vi.arb @@ -103,6 +103,7 @@ "extensions": "Tiện ích mở rộng", "extensionInstallUrl": "Liên kết cài đặt", "extensionInstallSuccess": "Cài đặt thành công", + "extensionUpdateConfirm": "Cập nhật “{name}” lên phiên bản mới nhất?", "extensionUpdateSuccess": "Cập nhật thành công", "extensionDelete": "Xóa tiện ích mở rộng", "extensionAlreadyLatest": "Đây đã là phiên bản mới nhất", diff --git a/ui/flutter/lib/l10n/app_zh.arb b/ui/flutter/lib/l10n/app_zh.arb index 0b801d25..2395f69b 100644 --- a/ui/flutter/lib/l10n/app_zh.arb +++ b/ui/flutter/lib/l10n/app_zh.arb @@ -103,6 +103,7 @@ "extensions": "扩展", "extensionInstallUrl": "安装链接", "extensionInstallSuccess": "安装成功", + "extensionUpdateConfirm": "确定将“{name}”更新到最新版本吗?", "extensionUpdateSuccess": "更新成功", "extensionDelete": "删除扩展", "extensionAlreadyLatest": "已经是最新版本", diff --git a/ui/flutter/lib/l10n/app_zh_TW.arb b/ui/flutter/lib/l10n/app_zh_TW.arb index 21a2bc72..2692840b 100644 --- a/ui/flutter/lib/l10n/app_zh_TW.arb +++ b/ui/flutter/lib/l10n/app_zh_TW.arb @@ -103,6 +103,7 @@ "extensions": "擴充功能", "extensionInstallUrl": "安裝 URL", "extensionInstallSuccess": "安裝成功", + "extensionUpdateConfirm": "確定將「{name}」更新到最新版本嗎?", "extensionUpdateSuccess": "更新成功", "extensionDelete": "刪除擴充功能", "extensionAlreadyLatest": "已是最新版本", diff --git a/ui/flutter/test/app_deep_link_controller_test.dart b/ui/flutter/test/app_deep_link_controller_test.dart index 324e026f..ea24bbda 100644 --- a/ui/flutter/test/app_deep_link_controller_test.dart +++ b/ui/flutter/test/app_deep_link_controller_test.dart @@ -1,4 +1,8 @@ import 'dart:async'; +import 'dart:convert'; + +import 'package:flutter/services.dart'; +import 'package:gopeed/features/extensions/application/pending_extension_install.dart'; import 'package:app_links_platform_interface/app_links_platform_interface.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -11,6 +15,44 @@ import 'package:gopeed/core/common/start_config.dart'; import 'package:share_handler/share_handler.dart'; void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + test('receives initial and subsequent scheme extension URLs with UTF-8 params', () async { + final originalPlatform = AppLinksPlatform.instance; + final initialUrl = 'https://github.com/author/扩展'; + Uri link(String url) => Uri.parse('gopeed:///extension').replace( + queryParameters: { + 'params': base64Encode(utf8.encode(jsonEncode({'url': url}))), + }, + ); + final platform = _FakeAppLinksPlatform(initialLink: link(initialUrl)); + AppLinksPlatform.instance = platform; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler( + const MethodChannel('window_manager'), + (_) async => false, + ); + final container = ProviderContainer( + overrides: [appRuntimeControllerProvider.overrideWith(_FakeRuntimeController.new)], + ); + try { + await container.read(appDeepLinkControllerProvider.future); + expect(container.read(pendingExtensionInstallProvider)?.url, initialUrl); + container.read(pendingExtensionInstallProvider.notifier).clear(); + platform.emit(link('https://github.com/author/second')); + await Future.delayed(Duration.zero); + expect(container.read(pendingExtensionInstallProvider)?.url, 'https://github.com/author/second'); + } finally { + container.dispose(); + await Future.delayed(Duration.zero); + AppLinksPlatform.instance = originalPlatform; + await platform.close(); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler( + const MethodChannel('window_manager'), + null, + ); + } + }); + test('keeps the established path-style Gopeed deep-link routes', () { expect(gopeedDeepLinkRoute(Uri.parse('gopeed:///create')), '/create'); expect(gopeedDeepLinkRoute(Uri.parse('gopeed:///extension')), '/extension'); @@ -63,12 +105,16 @@ void main() { } class _FakeAppLinksPlatform extends AppLinksPlatform { + _FakeAppLinksPlatform({this.initialLink}); + + final Uri? initialLink; + void emit(Uri uri) => _links.add(uri); final _links = StreamController.broadcast(); bool get hasListener => _links.hasListener; @override - Future getInitialLink() async => null; + Future getInitialLink() async => initialLink; @override Stream get uriLinkStream => _links.stream; diff --git a/ui/flutter/test/widget_test.dart b/ui/flutter/test/widget_test.dart index 3a6c087c..3f0732e3 100644 --- a/ui/flutter/test/widget_test.dart +++ b/ui/flutter/test/widget_test.dart @@ -1,7 +1,8 @@ import 'dart:async'; import 'dart:io'; -import 'package:flutter/foundation.dart' show TargetPlatform, debugDefaultTargetPlatformOverride; +import 'package:flutter/foundation.dart' + show TargetPlatform, defaultTargetPlatform, debugDefaultTargetPlatformOverride, kIsWeb; import 'package:flutter/material.dart' show Icons, Scrollbar, SelectionArea; import 'package:flutter/gestures.dart' show PointerDeviceKind, kDoubleTapMinTime, kSecondaryMouseButton; import 'package:flutter/rendering.dart' show RenderParagraph; @@ -15,6 +16,12 @@ import 'package:window_manager/window_manager.dart'; import 'package:gopeed/features/home/presentation/pages/home_page.dart'; import 'package:gopeed/app/app.dart'; import 'package:gopeed/app/router/app_router.dart'; +import 'package:gopeed/core/capabilities/capability_rpc.dart'; +import 'package:gopeed/core/capabilities/gopeed_capability.dart'; +import 'package:gopeed/core/capabilities/storage_capability.dart'; +import 'package:gopeed/api/model/resolve_result.dart'; +import 'package:gopeed/features/tasks/application/pending_create_task.dart'; +import 'package:gopeed/features/tasks/application/task_list_navigation.dart'; import 'package:gopeed/core/capabilities/app_capabilities.dart'; import 'package:gopeed/core/capabilities/app_navigation_capability.dart'; import 'package:gopeed/app/application/app_appearance_controller.dart'; @@ -23,6 +30,8 @@ import 'package:gopeed/app/application/app_notification_controller.dart'; import 'package:gopeed/app/application/app_platform_controller.dart'; import 'package:gopeed/app/application/app_runtime_controller.dart'; import 'package:gopeed/api/model/create_task.dart'; +import 'package:gopeed/api/model/install_extension.dart'; +import 'package:gopeed/features/extensions/application/pending_extension_install.dart'; import 'package:gopeed/api/model/downloader_config.dart'; import 'package:gopeed/api/model/extension.dart' as api_extension; import 'package:gopeed/api/model/meta.dart'; @@ -97,6 +106,85 @@ import 'package:gopeed/util/updater.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized().platformDispatcher.localeTestValue = const Locale('en'); + for (final direct in [true, false]) { + testWidgets( + 'mobile creation selects downloading after success (direct: $direct)', + (tester) async { + await _setTestSize(tester, const Size(700, 900)); + var created = 0; + final config = DownloaderConfig(downloadDir: '/downloads')..extra.defaultDirectDownload = direct; + final registry = CapabilityRegistry(createAppCapabilityCodecs()) + ..bind(GopeedMethods.getConfig, (_) => config) + ..bind(StorageMethods.saveCreateHistory, (_) => const RpcUnit()) + ..bind( + GopeedMethods.resolve, + (_) => ResolveResult( + id: 'resolved', + res: Resource( + name: 'new.zip', + files: [FileInfo(name: 'new.zip', size: 10)], + ), + ), + ) + ..bind(GopeedMethods.createTask, (_) { + created++; + return 'new-task'; + }); + final container = ProviderContainer( + overrides: [ + appCapabilitiesProvider.overrideWithValue(AppCapabilities(LocalCapabilityInvoker(registry))), + appRuntimeControllerProvider.overrideWith(FakeRuntimeController.new), + appPlatformControllerProvider.overrideWith(FakePlatformController.new), + appDeepLinkControllerProvider.overrideWith(FakeDeepLinkController.new), + appNotificationControllerProvider.overrideWith(FakeNotificationController.new), + tasksControllerProvider.overrideWith(CompletedTasksController.new), + settingsControllerProvider.overrideWith(FakeSettingsController.new), + ], + ); + addTearDown(container.dispose); + await tester.pumpWidget(UncontrolledProviderScope(container: container, child: const GopeedApp())); + await tester.pumpAndSettle(); + await tester.tap(find.text('Completed 1')); + await tester.pumpAndSettle(); + expect(find.text('done.zip'), findsOneWidget); + final router = GoRouter.of(AppRouter.rootNavigatorKey.currentContext!); + void openCreate() { + container + .read(pendingCreateTaskProvider.notifier) + .set(CreateTask(req: Request(url: 'https://example.com/new.zip'))); + router.go('/create'); + } + + openCreate(); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const ValueKey('create-task-cancel-button'))); + await tester.pumpAndSettle(); + expect(find.text('done.zip'), findsOneWidget); + expect(container.read(taskListNavigationProvider), 0); + expect(created, 0); + openCreate(); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const ValueKey('create-task-confirm-button'))); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + if (!direct) { + expect(created, 0); + await tester.tap(find.byKey(const ValueKey('resolve-create-button'))); + await tester.pumpAndSettle(); + } + await tester.pumpAndSettle(); + expect(created, 1); + expect(router.routeInformationProvider.value.uri.path, '/'); + expect(container.read(taskListNavigationProvider), 1); + expect(find.text('done.zip'), findsNothing); + expect(tester.takeException(), isNull); + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pumpAndSettle(); + }, + variant: TargetPlatformVariant({TargetPlatform.android, TargetPlatform.iOS}), + ); + } + testWidgets('child success returns to downloading from completed and another page', (tester) async { await _setTestSize(tester, const Size(1024, 768)); await tester.pumpWidget( @@ -676,6 +764,73 @@ void main() { expect(tester.takeException(), isNull); }); + for (final (width, details) in [(390.0, false), (1100.0, false), (1100.0, true)]) { + testWidgets('extension update confirms, shows progress and retries at $width details=$details', (tester) async { + await _setTestSize(tester, Size(width, 900)); + final controller = UpdateableExtensionsController(); + await tester.pumpWidget( + ProviderScope( + overrides: [extensionsControllerProvider.overrideWith(() => controller)], + child: shad.ShadcnApp( + theme: AppTheme.light(), + materialTheme: AppTheme.materialLight(), + home: const ExtensionsPage(), + ), + ), + ); + await tester.pumpAndSettle(); + if (details) { + await tester.tap(find.byKey(const ValueKey('extension-card-extension-0'))); + await tester.pumpAndSettle(); + } + final entry = find.byKey(ValueKey(details ? 'extension-details-update' : 'update-extension-extension-0')); + final dialog = find.byKey(const ValueKey('extension-update-dialog')); + final confirm = find.byKey(const ValueKey('confirm-update-extension-button')); + final cancel = find.byKey(const ValueKey('cancel-update-extension-button')); + await tester.tap(entry); + await tester.pumpAndSettle(); + expect(dialog, findsOneWidget); + expect(find.text('Update “Extension 0” to the latest version?'), findsOneWidget); + expect(controller.updateCalls, 0); + await tester.tap(cancel); + await tester.pumpAndSettle(); + expect(dialog, findsNothing); + expect(controller.updateCalls, 0); + + await tester.tap(entry); + await tester.pumpAndSettle(); + controller.failUpdate = true; + await tester.tap(confirm); + await tester.pump(); + expect(controller.updateCalls, 1); + expect(tester.widget(confirm).loading, isTrue); + expect(tester.widget(cancel).onPressed, isNull); + await tester.tap(confirm); + await tester.tapAt(const Offset(5, 5)); + await tester.binding.handlePopRoute(); + await tester.pump(); + expect(controller.updateCalls, 1); + expect(dialog, findsOneWidget); + controller.updateGate.complete(); + await tester.pumpAndSettle(); + expect(dialog, findsOneWidget); + expect(find.byKey(const ValueKey('extension-update-error')), findsOneWidget); + expect(tester.widget(confirm).loading, isFalse); + + controller.failUpdate = false; + controller.updateGate = Completer(); + await tester.tap(confirm); + await tester.pump(); + expect(controller.updateCalls, 2); + controller.updateGate.complete(); + await tester.pumpAndSettle(); + expect(dialog, findsNothing); + await tester.pump(const Duration(seconds: 6)); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + }); + } + testWidgets('installed extension details use a disabled installed button without a hero status', ( WidgetTester tester, ) async { @@ -885,66 +1040,249 @@ void main() { expect(tester.takeException(), isNull); }); - testWidgets('extension install actions show progress instead of a disabled icon while busy', ( - WidgetTester tester, - ) async { - await _setTestSize(tester, const Size(1100, 900)); - await tester.pumpWidget( - ProviderScope( - overrides: [extensionsControllerProvider.overrideWith(BusyInstallExtensionsController.new)], - child: shad.ShadcnApp( - theme: AppTheme.light(), - materialTheme: AppTheme.materialLight(), - home: const ExtensionsPage(), + testWidgets( + 'URL installation leaves toolbar icons unchanged while store installation shows progress', + (WidgetTester tester) async { + await _setTestSize(tester, const Size(1100, 900)); + await tester.pumpWidget( + ProviderScope( + overrides: [extensionsControllerProvider.overrideWith(BusyInstallExtensionsController.new)], + child: shad.ShadcnApp( + theme: AppTheme.light(), + materialTheme: AppTheme.materialLight(), + home: const ExtensionsPage(), + ), ), - ), - ); - await tester.pump(); - await tester.pump(const Duration(milliseconds: 100)); - - final manualInstall = find.byKey(const ValueKey('install-extension-button')); - final storeInstall = find.byKey(const ValueKey('install-store-extension-extension-1')); - expect(find.descendant(of: manualInstall, matching: find.byType(shad.CircularProgressIndicator)), findsOneWidget); - expect(find.descendant(of: storeInstall, matching: find.byType(shad.CircularProgressIndicator)), findsOneWidget); - expect(find.descendant(of: storeInstall, matching: find.byIcon(Icons.download)), findsNothing); - expect(tester.takeException(), isNull); - }); - - testWidgets('extension install popover unlocks local loading and installed cards use switches', ( - WidgetTester tester, - ) async { - await _setTestSize(tester, const Size(1100, 900)); - await tester.pumpWidget( - ProviderScope( - overrides: [extensionsControllerProvider.overrideWith(FakeExtensionsController.new)], - child: shad.ShadcnApp( - theme: AppTheme.light(), - materialTheme: AppTheme.materialLight(), - home: const ExtensionsPage(), - ), - ), - ); - await tester.pumpAndSettle(); - - expect(find.byType(shad.Switch), findsOneWidget); - expect(find.byKey(const ValueKey('load-local-extension-button')), findsNothing); - final installButton = find.byKey(const ValueKey('install-extension-button')); - expect(find.descendant(of: installButton, matching: find.byType(shad.IconButton)), findsOneWidget); - - for (var index = 0; index < 5; index++) { - await tester.tap(installButton); + ); + await tester.pump(); await tester.pump(const Duration(milliseconds: 100)); - } - expect(find.byKey(const ValueKey('extension-install-popover')), findsOneWidget); - expect(find.byKey(const ValueKey('extension-install-url-input')), findsOneWidget); - final localButton = find.byKey(const ValueKey('load-local-extension-button')); - expect(localButton, findsOneWidget); - expect(find.descendant(of: localButton, matching: find.byType(shad.IconButton)), findsOneWidget); + final manualInstall = find.byKey(const ValueKey('install-extension-button')); + final storeInstall = find.byKey(const ValueKey('install-store-extension-extension-1')); + expect(find.descendant(of: manualInstall, matching: find.byType(shad.CircularProgressIndicator)), findsNothing); + expect(find.descendant(of: manualInstall, matching: find.byIcon(Icons.add_link)), findsOneWidget); + final localInstall = find.byKey(const ValueKey('load-local-extension-button')); + expect(localInstall, findsOneWidget); + expect(find.descendant(of: localInstall, matching: find.byType(shad.CircularProgressIndicator)), findsNothing); + expect(find.descendant(of: localInstall, matching: find.byIcon(Icons.folder_open_outlined)), findsOneWidget); + expect(find.descendant(of: storeInstall, matching: find.byType(shad.CircularProgressIndicator)), findsOneWidget); + expect(find.descendant(of: storeInstall, matching: find.byIcon(Icons.download)), findsNothing); + expect(tester.takeException(), isNull); + }, + variant: TargetPlatformVariant.only(TargetPlatform.macOS), + ); - await tester.pump(const Duration(seconds: 3)); - expect(tester.takeException(), isNull); - }); + for (final (width, platform) in [ + (320.0, TargetPlatform.android), + (390.0, TargetPlatform.iOS), + (768.0, TargetPlatform.android), + (1100.0, TargetPlatform.macOS), + ]) { + testWidgets( + 'scheme install automatically runs before and after mounting at width $width', + (tester) async { + await _setTestSize(tester, Size(width, 900)); + final controller = FakeExtensionsController()..installGate = Completer(); + final container = ProviderContainer(overrides: [extensionsControllerProvider.overrideWith(() => controller)]); + addTearDown(container.dispose); + const firstUrl = 'https://github.com/author/扩展'; + container.read(pendingExtensionInstallProvider.notifier).set(InstallExtension(url: firstUrl)); + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: shad.ShadcnApp( + theme: AppTheme.light(), + materialTheme: AppTheme.materialLight(), + home: const ExtensionsPage(), + ), + ), + ); + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); + final input = find.byKey(const ValueKey('extension-install-url-input')); + expect(tester.widget(input).controller!.text, firstUrl); + expect(container.read(pendingExtensionInstallProvider), isNull); + expect(controller.installCalls, 1); + expect(controller.lastInstallUrl, firstUrl); + final popoverRect = tester.getRect(find.byKey(const ValueKey('extension-install-popover'))); + expect(popoverRect.left, greaterThanOrEqualTo(0)); + expect(popoverRect.right, lessThanOrEqualTo(width)); + expect(find.descendant(of: input, matching: find.byType(shad.CircularProgressIndicator)), findsOneWidget); + + // Rebuilds and manual submission must not duplicate an active install. + await tester.tap(find.descendant(of: input, matching: find.byType(shad.IconButton))); + await tester.pump(); + expect(controller.installCalls, 1); + controller.installGate!.complete(); + await tester.pumpAndSettle(); + expect(find.byKey(const ValueKey('extension-install-popover')), findsNothing); + + // A warm link automatically installs from an already-open form too. + await tester.tap(find.byKey(const ValueKey('install-extension-button'))); + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); + const secondUrl = 'https://github.com/author/second'; + controller.installGate = Completer(); + container.read(pendingExtensionInstallProvider.notifier).set(InstallExtension(url: secondUrl)); + await tester.pump(); + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); + expect(tester.widget(input).controller!.text, secondUrl); + expect(controller.installCalls, 2); + expect(controller.lastInstallUrl, secondUrl); + + // A request arriving during installation waits until the active form closes. + container.read(pendingExtensionInstallProvider.notifier).set(InstallExtension(url: firstUrl)); + await tester.pump(); + expect(controller.installCalls, 2); + controller.installGate!.complete(); + controller.installGate = Completer(); + for (var i = 0; i < 8; i++) { + await tester.pump(const Duration(milliseconds: 200)); + } + expect(controller.installCalls, 3); + expect(controller.lastInstallUrl, firstUrl); + controller.installGate!.complete(); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); + }, + variant: TargetPlatformVariant.only(platform), + ); + } + + testWidgets( + 'install URL keeps focus after native menu paste', + (tester) async { + await _setTestSize(tester, const Size(390, 900)); + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.platform, (call) async { + if (call.method == 'Clipboard.hasStrings') return {'value': true}; + if (call.method == 'Clipboard.getData') return {'text': 'https://github.com/author/repo'}; + return null; + }); + addTearDown(() => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.platform, null)); + await tester.pumpWidget( + ProviderScope( + overrides: [extensionsControllerProvider.overrideWith(FakeExtensionsController.new)], + child: shad.ShadcnApp( + theme: AppTheme.light(), + materialTheme: AppTheme.materialLight(), + home: const ExtensionsPage(), + ), + ), + ); + await tester.pumpAndSettle(); + final search = find.byKey(const ValueKey('extension-search-input')); + await tester.tap(search); + await tester.tap(find.byKey(const ValueKey('install-extension-button'))); + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); + final input = find.byKey(const ValueKey('extension-install-url-input')); + final editableFinder = find.descendant(of: input, matching: find.byType(EditableText)); + final editable = tester.widget(editableFinder); + expect(editable.focusNode.hasFocus, isTrue); + if (defaultTargetPlatform == TargetPlatform.macOS) { + final gesture = await tester.startGesture( + tester.getCenter(input), + kind: PointerDeviceKind.mouse, + buttons: kSecondaryMouseButton, + ); + await gesture.up(); + } else { + await tester.longPress(input); + } + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); + await tester.tap(find.text('Paste')); + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); + expect(input, findsOneWidget); + expect(editable.controller.text, 'https://github.com/author/repo'); + expect(editable.focusNode.hasFocus, isTrue); + expect( + tester + .widget(find.descendant(of: search, matching: find.byType(EditableText))) + .focusNode + .hasFocus, + isFalse, + ); + await tester.tapAt(const Offset(10, 500)); + await tester.pumpAndSettle(); + expect(input, findsNothing); + }, + variant: TargetPlatformVariant({TargetPlatform.android, TargetPlatform.iOS, TargetPlatform.macOS}), + ); + + testWidgets( + 'mobile does not unlock local extension installation after five taps', + (tester) async { + // A tablet-sized mobile screen must still use mobile platform policy. + await _setTestSize(tester, const Size(1100, 900)); + final controller = FakeExtensionsController(); + await tester.pumpWidget( + ProviderScope( + overrides: [extensionsControllerProvider.overrideWith(() => controller)], + child: shad.ShadcnApp( + theme: AppTheme.light(), + materialTheme: AppTheme.materialLight(), + home: const ExtensionsPage(), + ), + ), + ); + await tester.pumpAndSettle(); + for (var i = 0; i < 5; i++) { + await tester.tap(find.byKey(const ValueKey('install-extension-button'))); + await tester.pump(const Duration(milliseconds: 100)); + } + expect(controller.devModeEnabled, isFalse); + expect(find.byKey(const ValueKey('load-local-extension-button')), findsNothing); + // Even a pre-existing dev-mode state must not expose the native folder action. + controller.enableDevModeForTest(); + await tester.pump(); + expect(find.byKey(const ValueKey('load-local-extension-button')), findsNothing); + }, + variant: TargetPlatformVariant({TargetPlatform.android, TargetPlatform.iOS}), + ); + + testWidgets( + 'extension install popover unlocks local loading and installed cards use switches', + (WidgetTester tester) async { + await _setTestSize(tester, const Size(1100, 900)); + await tester.pumpWidget( + ProviderScope( + overrides: [extensionsControllerProvider.overrideWith(FakeExtensionsController.new)], + child: shad.ShadcnApp( + theme: AppTheme.light(), + materialTheme: AppTheme.materialLight(), + home: const ExtensionsPage(), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.byType(shad.Switch), findsOneWidget); + expect(find.byKey(const ValueKey('load-local-extension-button')), findsNothing); + final installButton = find.byKey(const ValueKey('install-extension-button')); + expect(find.descendant(of: installButton, matching: find.byType(shad.IconButton)), findsOneWidget); + + for (var index = 0; index < 5; index++) { + await tester.tap(installButton); + await tester.pump(const Duration(milliseconds: 100)); + } + + expect(find.byKey(const ValueKey('extension-install-popover')), findsOneWidget); + expect(find.byKey(const ValueKey('extension-install-url-input')), findsOneWidget); + final localButton = find.byKey(const ValueKey('load-local-extension-button')); + expect(localButton, findsOneWidget); + expect(find.descendant(of: localButton, matching: find.byType(shad.IconButton)), findsOneWidget); + + await tester.pump(const Duration(seconds: 3)); + expect(tester.takeException(), isNull); + }, + variant: TargetPlatformVariant({TargetPlatform.windows, TargetPlatform.macOS, TargetPlatform.linux}), + ); testWidgets('responsive menu uses sidebar on desktop and two-level navigation on mobile', ( WidgetTester tester, @@ -3077,6 +3415,78 @@ void main() { expect(record.isIndeterminate, isTrue); }); + test('known total size preserves zero download speed independently of task status', () { + for (final status in api_task.Status.values) { + for (final total in [0, 1024]) { + for (final speed in [0, 512]) { + final task = _apiTransferTask(id: 'speed', status: status, uploading: false, speed: speed, uploadSpeed: 0); + task.meta.res = Resource(size: total, files: []); + expect(TaskRecord.fromApi(task).speed, total > 0 || speed > 0 ? '$speed B/s' : isNull); + } + } + } + }); + + for (final width in [390.0, 1024.0]) { + testWidgets('known-size task cards retain zero download speed at width $width', (tester) async { + await _setTestSize(tester, Size(width, 220)); + for (final status in api_task.Status.values) { + final task = _apiTransferTask(id: 'zero-speed', status: status, uploading: false, speed: 0, uploadSpeed: 0); + task.meta.res = Resource(size: 1024, files: []); + await tester.pumpWidget( + shad.ShadcnApp( + theme: AppTheme.light(), + materialTheme: AppTheme.materialLight(), + home: Padding( + padding: const EdgeInsets.all(24), + child: TaskCard( + task: TaskRecord.fromApi(task), + selected: false, + batchMode: false, + selectedInBatch: false, + onPressed: () {}, + onToggleBatch: () {}, + ), + ), + ), + ); + await tester.pump(); + expect(find.text('0 B/s'), findsOneWidget); + expect(find.byIcon(Icons.south), findsOneWidget); + expect(tester.takeException(), isNull); + } + }); + } + + testWidgets('known-size task details retain zero download speed', (tester) async { + await _setTestSize(tester, const Size(700, 900)); + final task = _apiTransferTask( + id: 'zero-details', + status: api_task.Status.running, + uploading: false, + speed: 0, + uploadSpeed: 0, + ); + task.meta.res = Resource(size: 1024, files: []); + await tester.pumpWidget( + ProviderScope( + child: shad.ShadcnApp( + theme: AppTheme.light(), + materialTheme: AppTheme.materialLight(), + home: TaskDetailsView( + task: TaskRecord.fromApi(task), + mobile: true, + onOpenStorage: () {}, + onUpdateUrl: (_) async {}, + ), + ), + ), + ); + await tester.pumpAndSettle(); + expect(find.text('0 B/s'), findsOneWidget); + expect(tester.takeException(), isNull); + }); + test('task records preserve zero upload speed while uploading', () { final record = TaskRecord.fromApi( _apiTransferTask(id: 'seeding', status: api_task.Status.done, uploading: true, speed: 0, uploadSpeed: 0), @@ -3301,7 +3711,7 @@ void main() { expect(find.text('Failed'), findsOneWidget); expect(find.text('Connection refused'), findsNothing); - expect(find.text('10 B/s'), findsNothing); + expect(find.text('10 B/s'), findsOneWidget); expect(tester.getRect(find.text('50 B / 100 B')).left, lessThan(tester.getRect(find.text('Failed')).left)); }); @@ -4851,45 +5261,100 @@ void main() { expect(openedCount, 0); }); - testWidgets('completed task double click opens while folder action reveals', (WidgetTester tester) async { - await _setTestSize(tester, const Size(1024, 220)); - var openedCount = 0; - var revealedCount = 0; + for (final width in [390.0, 1024.0]) { + testWidgets( + 'completed card actions respect platform at width $width', + (tester) async { + await _setTestSize(tester, Size(width, 220)); + var deleted = 0; + var revealed = 0; + await tester.pumpWidget( + shad.ShadcnApp( + theme: AppTheme.light(), + materialTheme: AppTheme.materialLight(), + home: Padding( + padding: const EdgeInsets.all(24), + child: TaskCard( + task: _taskRecord(id: 'platform-actions', name: 'done.zip', status: TaskStatus.completed), + selected: false, + batchMode: false, + selectedInBatch: false, + onPressed: () {}, + onToggleBatch: () {}, + onDelete: () => deleted++, + onReveal: () => revealed++, + ), + ), + ), + ); + await tester.pumpAndSettle(); + final desktop = + !kIsWeb && + {TargetPlatform.windows, TargetPlatform.macOS, TargetPlatform.linux}.contains(defaultTargetPlatform); + expect(find.byIcon(Icons.folder_open_outlined), desktop ? findsOneWidget : findsNothing); + expect(find.byIcon(Icons.delete_outline), findsOneWidget); + if (desktop) { + await tester.tap(find.byIcon(Icons.folder_open_outlined)); + expect(revealed, 1); + } + await tester.tap(find.byIcon(Icons.delete_outline)); + expect(deleted, 1); + expect(tester.takeException(), isNull); + }, + variant: TargetPlatformVariant({ + TargetPlatform.android, + TargetPlatform.iOS, + TargetPlatform.windows, + TargetPlatform.macOS, + TargetPlatform.linux, + }), + ); + } - await tester.pumpWidget( - shad.ShadcnApp( - theme: AppTheme.light(), - materialTheme: AppTheme.materialLight(), - home: Padding( - padding: const EdgeInsets.all(24), - child: TaskCard( - task: _taskRecord(id: 'completed-actions', name: 'completed.zip', status: TaskStatus.completed), - selected: false, - batchMode: false, - selectedInBatch: false, - onPressed: () {}, - onToggleBatch: () {}, - onOpen: () => openedCount++, - onReveal: () => revealedCount++, + testWidgets( + 'completed task double click opens while folder action reveals', + (WidgetTester tester) async { + await _setTestSize(tester, const Size(1024, 220)); + var openedCount = 0; + var revealedCount = 0; + + await tester.pumpWidget( + shad.ShadcnApp( + theme: AppTheme.light(), + materialTheme: AppTheme.materialLight(), + home: Padding( + padding: const EdgeInsets.all(24), + child: TaskCard( + task: _taskRecord(id: 'completed-actions', name: 'completed.zip', status: TaskStatus.completed), + selected: false, + batchMode: false, + selectedInBatch: false, + onPressed: () {}, + onToggleBatch: () {}, + onOpen: () => openedCount++, + onReveal: () => revealedCount++, + ), ), ), - ), - ); + ); - await tester.tap(find.text('completed.zip')); - await tester.pump(kDoubleTapMinTime); - await tester.tap(find.text('completed.zip')); - await tester.pumpAndSettle(); + await tester.tap(find.text('completed.zip')); + await tester.pump(kDoubleTapMinTime); + await tester.tap(find.text('completed.zip')); + await tester.pumpAndSettle(); - expect(openedCount, 1); - expect(revealedCount, 0); + expect(openedCount, 1); + expect(revealedCount, 0); - await tester.tap(find.byIcon(Icons.folder_open_outlined)); - await tester.pump(); + await tester.tap(find.byIcon(Icons.folder_open_outlined)); + await tester.pump(); - expect(openedCount, 1); - expect(revealedCount, 1); - }); + expect(openedCount, 1); + expect(revealedCount, 1); + }, + variant: TargetPlatformVariant.only(TargetPlatform.macOS), + skip: kIsWeb, + ); } Future _setTestSize(WidgetTester tester, Size size) async { @@ -5020,7 +5485,23 @@ class FailingTasksController extends TasksController { } class FakeExtensionsController extends ExtensionsController { + bool get devModeEnabled => state.requireValue.devMode; + + void enableDevModeForTest() { + state = AsyncValue.data(state.requireValue.copyWith(devMode: true)); + } + int installCalls = 0; + String? lastInstallUrl; + Completer? installGate; + + @override + Future installFromUrl(String url, {bool devInstall = false}) async { + installCalls++; + lastInstallUrl = url; + await installGate?.future; + } + int removeCalls = 0; @override @@ -5187,11 +5668,31 @@ class PaginatedExtensionsController extends FakeExtensionsController { } } +class UpdateableExtensionsController extends FakeExtensionsController { + int updateCalls = 0; + bool failUpdate = false; + Completer updateGate = Completer(); + + @override + Future build() async => + (await super.build()).copyWith(listFilter: ExtensionListFilter.installed, updateFlags: {'extension-0': '2.0.0'}); + + @override + Future upgradeExtension(api_extension.Extension extension) async { + updateCalls++; + await updateGate.future; + if (failUpdate) throw StateError('update failed'); + } +} + class BusyInstallExtensionsController extends FakeExtensionsController { @override Future build() async { final initial = await super.build(); - return initial.copyWith(busyExtensionIds: {ExtensionsController.manualInstallBusyKey, 'extension-1'}); + return initial.copyWith( + devMode: true, + busyExtensionIds: {ExtensionsController.manualInstallBusyKey, 'extension-1'}, + ); } }