mirror of
https://github.com/GopeedLab/gopeed.git
synced 2026-11-15 12:53:41 +00:00
feat(ui): unify child surfaces and show web task creation in a modal (#1633)
This commit is contained in:
30 files changed
+618
-85
No files matched your search
@@ -20,7 +20,7 @@ Beyond core download management, Gopeed offers browser integration, JavaScript e
|
|||||||
|
|
||||||
Visit ✈ [Official Website](https://gopeed.com)
|
Visit ✈ [Official Website](https://gopeed.com)
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
## ✨ Features
|
## ✨ Features
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -20,7 +20,7 @@ Gopeed(**Go Speed** の略)は、Go と Flutter で開発された高速で
|
|||||||
|
|
||||||
✈ [公式サイト](https://gopeed.com)
|
✈ [公式サイト](https://gopeed.com)
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
## ✨ 主な機能
|
## ✨ 主な機能
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -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)
|
Truy cập ✈ [Trang web chính thức](https://gopeed.com)
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
## ✨ Tính năng chính
|
## ✨ Tính năng chính
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -20,7 +20,7 @@ Gopeed(全称 **Go Speed**,也叫“够快下载器”)是一款使用 Go
|
|||||||
|
|
||||||
访问 ✈ [官方网站](https://gopeed.com)
|
访问 ✈ [官方网站](https://gopeed.com)
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
## ✨ 主要特性
|
## ✨ 主要特性
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -20,7 +20,7 @@ Gopeed(**Go Speed** 的縮寫)是一款以 Go 與 Flutter 開發的高速、
|
|||||||
|
|
||||||
瀏覽 ✈ [官方網站](https://gopeed.com)
|
瀏覽 ✈ [官方網站](https://gopeed.com)
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
## ✨ 主要功能
|
## ✨ 主要功能
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 74 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 77 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 1.3 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 792 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 1.3 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 787 KiB |
@@ -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
|
batch, including partial success. The preview tracks completed batches so a
|
||||||
failed batch can be retried without repeating successful batches. Creation does
|
failed batch can be retried without repeating successful batches. Creation does
|
||||||
not accept a group ID to append members to an existing group.
|
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.
|
||||||
@@ -13,9 +13,7 @@ import '../../api/model/install_extension.dart';
|
|||||||
import '../../api/model/request.dart';
|
import '../../api/model/request.dart';
|
||||||
import '../../core/entry/app_startup_options.dart';
|
import '../../core/entry/app_startup_options.dart';
|
||||||
import '../../core/utils/content_uri_resolver.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/extensions/application/pending_extension_install.dart';
|
||||||
import '../../features/tasks/application/pending_create_task.dart';
|
|
||||||
import '../../util/util.dart';
|
import '../../util/util.dart';
|
||||||
import '../router/app_router.dart';
|
import '../router/app_router.dart';
|
||||||
import 'app_runtime_controller.dart';
|
import 'app_runtime_controller.dart';
|
||||||
@@ -118,18 +116,7 @@ class AppDeepLinkController extends AsyncNotifier<AppDeepLinkState> {
|
|||||||
await _go('/');
|
await _go('/');
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _openCreate(CreateTask? createTask) async {
|
Future<void> _openCreate(CreateTask? createTask) => AppRouter.openCreateTask(createTask: createTask);
|
||||||
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<void> _go(String route) async {
|
Future<void> _go(String route) async {
|
||||||
if (Util.isDesktop()) {
|
if (Util.isDesktop()) {
|
||||||
|
|||||||
@@ -14,9 +14,7 @@ import '../../api/api.dart' as api;
|
|||||||
import '../../api/model/downloader_config.dart';
|
import '../../api/model/downloader_config.dart';
|
||||||
import '../../core/libgopeed_boot.dart';
|
import '../../core/libgopeed_boot.dart';
|
||||||
import '../../core/capabilities/app_capabilities.dart';
|
import '../../core/capabilities/app_capabilities.dart';
|
||||||
import '../../core/window/app_window_launcher.dart';
|
|
||||||
import '../../core/window/window_capability_transport.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 '../../features/tasks/application/pending_update_task.dart';
|
||||||
import '../../l10n/l10n.dart';
|
import '../../l10n/l10n.dart';
|
||||||
import '../../util/package_info.dart';
|
import '../../util/package_info.dart';
|
||||||
@@ -211,11 +209,7 @@ class AppPlatformController extends AsyncNotifier<AppPlatformState> with WindowL
|
|||||||
await ref.read(gopeedServiceProvider).createTask(createTask);
|
await ref.read(gopeedServiceProvider).createTask(createTask);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
final opened = await AppWindowLauncher.openCreateTaskWindow(createTask: createTask);
|
await AppRouter.openCreateTask(createTask: createTask);
|
||||||
if (!opened) {
|
|
||||||
ref.read(pendingCreateTaskProvider.notifier).set(createTask);
|
|
||||||
await _go('/create');
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
onForward: ({required path, required method, data, query}) {
|
onForward: ({required path, required method, data, query}) {
|
||||||
return api.forward(path, method: method, data: data, queryParameters: query);
|
return api.forward(path, method: method, data: data, queryParameters: query);
|
||||||
@@ -348,10 +342,7 @@ class AppPlatformController extends AsyncNotifier<AppPlatformState> with WindowL
|
|||||||
|
|
||||||
Future<void> _openCreateTask() async {
|
Future<void> _openCreateTask() async {
|
||||||
await _showMainWindow();
|
await _showMainWindow();
|
||||||
final opened = await AppWindowLauncher.openCreateTaskWindow();
|
await AppRouter.openCreateTask();
|
||||||
if (!opened) {
|
|
||||||
_go('/create');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _go(String route) async {
|
Future<void> _go(String route) async {
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
import 'package:flutter/widgets.dart';
|
import 'package:flutter/widgets.dart';
|
||||||
import 'package:shadcn_flutter/shadcn_flutter.dart' as shad;
|
import 'package:shadcn_flutter/shadcn_flutter.dart' as shad;
|
||||||
|
|
||||||
import '../api/model/create_task.dart';
|
|
||||||
import '../app/application/app_appearance_controller.dart';
|
import '../app/application/app_appearance_controller.dart';
|
||||||
import '../core/window/app_window_bootstrap.dart';
|
import '../core/window/app_window_bootstrap.dart';
|
||||||
import '../core/window/app_window_frame.dart';
|
import '../core/window/app_window_frame.dart';
|
||||||
import '../core/window/app_window_payload.dart';
|
import '../core/window/app_window_payload.dart';
|
||||||
import '../core/window/window_capability_transport.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 '../l10n/l10n.dart';
|
||||||
import '../shared/theme/app_component_themes.dart';
|
import '../shared/theme/app_component_themes.dart';
|
||||||
import '../shared/theme/app_theme.dart';
|
import '../shared/theme/app_theme.dart';
|
||||||
@@ -82,10 +81,7 @@ class _ChildWindowAppState extends State<ChildWindowApp> with WidgetsBindingObse
|
|||||||
|
|
||||||
Widget _buildPage(BuildContext context) {
|
Widget _buildPage(BuildContext context) {
|
||||||
return switch (widget.payload.type) {
|
return switch (widget.payload.type) {
|
||||||
AppWindowType.createTask => CreateTaskWindowPage(
|
AppWindowType.createTask => AppWindowContent.build(widget.payload, windowController: widget.session.controller),
|
||||||
windowController: widget.session.controller,
|
|
||||||
initialTask: widget.payload.createTask == null ? null : CreateTask.fromJson(widget.payload.createTask!),
|
|
||||||
),
|
|
||||||
_ => shad.Scaffold(child: Center(child: Text(context.l10n.unsupportedWindowPayload))),
|
_ => shad.Scaffold(child: Center(child: Text(context.l10n.unsupportedWindowPayload))),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/widgets.dart';
|
import 'package:flutter/widgets.dart';
|
||||||
import 'package:go_router/go_router.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/auth/presentation/pages/login_page.dart';
|
||||||
import '../../features/home/presentation/pages/home_page.dart';
|
import '../../features/home/presentation/pages/home_page.dart';
|
||||||
import '../../features/settings/presentation/pages/settings_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/domain/task_record.dart';
|
||||||
import '../../features/tasks/presentation/pages/task_details_page.dart';
|
import '../../features/tasks/presentation/pages/task_details_page.dart';
|
||||||
import '../../features/tasks/presentation/pages/task_files_page.dart';
|
import '../../features/tasks/presentation/pages/task_files_page.dart';
|
||||||
@@ -22,6 +30,37 @@ import 'runtime_page.dart';
|
|||||||
class AppRouter {
|
class AppRouter {
|
||||||
static final GlobalKey<NavigatorState> rootNavigatorKey = GlobalKey<NavigatorState>();
|
static final GlobalKey<NavigatorState> rootNavigatorKey = GlobalKey<NavigatorState>();
|
||||||
|
|
||||||
|
static bool get _isMobile =>
|
||||||
|
!kIsWeb && (defaultTargetPlatform == TargetPlatform.android || defaultTargetPlatform == TargetPlatform.iOS);
|
||||||
|
|
||||||
|
static Future<void> 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<void> 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<void>(location, extra: payload.toRaw()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
static void showDownloadingTasks() {
|
static void showDownloadingTasks() {
|
||||||
final context = rootNavigatorKey.currentContext;
|
final context = rootNavigatorKey.currentContext;
|
||||||
if (context == null || !context.mounted) return;
|
if (context == null || !context.mounted) return;
|
||||||
@@ -31,8 +70,7 @@ class AppRouter {
|
|||||||
|
|
||||||
static GoRouter build(WebAuthController webAuthController) {
|
static GoRouter build(WebAuthController webAuthController) {
|
||||||
// Keep desktop and web sections as peers; native mobile retains home below them.
|
// Keep desktop and web sections as peers; native mobile retains home below them.
|
||||||
final isMobile =
|
final isMobile = _isMobile;
|
||||||
!kIsWeb && (defaultTargetPlatform == TargetPlatform.android || defaultTargetPlatform == TargetPlatform.iOS);
|
|
||||||
final sectionRoutes = [
|
final sectionRoutes = [
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: isMobile ? 'extensions' : '/extensions',
|
path: isMobile ? 'extensions' : '/extensions',
|
||||||
@@ -83,7 +121,33 @@ class AppRouter {
|
|||||||
path: '/',
|
path: '/',
|
||||||
builder: _runtimePage((context, state) => const HomePage()),
|
builder: _runtimePage((context, state) => const HomePage()),
|
||||||
routes: [
|
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<void>(key: state.pageKey, child: child)
|
||||||
|
: AppWindowPage<void>(key: state.pageKey, child: child);
|
||||||
|
},
|
||||||
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: 'tasks/:id',
|
path: 'tasks/:id',
|
||||||
builder: _runtimePage(
|
builder: _runtimePage(
|
||||||
|
|||||||
@@ -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<bool>? 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'),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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<T> extends Page<T> {
|
||||||
|
const AppWindowPage({super.key, required this.child, this.preferredSize = AppDesignTokens.childWindowSize});
|
||||||
|
|
||||||
|
final Widget child;
|
||||||
|
final Size preferredSize;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Route<T> createRoute(BuildContext context) => DialogRoute<T>(
|
||||||
|
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<T>;
|
||||||
|
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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,7 +3,6 @@ import 'dart:async';
|
|||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/widgets.dart';
|
import 'package:flutter/widgets.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
|
||||||
|
|
||||||
import '../../../api/model/create_task.dart';
|
import '../../../api/model/create_task.dart';
|
||||||
import '../../application/app_deep_link_controller.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_runtime_controller.dart';
|
||||||
import '../../application/app_platform_controller.dart';
|
import '../../application/app_platform_controller.dart';
|
||||||
import '../../../features/settings/presentation/widgets/app_update_dialog.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/pending_update_task.dart';
|
||||||
import '../../../features/tasks/application/tasks_controller.dart';
|
import '../../../features/tasks/application/tasks_controller.dart';
|
||||||
import '../../../features/tasks/presentation/widgets/pending_update_dialog.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';
|
import '../../../shared/widgets/app_toast.dart';
|
||||||
|
|
||||||
class MainShell extends ConsumerStatefulWidget {
|
class MainShell extends ConsumerStatefulWidget {
|
||||||
@@ -124,11 +122,5 @@ class _MainShellState extends ConsumerState<MainShell> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _openCapturedCreateTask(CreateTask createTask) async {
|
Future<void> _openCapturedCreateTask(CreateTask createTask) => AppRouter.openCreateTask(createTask: createTask);
|
||||||
final opened = await AppWindowLauncher.openCreateTaskWindow(createTask: createTask);
|
|
||||||
if (!opened && mounted) {
|
|
||||||
ref.read(pendingCreateTaskProvider.notifier).set(createTask);
|
|
||||||
context.go('/create');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -4,6 +4,7 @@ import 'package:flutter/widgets.dart';
|
|||||||
import 'package:window_manager/window_manager.dart';
|
import 'package:window_manager/window_manager.dart';
|
||||||
|
|
||||||
import '../../l10n/l10n.dart';
|
import '../../l10n/l10n.dart';
|
||||||
|
import '../../shared/theme/app_design_tokens.dart';
|
||||||
import 'app_window_chrome.dart';
|
import 'app_window_chrome.dart';
|
||||||
import 'app_window_payload.dart';
|
import 'app_window_payload.dart';
|
||||||
|
|
||||||
@@ -46,7 +47,7 @@ class AppWindowBootstrap {
|
|||||||
|
|
||||||
final options = switch (type) {
|
final options = switch (type) {
|
||||||
AppWindowType.createTask => WindowOptions(
|
AppWindowType.createTask => WindowOptions(
|
||||||
size: Size(820, 620),
|
size: AppDesignTokens.childWindowSize,
|
||||||
center: true,
|
center: true,
|
||||||
skipTaskbar: false,
|
skipTaskbar: false,
|
||||||
backgroundColor: Colors.transparent,
|
backgroundColor: Colors.transparent,
|
||||||
|
|||||||
@@ -2,22 +2,24 @@ import 'dart:io';
|
|||||||
|
|
||||||
import 'package:desktop_multi_window/desktop_multi_window.dart';
|
import 'package:desktop_multi_window/desktop_multi_window.dart';
|
||||||
import 'package:flutter/foundation.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 '../../util/log_util.dart';
|
||||||
import '../capabilities/app_capabilities.dart';
|
import '../capabilities/app_capabilities.dart';
|
||||||
import 'app_window_payload.dart';
|
import 'app_window_payload.dart';
|
||||||
import 'window_capability_transport.dart';
|
import 'window_capability_transport.dart';
|
||||||
|
|
||||||
class AppWindowLauncher {
|
class AppWindowLauncher {
|
||||||
static Future<bool> openCreateTaskWindow({CreateTask? createTask}) async {
|
static Future<bool> 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)) {
|
if (kIsWeb || !(Platform.isWindows || Platform.isMacOS || Platform.isLinux)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await AppWindowCapabilityHost.instance.start(LocalAppCapabilities.instance.registry);
|
await AppWindowCapabilityHost.instance.start(LocalAppCapabilities.instance.registry);
|
||||||
final payload = AppWindowPayload.createTask(createTask: createTask?.toJson());
|
|
||||||
// Every request deliberately creates a new child window. Create-task
|
// Every request deliberately creates a new child window. Create-task
|
||||||
// windows are independent and are never reused.
|
// windows are independent and are never reused.
|
||||||
await WindowController.create(WindowConfiguration(arguments: payload.toRaw(), hiddenAtLaunch: true));
|
await WindowController.create(WindowConfiguration(arguments: payload.toRaw(), hiddenAtLaunch: true));
|
||||||
@@ -27,10 +29,16 @@ class AppWindowLauncher {
|
|||||||
return true;
|
return true;
|
||||||
} catch (error, stackTrace) {
|
} catch (error, stackTrace) {
|
||||||
try {
|
try {
|
||||||
logger.w('create task window failed', error, stackTrace);
|
logger.w('child window failed', error, stackTrace);
|
||||||
} catch (_) {
|
} 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;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import '../../../../app/application/app_runtime_controller.dart';
|
|||||||
import '../../../../core/capabilities/app_capabilities.dart';
|
import '../../../../core/capabilities/app_capabilities.dart';
|
||||||
import '../../../../core/utils/breakpoints.dart';
|
import '../../../../core/utils/breakpoints.dart';
|
||||||
import '../../../../core/utils/file_explorer.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 '../../../../core/window/app_window_chrome.dart';
|
||||||
import '../../../../shared/theme/app_design_tokens.dart';
|
import '../../../../shared/theme/app_design_tokens.dart';
|
||||||
import '../../../../shared/theme/app_palette.dart';
|
import '../../../../shared/theme/app_palette.dart';
|
||||||
@@ -580,19 +580,7 @@ class _HomePageState extends ConsumerState<HomePage> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _handleAddTask() {
|
void _handleAddTask() => unawaited(AppRouter.openCreateTask());
|
||||||
if (!Util.isDesktop()) {
|
|
||||||
context.go('/create');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
unawaited(
|
|
||||||
AppWindowLauncher.openCreateTaskWindow().then((opened) {
|
|
||||||
if (!opened && mounted) {
|
|
||||||
context.go('/create');
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _runTaskAction(Future<void> Function() action) async {
|
Future<void> _runTaskAction(Future<void> Function() action) async {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import 'package:flutter/material.dart' show Icons;
|
import 'package:shadcn_flutter/shadcn_flutter.dart';
|
||||||
import 'package:flutter/widgets.dart';
|
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
import '../../../../core/window/app_window_chrome.dart';
|
import '../../../../core/window/app_window_chrome.dart';
|
||||||
@@ -28,7 +27,21 @@ class PrimaryRail extends StatelessWidget {
|
|||||||
children: [
|
children: [
|
||||||
SizedBox(
|
SizedBox(
|
||||||
height: AppDesignTokens.contentHeaderHeight,
|
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),
|
const SizedBox(height: AppDesignTokens.space8),
|
||||||
_RailItem(
|
_RailItem(
|
||||||
|
|||||||
@@ -44,10 +44,12 @@ import '../widgets/create_task_action_buttons.dart';
|
|||||||
import '../widgets/resolve_download_dialog.dart';
|
import '../widgets/resolve_download_dialog.dart';
|
||||||
|
|
||||||
class CreateTaskWindowPage extends ConsumerStatefulWidget {
|
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 multi_window.WindowController? windowController;
|
||||||
final CreateTask? initialTask;
|
final CreateTask? initialTask;
|
||||||
|
final ValueChanged<bool>? onClose;
|
||||||
|
final bool modal;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
ConsumerState<CreateTaskWindowPage> createState() => _CreateTaskWindowPageState();
|
ConsumerState<CreateTaskWindowPage> createState() => _CreateTaskWindowPageState();
|
||||||
@@ -133,7 +135,7 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
|
|||||||
if (widget.initialTask == null) {
|
if (widget.initialTask == null) {
|
||||||
ref.read(pendingCreateTaskProvider.notifier).clear();
|
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
|
// Navigating to /create again reuses this page, so consume subsequent
|
||||||
// browser/share requests as well as the initial pending task.
|
// browser/share requests as well as the initial pending task.
|
||||||
ref.listenManual(pendingCreateTaskProvider, (_, next) {
|
ref.listenManual(pendingCreateTaskProvider, (_, next) {
|
||||||
@@ -371,7 +373,9 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
|
|||||||
child: Padding(
|
child: Padding(
|
||||||
key: const ValueKey('create-task-safe-content'),
|
key: const ValueKey('create-task-safe-content'),
|
||||||
padding: EdgeInsets.only(
|
padding: EdgeInsets.only(
|
||||||
top: AppWindowChrome.reservesHeaderInset
|
top: widget.modal
|
||||||
|
? 0
|
||||||
|
: AppWindowChrome.reservesHeaderInset
|
||||||
? AppDesignTokens.windowHeaderHeight
|
? AppDesignTokens.windowHeaderHeight
|
||||||
: MediaQuery.paddingOf(context).top,
|
: MediaQuery.paddingOf(context).top,
|
||||||
),
|
),
|
||||||
@@ -867,6 +871,10 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _closeWindow() async {
|
Future<void> _closeWindow() async {
|
||||||
|
if (widget.onClose != null) {
|
||||||
|
widget.onClose!(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (widget.windowController == null && mounted) {
|
if (widget.windowController == null && mounted) {
|
||||||
context.go('/');
|
context.go('/');
|
||||||
return;
|
return;
|
||||||
@@ -888,6 +896,10 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!mounted || session != _createSession) return;
|
if (!mounted || session != _createSession) return;
|
||||||
|
if (widget.onClose != null) {
|
||||||
|
widget.onClose!(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (widget.windowController != null) {
|
if (widget.windowController != null) {
|
||||||
// Navigation is best-effort after a successful submission. A navigation
|
// Navigation is best-effort after a successful submission. A navigation
|
||||||
// failure must not leave a completed form available for resubmission.
|
// failure must not leave a completed form available for resubmission.
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import 'package:flutter/widgets.dart';
|
|||||||
class AppDesignTokens {
|
class AppDesignTokens {
|
||||||
const AppDesignTokens._();
|
const AppDesignTokens._();
|
||||||
|
|
||||||
|
static const Size childWindowSize = Size(820, 620);
|
||||||
static const double windowHeaderHeight = 30;
|
static const double windowHeaderHeight = 30;
|
||||||
static const double windowRadius = 12;
|
static const double windowRadius = 12;
|
||||||
// Leave room for native macOS traffic lights while keeping rail widths consistent across platforms.
|
// Leave room for native macOS traffic lights while keeping rail widths consistent across platforms.
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ void main() {
|
|||||||
final router = AppRouter.build(auth);
|
final router = AppRouter.build(auth);
|
||||||
addTearDown(router.dispose);
|
addTearDown(router.dispose);
|
||||||
addTearDown(auth.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 {
|
for (final entry in {
|
||||||
'/': ['/'],
|
'/': ['/'],
|
||||||
@@ -28,8 +28,15 @@ void main() {
|
|||||||
}.entries) {
|
}.entries) {
|
||||||
final match = router.configuration.findMatch(Uri.parse(entry.key));
|
final match = router.configuration.findMatch(Uri.parse(entry.key));
|
||||||
expect(match.isError, isFalse);
|
expect(match.isError, isFalse);
|
||||||
final shell = match.matches.single as ShellRouteMatch;
|
final shell = match.matches.first as ShellRouteMatch;
|
||||||
expect(shell.matches.map((match) => (match.route as GoRoute).path), entry.value);
|
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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<ProviderContainer> 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<void> 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<Padding>(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<AppTextField>(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<AppTextField>(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<CreateTaskBatchResult>? 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<CreateTaskBatchResult>();
|
||||||
|
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<AppTextField>(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<AppTextField>(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<AppTextField>(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<AppRuntimeState> 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<AppPlatformState> build() async => const AppPlatformState(started: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
class _DeepLinks extends AppDeepLinkController {
|
||||||
|
@override
|
||||||
|
Future<AppDeepLinkState> build() async => const AppDeepLinkState(started: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
class _Notifications extends AppNotificationController {
|
||||||
|
@override
|
||||||
|
Future<AppNotificationState> build() async => const AppNotificationState(started: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
class _Tasks extends TasksController {
|
||||||
|
@override
|
||||||
|
Future<TasksState> build() async => const TasksState(tasks: []);
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import 'package:flutter_test/flutter_test.dart';
|
|||||||
import 'package:gopeed/api/model/create_task.dart';
|
import 'package:gopeed/api/model/create_task.dart';
|
||||||
import 'package:gopeed/api/model/request.dart';
|
import 'package:gopeed/api/model/request.dart';
|
||||||
import 'package:gopeed/core/window/app_window_launcher.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';
|
import 'package:gopeed/core/window/window_capability_transport.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
@@ -34,8 +35,10 @@ void main() {
|
|||||||
return null;
|
return null;
|
||||||
});
|
});
|
||||||
|
|
||||||
final opened = await AppWindowLauncher.openCreateTaskWindow(
|
final opened = await AppWindowLauncher.open(
|
||||||
createTask: CreateTask(req: Request(url: 'https://example.com/file.zip')),
|
AppWindowPayload.createTask(
|
||||||
|
createTask: CreateTask(req: Request(url: 'https://example.com/file.zip')).toJson(),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(opened, isTrue);
|
expect(opened, isTrue);
|
||||||
@@ -56,6 +59,6 @@ void main() {
|
|||||||
return null;
|
return null;
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(await AppWindowLauncher.openCreateTaskWindow(), isFalse);
|
expect(await AppWindowLauncher.open(AppWindowPayload.createTask()), isFalse);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -9,9 +9,9 @@ void main() {
|
|||||||
test('create-task child window has a localized native title', () {
|
test('create-task child window has a localized native title', () {
|
||||||
expect(
|
expect(
|
||||||
AppWindowBootstrap.subWindowTitle(AppWindowType.createTask, appLocalizationsFor('en')),
|
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', () {
|
test('create-task payload only carries initial task data', () {
|
||||||
|
|||||||
@@ -2796,7 +2796,7 @@ void main() {
|
|||||||
await tester.tap(find.byKey(const ValueKey('settings-language-select')));
|
await tester.tap(find.byKey(const ValueKey('settings-language-select')));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
expect(find.text('English'), findsOneWidget);
|
expect(find.text('English'), findsOneWidget);
|
||||||
expect(SettingsLanguageSelect.supportedValues, hasLength(22));
|
expect(SettingsLanguageSelect.supportedValues, hasLength(AppLocalizations.supportedLocales.length));
|
||||||
expect(SettingsLanguageSelect.supportedValues, containsAll(<String>['zh', 'zh_TW', 'pt']));
|
expect(SettingsLanguageSelect.supportedValues, containsAll(<String>['zh', 'zh_TW', 'pt']));
|
||||||
expect(SettingsLanguageSelect.supportedValues, isNot(contains('zh_CN')));
|
expect(SettingsLanguageSelect.supportedValues, isNot(contains('zh_CN')));
|
||||||
expect(SettingsLanguageSelect.supportedValues, isNot(contains('pt_BR')));
|
expect(SettingsLanguageSelect.supportedValues, isNot(contains('pt_BR')));
|
||||||
|
|||||||
Reference in new issue
Block a user