From 0d52b840a2636d5f0d371deee244cdb67a571e4a Mon Sep 17 00:00:00 2001 From: Levi Date: Sat, 12 Sep 2026 18:09:36 +0800 Subject: [PATCH 1/5] 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 2/5] 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 3/5] 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 4/5] 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 5/5] 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'); + }); +}