diff --git a/README.md b/README.md index a8a5ce17..7bcac313 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ Beyond core download management, Gopeed offers browser integration, JavaScript e Visit ✈ [Official Website](https://gopeed.com) -![Application screenshot](_docs/img/ui-concept-en.png) +![Application screenshot](_docs/img/ui-concept-en.webp) ## ✨ Features diff --git a/README_ja-JP.md b/README_ja-JP.md index fabaabb7..5de9df8b 100644 --- a/README_ja-JP.md +++ b/README_ja-JP.md @@ -20,7 +20,7 @@ Gopeed(**Go Speed** の略)は、Go と Flutter で開発された高速で ✈ [公式サイト](https://gopeed.com) -![アプリのスクリーンショット](_docs/img/ui-concept-en.png) +![アプリのスクリーンショット](_docs/img/ui-concept-en.webp) ## ✨ 主な機能 diff --git a/README_vi-VN.md b/README_vi-VN.md index b519dbfa..7fbf91f7 100644 --- a/README_vi-VN.md +++ b/README_vi-VN.md @@ -20,7 +20,7 @@ Ngoài việc quản lý tải xuống hằng ngày, Gopeed còn hỗ trợ tíc Truy cập ✈ [Trang web chính thức](https://gopeed.com) -![Ảnh chụp màn hình ứng dụng](_docs/img/ui-concept-en.png) +![Ảnh chụp màn hình ứng dụng](_docs/img/ui-concept-en.webp) ## ✨ Tính năng chính diff --git a/README_zh-CN.md b/README_zh-CN.md index bd05d0aa..68b32ad4 100644 --- a/README_zh-CN.md +++ b/README_zh-CN.md @@ -20,7 +20,7 @@ Gopeed(全称 **Go Speed**,也叫“够快下载器”)是一款使用 Go 访问 ✈ [官方网站](https://gopeed.com) -![应用截图](_docs/img/ui-concept-zh-CN.png) +![应用截图](_docs/img/ui-concept-zh-CN.webp) ## ✨ 主要特性 diff --git a/README_zh-TW.md b/README_zh-TW.md index 40015dd4..d3936aa9 100644 --- a/README_zh-TW.md +++ b/README_zh-TW.md @@ -20,7 +20,7 @@ Gopeed(**Go Speed** 的縮寫)是一款以 Go 與 Flutter 開發的高速、 瀏覽 ✈ [官方網站](https://gopeed.com) -![應用程式截圖](_docs/img/ui-concept-en.png) +![應用程式截圖](_docs/img/ui-concept-en.webp) ## ✨ 主要功能 diff --git a/_docs/img/mobile-ui-en.png b/_docs/img/mobile-ui-en.png deleted file mode 100644 index c1094912..00000000 Binary files a/_docs/img/mobile-ui-en.png and /dev/null differ diff --git a/_docs/img/mobile-ui-zh-CN.png b/_docs/img/mobile-ui-zh-CN.png deleted file mode 100644 index a94d75f4..00000000 Binary files a/_docs/img/mobile-ui-zh-CN.png and /dev/null differ diff --git a/_docs/img/ui-concept-en.png b/_docs/img/ui-concept-en.png deleted file mode 100644 index 5d267e9b..00000000 Binary files a/_docs/img/ui-concept-en.png and /dev/null differ diff --git a/_docs/img/ui-concept-en.webp b/_docs/img/ui-concept-en.webp new file mode 100644 index 00000000..84dc1b7a Binary files /dev/null and b/_docs/img/ui-concept-en.webp differ diff --git a/_docs/img/ui-concept-zh-CN.png b/_docs/img/ui-concept-zh-CN.png deleted file mode 100644 index a7d721bc..00000000 Binary files a/_docs/img/ui-concept-zh-CN.png and /dev/null differ diff --git a/_docs/img/ui-concept-zh-CN.webp b/_docs/img/ui-concept-zh-CN.webp new file mode 100644 index 00000000..8a9ec883 Binary files /dev/null and b/_docs/img/ui-concept-zh-CN.webp differ diff --git a/ui/flutter/docs/window-capability-rpc.md b/ui/flutter/docs/window-capability-rpc.md index a71ffa74..4fcc8e0a 100644 --- a/ui/flutter/docs/window-capability-rpc.md +++ b/ui/flutter/docs/window-capability-rpc.md @@ -143,3 +143,29 @@ with `taskIds` and an optional `parentId`. A successful response completes the batch, including partial success. The preview tracks completed batches so a failed batch can be retried without repeating successful batches. Creation does not accept a group ID to append members to an existing group. + +## 11. Opening Child Surfaces + +Use `AppRouter.openWindow(AppWindowPayload)` for child-window destinations. +`AppRouter.openCreateTask(createTask: ...)` is the create-task convenience API. +All entry points, including deep links, browser capture and the tray, use this +API rather than selecting a presentation themselves: + +- Desktop launches an independent native child window through + `AppWindowLauncher.open(payload)` and the existing capability transport. +- Web pushes an `AppWindowPage` on the root Navigator. This routed shadcn modal + keeps the current section, filters and scroll state mounted underneath it. +- Native mobile retains the full-page flow, including later share requests. +- If native child-window creation fails, the main window is brought forward + and the same in-app modal is used as a fallback. + +`AppWindowContent` owns the destination paths and content builders shared with +`ChildWindowApp`. For another child surface, add its payload type, register its +path and content, and use `AppWindowPage` for its in-app route. The modal's +preferred size can be supplied per destination. Keep backend and storage access +on the existing capability API in either presentation. + +Create-task content reports cancellation or completion through `onClose` when +hosted in-app. Cancellation pops only the child surface. Completion selects the +downloading list and returns home. The route payload uses a serializable string +so browser history can restore a modal and its initial request. 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 f960dcbd..1ed3ecdc 100644 --- a/ui/flutter/lib/app/application/app_deep_link_controller.dart +++ b/ui/flutter/lib/app/application/app_deep_link_controller.dart @@ -13,9 +13,7 @@ import '../../api/model/install_extension.dart'; import '../../api/model/request.dart'; import '../../core/entry/app_startup_options.dart'; import '../../core/utils/content_uri_resolver.dart'; -import '../../core/window/app_window_launcher.dart'; import '../../features/extensions/application/pending_extension_install.dart'; -import '../../features/tasks/application/pending_create_task.dart'; import '../../util/util.dart'; import '../router/app_router.dart'; import 'app_runtime_controller.dart'; @@ -118,18 +116,7 @@ class AppDeepLinkController extends AsyncNotifier { await _go('/'); } - Future _openCreate(CreateTask? createTask) async { - if (Util.isDesktop()) { - final opened = await AppWindowLauncher.openCreateTaskWindow(createTask: createTask); - if (!opened) { - ref.read(pendingCreateTaskProvider.notifier).set(createTask); - await _go('/create'); - } - return; - } - ref.read(pendingCreateTaskProvider.notifier).set(createTask); - await _go('/create'); - } + Future _openCreate(CreateTask? createTask) => AppRouter.openCreateTask(createTask: createTask); Future _go(String route) async { if (Util.isDesktop()) { diff --git a/ui/flutter/lib/app/application/app_platform_controller.dart b/ui/flutter/lib/app/application/app_platform_controller.dart index 5bab043e..39012228 100644 --- a/ui/flutter/lib/app/application/app_platform_controller.dart +++ b/ui/flutter/lib/app/application/app_platform_controller.dart @@ -14,9 +14,7 @@ import '../../api/api.dart' as api; import '../../api/model/downloader_config.dart'; import '../../core/libgopeed_boot.dart'; import '../../core/capabilities/app_capabilities.dart'; -import '../../core/window/app_window_launcher.dart'; import '../../core/window/window_capability_transport.dart'; -import '../../features/tasks/application/pending_create_task.dart'; import '../../features/tasks/application/pending_update_task.dart'; import '../../l10n/l10n.dart'; import '../../util/package_info.dart'; @@ -211,11 +209,7 @@ class AppPlatformController extends AsyncNotifier with WindowL await ref.read(gopeedServiceProvider).createTask(createTask); return; } - final opened = await AppWindowLauncher.openCreateTaskWindow(createTask: createTask); - if (!opened) { - ref.read(pendingCreateTaskProvider.notifier).set(createTask); - await _go('/create'); - } + await AppRouter.openCreateTask(createTask: createTask); }, onForward: ({required path, required method, data, query}) { return api.forward(path, method: method, data: data, queryParameters: query); @@ -348,10 +342,7 @@ class AppPlatformController extends AsyncNotifier with WindowL Future _openCreateTask() async { await _showMainWindow(); - final opened = await AppWindowLauncher.openCreateTaskWindow(); - if (!opened) { - _go('/create'); - } + await AppRouter.openCreateTask(); } Future _go(String route) async { diff --git a/ui/flutter/lib/app/child_window_app.dart b/ui/flutter/lib/app/child_window_app.dart index e38c30cb..12bef4bc 100644 --- a/ui/flutter/lib/app/child_window_app.dart +++ b/ui/flutter/lib/app/child_window_app.dart @@ -1,13 +1,12 @@ import 'package:flutter/widgets.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart' as shad; -import '../api/model/create_task.dart'; import '../app/application/app_appearance_controller.dart'; import '../core/window/app_window_bootstrap.dart'; import '../core/window/app_window_frame.dart'; import '../core/window/app_window_payload.dart'; import '../core/window/window_capability_transport.dart'; -import '../features/tasks/presentation/pages/create_task_window_page.dart'; +import 'router/app_window_content.dart'; import '../l10n/l10n.dart'; import '../shared/theme/app_component_themes.dart'; import '../shared/theme/app_theme.dart'; @@ -82,10 +81,7 @@ class _ChildWindowAppState extends State with WidgetsBindingObse Widget _buildPage(BuildContext context) { return switch (widget.payload.type) { - AppWindowType.createTask => CreateTaskWindowPage( - windowController: widget.session.controller, - initialTask: widget.payload.createTask == null ? null : CreateTask.fromJson(widget.payload.createTask!), - ), + AppWindowType.createTask => AppWindowContent.build(widget.payload, windowController: widget.session.controller), _ => shad.Scaffold(child: Center(child: Text(context.l10n.unsupportedWindowPayload))), }; } diff --git a/ui/flutter/lib/app/router/app_router.dart b/ui/flutter/lib/app/router/app_router.dart index c1d7ee62..828a5014 100644 --- a/ui/flutter/lib/app/router/app_router.dart +++ b/ui/flutter/lib/app/router/app_router.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; import 'package:go_router/go_router.dart'; @@ -11,7 +13,13 @@ import '../../features/auth/application/web_auth_controller.dart'; import '../../features/auth/presentation/pages/login_page.dart'; import '../../features/home/presentation/pages/home_page.dart'; import '../../features/settings/presentation/pages/settings_page.dart'; -import '../../features/tasks/presentation/pages/create_task_window_page.dart'; +import '../../api/model/create_task.dart'; +import '../../core/window/app_window_chrome.dart'; +import '../../core/window/app_window_launcher.dart'; +import '../../core/window/app_window_payload.dart'; +import '../../features/tasks/application/pending_create_task.dart'; +import 'app_window_content.dart'; +import 'app_window_page.dart'; 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'; @@ -22,6 +30,37 @@ import 'runtime_page.dart'; class AppRouter { static final GlobalKey rootNavigatorKey = GlobalKey(); + static bool get _isMobile => + !kIsWeb && (defaultTargetPlatform == TargetPlatform.android || defaultTargetPlatform == TargetPlatform.iOS); + + static Future openCreateTask({CreateTask? createTask}) => + openWindow(AppWindowPayload.createTask(createTask: createTask?.toJson())); + + /// Opens a desktop child window, a Web modal, or a native mobile page. + /// Callers never need to choose a presentation or implement a fallback. + static Future openWindow(AppWindowPayload payload) async { + final location = AppWindowContent.location(payload.type); + if (AppWindowChrome.isDesktopWindow && await AppWindowLauncher.open(payload)) return; + final context = rootNavigatorKey.currentContext; + if (context == null || !context.mounted) return; + final router = GoRouter.of(context); + if (router.state.uri.path == location) { + // Native share intents may arrive while the creation page is already open. + if (payload.type == AppWindowType.createTask && payload.createTask != null) { + ProviderScope.containerOf( + context, + listen: false, + ).read(pendingCreateTaskProvider.notifier).set(CreateTask.fromJson(payload.createTask!)); + } + return; + } + if (_isMobile) { + router.go(location, extra: payload.toRaw()); + } else { + unawaited(router.push(location, extra: payload.toRaw())); + } + } + static void showDownloadingTasks() { final context = rootNavigatorKey.currentContext; if (context == null || !context.mounted) return; @@ -31,8 +70,7 @@ 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 isMobile = _isMobile; final sectionRoutes = [ GoRoute( path: isMobile ? 'extensions' : '/extensions', @@ -83,7 +121,33 @@ class AppRouter { path: '/', builder: _runtimePage((context, state) => const HomePage()), routes: [ - GoRoute(path: 'create', builder: _runtimePage((context, state) => const CreateTaskWindowPage())), + GoRoute( + path: 'create', + parentNavigatorKey: rootNavigatorKey, + pageBuilder: (context, state) { + final payload = state.extra is String + ? AppWindowPayload.fromRaw(state.extra! as String) + : AppWindowPayload.createTask(); + final child = RuntimePage( + builder: (context) => AppWindowContent.build( + payload, + modal: !isMobile, + onClose: (created) { + if (created) { + showDownloadingTasks(); + } else if (context.canPop()) { + context.pop(); + } else { + context.go('/'); + } + }, + ), + ); + return isMobile + ? NoTransitionPage(key: state.pageKey, child: child) + : AppWindowPage(key: state.pageKey, child: child); + }, + ), GoRoute( path: 'tasks/:id', builder: _runtimePage( diff --git a/ui/flutter/lib/app/router/app_window_content.dart b/ui/flutter/lib/app/router/app_window_content.dart new file mode 100644 index 00000000..c6a5e7e6 --- /dev/null +++ b/ui/flutter/lib/app/router/app_window_content.dart @@ -0,0 +1,29 @@ +import 'package:desktop_multi_window/desktop_multi_window.dart'; +import 'package:flutter/widgets.dart'; + +import '../../api/model/create_task.dart'; +import '../../core/window/app_window_payload.dart'; +import '../../features/tasks/presentation/pages/create_task_window_page.dart'; + +/// Shared destination registry for desktop child engines and in-app windows. +abstract final class AppWindowContent { + static String location(AppWindowType type) => switch (type) { + AppWindowType.createTask => '/create', + AppWindowType.main => throw ArgumentError.value(type, 'type', 'Expected a child window'), + }; + + static Widget build( + AppWindowPayload payload, { + WindowController? windowController, + ValueChanged? onClose, + bool modal = false, + }) => switch (payload.type) { + AppWindowType.createTask => CreateTaskWindowPage( + windowController: windowController, + initialTask: payload.createTask == null ? null : CreateTask.fromJson(payload.createTask!), + onClose: onClose, + modal: modal, + ), + AppWindowType.main => throw ArgumentError.value(payload.type, 'payload.type', 'Expected a child window'), + }; +} diff --git a/ui/flutter/lib/app/router/app_window_page.dart b/ui/flutter/lib/app/router/app_window_page.dart new file mode 100644 index 00000000..b6f0af60 --- /dev/null +++ b/ui/flutter/lib/app/router/app_window_page.dart @@ -0,0 +1,64 @@ +import 'package:shadcn_flutter/shadcn_flutter.dart'; + +import '../../shared/theme/app_design_tokens.dart'; +import '../../shared/theme/app_palette.dart'; +import '../../core/utils/breakpoints.dart'; + +/// A routed modal keeps its underlying page mounted and participates in Back. +class AppWindowPage extends Page { + const AppWindowPage({super.key, required this.child, this.preferredSize = AppDesignTokens.childWindowSize}); + + final Widget child; + final Size preferredSize; + + @override + Route createRoute(BuildContext context) => DialogRoute( + context: context, + settings: this, + barrierDismissible: false, + barrierColor: Theme.of(context).colorScheme.background.withValues(alpha: 0.6), + traversalEdgeBehavior: TraversalEdgeBehavior.closedLoop, + alignment: Alignment.center, + fullScreen: true, + transitionBuilder: (context, animation, secondaryAnimation, child) => + FadeTransition(opacity: animation, child: child), + builder: (context) => Padding( + padding: EdgeInsets.only(bottom: MediaQuery.viewInsetsOf(context).bottom), + child: LayoutBuilder( + builder: (context, constraints) { + final page = ModalRoute.settingsOf(context)! as AppWindowPage; + final palette = AppPalette.of(context); + return Padding( + padding: EdgeInsets.all( + constraints.maxWidth < Breakpoints.mobile ? AppDesignTokens.space12 : AppDesignTokens.space32, + ), + child: Center( + child: ConstrainedBox( + constraints: BoxConstraints(maxWidth: page.preferredSize.width, maxHeight: page.preferredSize.height), + child: ModalContainer( + key: const ValueKey('app-window-modal'), + padding: EdgeInsets.zero, + filled: true, + fillColor: palette.sideBg, + borderColor: palette.border, + borderRadius: BorderRadius.circular(AppDesignTokens.windowRadius), + clipBehavior: Clip.antiAlias, + child: MediaQuery.removeViewInsets( + context: context, + removeBottom: true, + child: MediaQuery.removePadding( + context: context, + removeTop: true, + removeBottom: true, + child: page.child, + ), + ), + ), + ), + ), + ); + }, + ), + ), + ); +} diff --git a/ui/flutter/lib/app/router/shells/main_shell.dart b/ui/flutter/lib/app/router/shells/main_shell.dart index 4af3f1de..46fc47a3 100644 --- a/ui/flutter/lib/app/router/shells/main_shell.dart +++ b/ui/flutter/lib/app/router/shells/main_shell.dart @@ -3,7 +3,6 @@ import 'dart:async'; 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 '../../../api/model/create_task.dart'; import '../../application/app_deep_link_controller.dart'; @@ -11,11 +10,10 @@ import '../../application/app_notification_controller.dart'; import '../../application/app_runtime_controller.dart'; import '../../application/app_platform_controller.dart'; import '../../../features/settings/presentation/widgets/app_update_dialog.dart'; -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 '../../../core/window/app_window_launcher.dart'; +import '../app_router.dart'; import '../../../shared/widgets/app_toast.dart'; class MainShell extends ConsumerStatefulWidget { @@ -124,11 +122,5 @@ class _MainShellState extends ConsumerState { } } - Future _openCapturedCreateTask(CreateTask createTask) async { - final opened = await AppWindowLauncher.openCreateTaskWindow(createTask: createTask); - if (!opened && mounted) { - ref.read(pendingCreateTaskProvider.notifier).set(createTask); - context.go('/create'); - } - } + Future _openCapturedCreateTask(CreateTask createTask) => AppRouter.openCreateTask(createTask: createTask); } diff --git a/ui/flutter/lib/core/window/app_window_bootstrap.dart b/ui/flutter/lib/core/window/app_window_bootstrap.dart index e2b29316..d0a620a6 100644 --- a/ui/flutter/lib/core/window/app_window_bootstrap.dart +++ b/ui/flutter/lib/core/window/app_window_bootstrap.dart @@ -4,6 +4,7 @@ import 'package:flutter/widgets.dart'; import 'package:window_manager/window_manager.dart'; import '../../l10n/l10n.dart'; +import '../../shared/theme/app_design_tokens.dart'; import 'app_window_chrome.dart'; import 'app_window_payload.dart'; @@ -46,7 +47,7 @@ class AppWindowBootstrap { final options = switch (type) { AppWindowType.createTask => WindowOptions( - size: Size(820, 620), + size: AppDesignTokens.childWindowSize, center: true, skipTaskbar: false, backgroundColor: Colors.transparent, diff --git a/ui/flutter/lib/core/window/app_window_launcher.dart b/ui/flutter/lib/core/window/app_window_launcher.dart index 72a70557..4f7d70a1 100644 --- a/ui/flutter/lib/core/window/app_window_launcher.dart +++ b/ui/flutter/lib/core/window/app_window_launcher.dart @@ -2,22 +2,24 @@ import 'dart:io'; import 'package:desktop_multi_window/desktop_multi_window.dart'; import 'package:flutter/foundation.dart'; +import 'package:window_manager/window_manager.dart'; -import '../../api/model/create_task.dart'; import '../../util/log_util.dart'; import '../capabilities/app_capabilities.dart'; import 'app_window_payload.dart'; import 'window_capability_transport.dart'; class AppWindowLauncher { - static Future openCreateTaskWindow({CreateTask? createTask}) async { + static Future open(AppWindowPayload payload) async { + if (payload.type == AppWindowType.main) { + throw ArgumentError.value(payload.type, 'payload.type', 'Expected a child window'); + } if (kIsWeb || !(Platform.isWindows || Platform.isMacOS || Platform.isLinux)) { return false; } try { await AppWindowCapabilityHost.instance.start(LocalAppCapabilities.instance.registry); - final payload = AppWindowPayload.createTask(createTask: createTask?.toJson()); // Every request deliberately creates a new child window. Create-task // windows are independent and are never reused. await WindowController.create(WindowConfiguration(arguments: payload.toRaw(), hiddenAtLaunch: true)); @@ -27,10 +29,16 @@ class AppWindowLauncher { return true; } catch (error, stackTrace) { try { - logger.w('create task window failed', error, stackTrace); + logger.w('child window failed', error, stackTrace); } catch (_) { - debugPrint('create task window failed: $error'); + debugPrint('child window failed: $error'); } + // A tray/deep-link request can arrive while the main window is hidden. + // Bring the in-app fallback into view if the child engine cannot start. + try { + await windowManager.show(); + await windowManager.focus(); + } catch (_) {} return false; } } 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 6d404f8d..8094180b 100644 --- a/ui/flutter/lib/features/home/presentation/pages/home_page.dart +++ b/ui/flutter/lib/features/home/presentation/pages/home_page.dart @@ -11,7 +11,7 @@ import '../../../../app/application/app_runtime_controller.dart'; import '../../../../core/capabilities/app_capabilities.dart'; import '../../../../core/utils/breakpoints.dart'; import '../../../../core/utils/file_explorer.dart'; -import '../../../../core/window/app_window_launcher.dart'; +import '../../../../app/router/app_router.dart'; import '../../../../core/window/app_window_chrome.dart'; import '../../../../shared/theme/app_design_tokens.dart'; import '../../../../shared/theme/app_palette.dart'; @@ -580,19 +580,7 @@ class _HomePageState extends ConsumerState { } } - void _handleAddTask() { - if (!Util.isDesktop()) { - context.go('/create'); - return; - } - unawaited( - AppWindowLauncher.openCreateTaskWindow().then((opened) { - if (!opened && mounted) { - context.go('/create'); - } - }), - ); - } + void _handleAddTask() => unawaited(AppRouter.openCreateTask()); Future _runTaskAction(Future Function() action) async { try { diff --git a/ui/flutter/lib/features/home/presentation/widgets/primary_rail.dart b/ui/flutter/lib/features/home/presentation/widgets/primary_rail.dart index 6401e1de..03051cb6 100644 --- a/ui/flutter/lib/features/home/presentation/widgets/primary_rail.dart +++ b/ui/flutter/lib/features/home/presentation/widgets/primary_rail.dart @@ -1,5 +1,4 @@ -import 'package:flutter/material.dart' show Icons; -import 'package:flutter/widgets.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; import 'package:go_router/go_router.dart'; import '../../../../core/window/app_window_chrome.dart'; @@ -28,7 +27,21 @@ class PrimaryRail extends StatelessWidget { children: [ SizedBox( height: AppDesignTokens.contentHeaderHeight, - child: const Center(child: GopeedAppMark(key: ValueKey('primary-rail-app-mark'))), + child: Center( + child: AppTooltip( + message: context.l10n.task, + child: Semantics( + label: context.l10n.task, + button: true, + child: GhostButton( + key: const ValueKey('primary-rail-home-button'), + density: ButtonDensity.icon, + onPressed: () => context.go('/'), + child: const ExcludeSemantics(child: GopeedAppMark(key: ValueKey('primary-rail-app-mark'))), + ), + ), + ), + ), ), const SizedBox(height: AppDesignTokens.space8), _RailItem( 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 398ea006..abc96c13 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 @@ -44,10 +44,12 @@ import '../widgets/create_task_action_buttons.dart'; import '../widgets/resolve_download_dialog.dart'; class CreateTaskWindowPage extends ConsumerStatefulWidget { - const CreateTaskWindowPage({super.key, this.windowController, this.initialTask}); + const CreateTaskWindowPage({super.key, this.windowController, this.initialTask, this.onClose, this.modal = false}); final multi_window.WindowController? windowController; final CreateTask? initialTask; + final ValueChanged? onClose; + final bool modal; @override ConsumerState createState() => _CreateTaskWindowPageState(); @@ -133,7 +135,7 @@ class _CreateTaskWindowPageState extends ConsumerState { if (widget.initialTask == null) { ref.read(pendingCreateTaskProvider.notifier).clear(); } - if (widget.windowController == null && widget.initialTask == null) { + if (widget.windowController == null) { // Navigating to /create again reuses this page, so consume subsequent // browser/share requests as well as the initial pending task. ref.listenManual(pendingCreateTaskProvider, (_, next) { @@ -371,7 +373,9 @@ class _CreateTaskWindowPageState extends ConsumerState { child: Padding( key: const ValueKey('create-task-safe-content'), padding: EdgeInsets.only( - top: AppWindowChrome.reservesHeaderInset + top: widget.modal + ? 0 + : AppWindowChrome.reservesHeaderInset ? AppDesignTokens.windowHeaderHeight : MediaQuery.paddingOf(context).top, ), @@ -867,6 +871,10 @@ class _CreateTaskWindowPageState extends ConsumerState { } Future _closeWindow() async { + if (widget.onClose != null) { + widget.onClose!(false); + return; + } if (widget.windowController == null && mounted) { context.go('/'); return; @@ -888,6 +896,10 @@ class _CreateTaskWindowPageState extends ConsumerState { } } if (!mounted || session != _createSession) return; + if (widget.onClose != null) { + widget.onClose!(true); + return; + } if (widget.windowController != null) { // Navigation is best-effort after a successful submission. A navigation // failure must not leave a completed form available for resubmission. diff --git a/ui/flutter/lib/shared/theme/app_design_tokens.dart b/ui/flutter/lib/shared/theme/app_design_tokens.dart index 4904b483..686ec842 100644 --- a/ui/flutter/lib/shared/theme/app_design_tokens.dart +++ b/ui/flutter/lib/shared/theme/app_design_tokens.dart @@ -3,6 +3,7 @@ import 'package:flutter/widgets.dart'; class AppDesignTokens { const AppDesignTokens._(); + static const Size childWindowSize = Size(820, 620); static const double windowHeaderHeight = 30; static const double windowRadius = 12; // Leave room for native macOS traffic lights while keeping rail widths consistent across platforms. diff --git a/ui/flutter/test/app/router/app_router_test.dart b/ui/flutter/test/app/router/app_router_test.dart index bc0bea1c..4a4d038d 100644 --- a/ui/flutter/test/app/router/app_router_test.dart +++ b/ui/flutter/test/app/router/app_router_test.dart @@ -15,7 +15,7 @@ void main() { final router = AppRouter.build(auth); addTearDown(router.dispose); addTearDown(auth.dispose); - final isMobile = platform == TargetPlatform.android || platform == TargetPlatform.iOS; + final isMobile = !kIsWeb && (platform == TargetPlatform.android || platform == TargetPlatform.iOS); for (final entry in { '/': ['/'], @@ -28,8 +28,15 @@ void main() { }.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); + final shell = match.matches.first as ShellRouteMatch; + final routes = [ + ...shell.matches.map((match) => match.route as GoRoute), + ...match.matches.skip(1).map((match) => match.route as GoRoute), + ]; + expect(routes.map((route) => route.path), entry.value); + if (entry.key == '/create') { + expect(routes.last.parentNavigatorKey, same(AppRouter.rootNavigatorKey)); + } } }); } diff --git a/ui/flutter/test/app/router/app_window_navigation_test.dart b/ui/flutter/test/app/router/app_window_navigation_test.dart new file mode 100644 index 00000000..0b77a68a --- /dev/null +++ b/ui/flutter/test/app/router/app_window_navigation_test.dart @@ -0,0 +1,351 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart' show kIsWeb; +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:go_router/go_router.dart'; +import 'package:gopeed/api/model/create_task.dart'; +import 'package:gopeed/api/model/create_task_batch.dart'; +import 'package:gopeed/api/model/downloader_config.dart'; +import 'package:gopeed/api/model/request.dart'; +import 'package:gopeed/api/model/resolve_result.dart'; +import 'package:gopeed/api/model/resource.dart'; +import 'package:gopeed/app/app.dart'; +import 'package:gopeed/app/application/app_deep_link_controller.dart'; +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/app/router/app_router.dart'; +import 'package:gopeed/core/capabilities/app_capabilities.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/core/common/api_server_state.dart'; +import 'package:gopeed/core/common/start_config.dart'; +import 'package:gopeed/core/window/window_capability_transport.dart'; +import 'package:gopeed/features/home/presentation/pages/home_page.dart'; +import 'package:gopeed/features/tasks/application/task_list_navigation.dart'; +import 'package:gopeed/features/tasks/application/tasks_controller.dart'; +import 'package:gopeed/features/tasks/presentation/pages/create_task_window_page.dart'; +import 'package:gopeed/shared/widgets/app_text_field.dart'; + +void main() { + const windowChannel = MethodChannel('mixin.one/desktop_multi_window'); + const channels = MethodChannel('mixin.one/desktop_multi_window/channels'); + const windowManagerChannel = MethodChannel('window_manager'); + final binding = TestWidgetsFlutterBinding.ensureInitialized(); + final messenger = TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + + setUp(() { + binding.platformDispatcher.localesTestValue = const [Locale('en')]; + messenger.setMockMethodCallHandler(windowChannel, (_) async => throw PlatformException(code: 'unavailable')); + messenger.setMockMethodCallHandler(channels, (_) async => null); + messenger.setMockMethodCallHandler( + windowManagerChannel, + (call) async => call.method == 'isMinimized' ? false : null, + ); + }); + tearDown(() async { + await AppWindowCapabilityHost.instance.stop(); + messenger.setMockMethodCallHandler(windowChannel, null); + messenger.setMockMethodCallHandler(channels, null); + messenger.setMockMethodCallHandler(windowManagerChannel, null); + binding.platformDispatcher.clearLocalesTestValue(); + }); + + Future mount(WidgetTester tester, {CapabilityRegistry? registry}) async { + tester.view.physicalSize = const Size(1024, 768); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + registry ??= CapabilityRegistry(createAppCapabilityCodecs()) + ..bind(GopeedMethods.getConfig, (_) => DownloaderConfig(downloadDir: '/downloads')); + final container = ProviderContainer( + overrides: [ + appCapabilitiesProvider.overrideWithValue(AppCapabilities(LocalCapabilityInvoker(registry))), + appRuntimeControllerProvider.overrideWith(_Runtime.new), + appPlatformControllerProvider.overrideWith(_Platform.new), + appDeepLinkControllerProvider.overrideWith(_DeepLinks.new), + appNotificationControllerProvider.overrideWith(_Notifications.new), + tasksControllerProvider.overrideWith(_Tasks.new), + ], + ); + addTearDown(container.dispose); + await tester.pumpWidget(UncontrolledProviderScope(container: container, child: const GopeedApp())); + await tester.pumpAndSettle(); + return container; + } + + Future unmount(WidgetTester tester) async { + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + } + + testWidgets( + 'modal cancel and history restore preserve the mounted task list', + (tester) async { + await mount(tester); + final router = GoRouter.of(AppRouter.rootNavigatorKey.currentContext!); + final home = tester.state(find.byType(HomePage)); + final search = find.descendant( + of: find.byKey(const ValueKey('tasks-search-field-container')), + matching: find.byType(AppTextField), + ); + await tester.enterText(search, 'keep this filter'); + await tester.pumpAndSettle(); + final before = router.routeInformationProvider.value; + await tester.tap(find.byKey(const ValueKey('tasks-create-button-container'))); + await tester.pumpAndSettle(); + expect(find.byKey(const ValueKey('app-window-modal')), findsOneWidget); + expect(find.byType(HomePage, skipOffstage: false), findsOneWidget); + final opened = router.routeInformationProvider.value; + final modalBounds = tester.getRect(find.byKey(const ValueKey('app-window-modal'))); + expect(modalBounds.size, const Size(820, 620)); + final padding = tester.widget(find.byKey(const ValueKey('create-task-safe-content'))); + expect(padding.padding, EdgeInsets.zero); + + await router.routeInformationProvider.didPushRouteInformation(before); + await tester.pumpAndSettle(); + expect(find.byType(CreateTaskWindowPage), findsNothing); + expect(tester.state(find.byType(HomePage)), same(home)); + expect(tester.widget(search).controller!.text, 'keep this filter'); + await router.routeInformationProvider.didPushRouteInformation(opened); + await tester.pumpAndSettle(); + expect(find.byKey(const ValueKey('app-window-modal')), findsOneWidget); + await tester.tap(find.byKey(const ValueKey('create-task-cancel-button'))); + await tester.pumpAndSettle(); + expect(tester.state(find.byType(HomePage)), same(home)); + expect(tester.widget(search).controller!.text, 'keep this filter'); + await unmount(tester); + }, + variant: TargetPlatformVariant({TargetPlatform.macOS}), + ); + + testWidgets('modal fits phone, tablet and desktop viewports', (tester) async { + await mount(tester); + final router = GoRouter.of(AppRouter.rootNavigatorKey.currentContext!); + router.go('/create'); + await tester.pumpAndSettle(); + for (final size in [const Size(375, 667), const Size(768, 700), const Size(1280, 900)]) { + tester.view.physicalSize = size; + await tester.pumpAndSettle(); + final bounds = tester.getRect(find.byKey(const ValueKey('app-window-modal'))); + expect(bounds.left, greaterThan(0)); + expect(bounds.top, greaterThanOrEqualTo(0)); + expect(bounds.right, lessThan(size.width)); + expect(bounds.bottom, lessThanOrEqualTo(size.height)); + expect(find.byKey(const ValueKey('create-task-confirm-button')).hitTestable(), findsOneWidget); + expect(tester.takeException(), isNull); + } + tester.view.physicalSize = const Size(375, 667); + tester.view.viewInsets = const FakeViewPadding(bottom: 260); + addTearDown(tester.view.resetViewInsets); + await tester.pumpAndSettle(); + final confirmBounds = tester.getRect(find.byKey(const ValueKey('create-task-confirm-button'))); + expect(confirmBounds.bottom, lessThanOrEqualTo(667 - 260)); + tester.view.resetViewInsets(); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const ValueKey('create-task-cancel-button'))); + await tester.pumpAndSettle(); + expect(find.byType(HomePage), findsOneWidget); + await unmount(tester); + }, variant: TargetPlatformVariant({TargetPlatform.macOS})); + + testWidgets( + 'failed submission keeps input and retry submits once then selects downloads', + (tester) async { + var attempts = 0; + Completer? pending; + final registry = CapabilityRegistry(createAppCapabilityCodecs()) + ..bind( + GopeedMethods.getConfig, + (_) => DownloaderConfig(downloadDir: '/downloads')..extra.defaultDirectDownload = true, + ) + ..bind(StorageMethods.saveCreateHistory, (_) => const RpcUnit()) + ..bind(GopeedMethods.createTaskBatch, (_) { + attempts++; + if (attempts == 1) throw StateError('offline'); + pending = Completer(); + return pending!.future; + }); + final container = await mount(tester, registry: registry); + await AppRouter.openCreateTask( + createTask: CreateTask(req: Request(url: 'https://example.com/file.zip')), + ); + await tester.pumpAndSettle(); + final url = find.byKey(const ValueKey('create-task-url-input')); + expect(tester.widget(url).controller!.text, 'https://example.com/file.zip'); + final confirm = find.byKey(const ValueKey('create-task-confirm-button')); + await tester.tap(confirm); + await tester.pumpAndSettle(); + expect(attempts, 1); + await tester.pump(const Duration(seconds: 6)); + await tester.pumpAndSettle(); + expect(tester.widget(url).controller!.text, 'https://example.com/file.zip'); + expect(find.byKey(const ValueKey('app-window-modal')), findsOneWidget); + await tester.tap(confirm); + await tester.pump(); + await tester.tap(confirm); + await tester.pump(); + expect(attempts, 2); + pending!.complete(CreateTaskBatchResult(taskIds: ['new-task'])); + await tester.pumpAndSettle(); + expect(find.byType(CreateTaskWindowPage), findsNothing); + expect(container.read(taskListNavigationProvider), 1); + expect(find.byType(HomePage), findsOneWidget); + await unmount(tester); + }, + variant: TargetPlatformVariant({TargetPlatform.macOS}), + ); + + testWidgets( + 'file preview can cancel back to the modal and submit the resolved selection', + (tester) async { + CreateTaskBatch? submitted; + final registry = CapabilityRegistry(createAppCapabilityCodecs()) + ..bind(GopeedMethods.getConfig, (_) => DownloaderConfig(downloadDir: '/downloads')) + ..bind(StorageMethods.saveCreateHistory, (_) => const RpcUnit()) + ..bind( + GopeedMethods.resolve, + (_) => ResolveResult( + id: 'resolved-torrent', + res: Resource( + name: 'bundle', + files: [ + FileInfo(name: 'first.zip', size: 10), + FileInfo(name: 'second.zip', size: 20), + ], + ), + ), + ) + ..bind(GopeedMethods.createTaskBatch, (batch) { + submitted = batch; + return CreateTaskBatchResult(taskIds: ['torrent-task']); + }); + await mount(tester, registry: registry); + await AppRouter.openCreateTask( + createTask: CreateTask(req: Request(url: 'magnet:?xt=urn:btih:test')), + ); + await tester.pumpAndSettle(); + final confirm = find.byKey(const ValueKey('create-task-confirm-button')); + await tester.tap(confirm); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + expect(find.text('first.zip'), findsOneWidget); + await tester.tap(find.byKey(const ValueKey('resolve-cancel-button'))); + await tester.pumpAndSettle(); + expect(find.byKey(const ValueKey('app-window-modal')), findsOneWidget); + expect(submitted, isNull); + await tester.tap(confirm); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + await tester.tap(find.byKey(const ValueKey('resolve-create-button'))); + await tester.pumpAndSettle(); + expect(submitted!.reqs!.single.rid, 'resolved-torrent'); + expect(submitted!.reqs!.single.opts!.selectFiles, [0, 1]); + expect(find.byType(CreateTaskWindowPage), findsNothing); + expect(find.byType(HomePage), findsOneWidget); + await unmount(tester); + }, + variant: TargetPlatformVariant({TargetPlatform.macOS}), + ); + + testWidgets('cancel returns to the section underneath the modal', (tester) async { + await mount(tester); + final router = GoRouter.of(AppRouter.rootNavigatorKey.currentContext!); + router.go('/settings'); + await tester.pumpAndSettle(); + await AppRouter.openCreateTask(); + await tester.pumpAndSettle(); + expect(find.byKey(const ValueKey('app-window-modal')), findsOneWidget); + await tester.tap(find.byKey(const ValueKey('create-task-cancel-button'))); + await tester.pumpAndSettle(); + expect(router.state.uri.path, '/settings'); + await unmount(tester); + }, variant: TargetPlatformVariant({TargetPlatform.macOS})); + + testWidgets( + 'native mobile opens a page and updates repeated share requests', + (tester) async { + await mount(tester); + await AppRouter.openCreateTask( + createTask: CreateTask(req: Request(url: 'https://example.com/first.zip')), + ); + await tester.pumpAndSettle(); + expect(find.byKey(const ValueKey('app-window-modal')), findsNothing); + expect(find.byType(CreateTaskWindowPage), findsOneWidget); + await AppRouter.openCreateTask( + createTask: CreateTask(req: Request(url: 'https://example.com/second.zip')), + ); + await tester.pumpAndSettle(); + final url = find.byKey(const ValueKey('create-task-url-input')); + expect(tester.widget(url).controller!.text, 'https://example.com/second.zip'); + await tester.tap(find.byKey(const ValueKey('create-task-cancel-button'))); + await tester.pumpAndSettle(); + expect(find.byType(HomePage), findsOneWidget); + await unmount(tester); + }, + skip: kIsWeb, + variant: TargetPlatformVariant({TargetPlatform.android, TargetPlatform.iOS}), + ); + + testWidgets('logo returns home with mouse and keyboard activation', (tester) async { + await mount(tester); + final router = GoRouter.of(AppRouter.rootNavigatorKey.currentContext!); + final logo = find.byKey(const ValueKey('primary-rail-home-button')); + for (final keyboard in [false, true]) { + router.go('/settings'); + await tester.pumpAndSettle(); + if (keyboard) { + Focus.of(tester.element(find.byKey(const ValueKey('primary-rail-app-mark')))).requestFocus(); + await tester.pump(); + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + } else { + await tester.tap(logo); + } + await tester.pumpAndSettle(); + expect(router.state.uri.path, '/'); + expect(find.byType(HomePage), findsOneWidget); + } + await unmount(tester); + }, variant: TargetPlatformVariant({TargetPlatform.macOS})); +} + +class _Runtime extends AppRuntimeController { + @override + Future build() async => AppRuntimeState( + startConfig: StartConfig() + ..network = 'tcp' + ..address = '127.0.0.1:9999' + ..apiEnable = true + ..apiToken = '' + ..storage = 'bolt' + ..storageDir = '' + ..refreshInterval = 0, + apiServerState: ApiServerState.fromJson({}), + downloaderConfig: DownloaderConfig()..extra.locale = 'en', + ); +} + +class _Platform extends AppPlatformController { + @override + Future build() async => const AppPlatformState(started: true); +} + +class _DeepLinks extends AppDeepLinkController { + @override + Future build() async => const AppDeepLinkState(started: true); +} + +class _Notifications extends AppNotificationController { + @override + Future build() async => const AppNotificationState(started: true); +} + +class _Tasks extends TasksController { + @override + Future build() async => const TasksState(tasks: []); +} diff --git a/ui/flutter/test/core/window/app_window_launcher_test.dart b/ui/flutter/test/core/window/app_window_launcher_test.dart index a9da92d1..c5487a7c 100644 --- a/ui/flutter/test/core/window/app_window_launcher_test.dart +++ b/ui/flutter/test/core/window/app_window_launcher_test.dart @@ -3,6 +3,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:gopeed/api/model/create_task.dart'; import 'package:gopeed/api/model/request.dart'; import 'package:gopeed/core/window/app_window_launcher.dart'; +import 'package:gopeed/core/window/app_window_payload.dart'; import 'package:gopeed/core/window/window_capability_transport.dart'; void main() { @@ -34,8 +35,10 @@ void main() { return null; }); - final opened = await AppWindowLauncher.openCreateTaskWindow( - createTask: CreateTask(req: Request(url: 'https://example.com/file.zip')), + final opened = await AppWindowLauncher.open( + AppWindowPayload.createTask( + createTask: CreateTask(req: Request(url: 'https://example.com/file.zip')).toJson(), + ), ); expect(opened, isTrue); @@ -56,6 +59,6 @@ void main() { return null; }); - expect(await AppWindowLauncher.openCreateTaskWindow(), isFalse); + expect(await AppWindowLauncher.open(AppWindowPayload.createTask()), isFalse); }); } diff --git a/ui/flutter/test/core/window/app_window_payload_test.dart b/ui/flutter/test/core/window/app_window_payload_test.dart index d4bdd090..e62237cc 100644 --- a/ui/flutter/test/core/window/app_window_payload_test.dart +++ b/ui/flutter/test/core/window/app_window_payload_test.dart @@ -9,9 +9,9 @@ void main() { test('create-task child window has a localized native title', () { expect( AppWindowBootstrap.subWindowTitle(AppWindowType.createTask, appLocalizationsFor('en')), - 'Gopeed-Create Task', + 'Gopeed - Create Task', ); - expect(AppWindowBootstrap.subWindowTitle(AppWindowType.createTask, appLocalizationsFor('zh')), 'Gopeed-创建任务'); + expect(AppWindowBootstrap.subWindowTitle(AppWindowType.createTask, appLocalizationsFor('zh')), 'Gopeed - 创建任务'); }); test('create-task payload only carries initial task data', () { diff --git a/ui/flutter/test/widget_test.dart b/ui/flutter/test/widget_test.dart index 004d9600..ee720eb1 100644 --- a/ui/flutter/test/widget_test.dart +++ b/ui/flutter/test/widget_test.dart @@ -2796,7 +2796,7 @@ void main() { await tester.tap(find.byKey(const ValueKey('settings-language-select'))); await tester.pumpAndSettle(); expect(find.text('English'), findsOneWidget); - expect(SettingsLanguageSelect.supportedValues, hasLength(22)); + expect(SettingsLanguageSelect.supportedValues, hasLength(AppLocalizations.supportedLocales.length)); expect(SettingsLanguageSelect.supportedValues, containsAll(['zh', 'zh_TW', 'pt'])); expect(SettingsLanguageSelect.supportedValues, isNot(contains('zh_CN'))); expect(SettingsLanguageSelect.supportedValues, isNot(contains('pt_BR')));