feat(ui): unify child surfaces and show web task creation in a modal (#1633)

This commit is contained in:
Levi authored and GitHub committed 2026-11-02 23:54:05 +08:00
1 parent b0d45226d7
commit d69874e13e
30 files changed
+618 -85

No files matched your search

@@ -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<AppDeepLinkState> {
await _go('/');
}
Future<void> _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<void> _openCreate(CreateTask? createTask) => AppRouter.openCreateTask(createTask: createTask);
Future<void> _go(String route) async {
if (Util.isDesktop()) {
@@ -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<AppPlatformState> 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<AppPlatformState> with WindowL
Future<void> _openCreateTask() async {
await _showMainWindow();
final opened = await AppWindowLauncher.openCreateTaskWindow();
if (!opened) {
_go('/create');
}
await AppRouter.openCreateTask();
}
Future<void> _go(String route) async {
+2 -6
View File
@@ -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<ChildWindowApp> 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))),
};
}
+68 -4
View File
@@ -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<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() {
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<void>(key: state.pageKey, child: child)
: AppWindowPage<void>(key: state.pageKey, child: child);
},
),
GoRoute(
path: 'tasks/:id',
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/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<MainShell> {
}
}
Future<void> _openCapturedCreateTask(CreateTask createTask) async {
final opened = await AppWindowLauncher.openCreateTaskWindow(createTask: createTask);
if (!opened && mounted) {
ref.read(pendingCreateTaskProvider.notifier).set(createTask);
context.go('/create');
}
}
Future<void> _openCapturedCreateTask(CreateTask createTask) => AppRouter.openCreateTask(createTask: createTask);
}