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