From cee5f3336ef0910c4198ad59a1f2f0cf4b910d24 Mon Sep 17 00:00:00 2001 From: Levi Date: Sun, 13 Sep 2026 23:40:59 +0800 Subject: [PATCH] fix: extension install by deeplink (#1510) --- ui/flutter/ios/Runner/Info.plist | 3 + ui/flutter/ios/Runner/SceneDelegate.swift | 21 + .../application/app_deep_link_controller.dart | 2 +- .../lib/core/utils/file_explorer_native.dart | 2 +- .../presentation/pages/extensions_page.dart | 101 ++- .../widgets/extension_detail_view.dart | 9 +- .../widgets/extension_update_dialog.dart | 90 +++ .../features/tasks/domain/task_record.dart | 2 +- .../pages/create_task_window_page.dart | 3 + .../widgets/task_card/task_card_desktop.dart | 11 +- .../widgets/task_card/task_card_mobile.dart | 6 +- .../widgets/task_card/task_card_parts.dart | 2 +- ui/flutter/lib/l10n/app_ar.arb | 1 + ui/flutter/lib/l10n/app_az.arb | 1 + ui/flutter/lib/l10n/app_ca.arb | 1 + ui/flutter/lib/l10n/app_de.arb | 1 + ui/flutter/lib/l10n/app_en.arb | 8 + ui/flutter/lib/l10n/app_es.arb | 1 + ui/flutter/lib/l10n/app_fa.arb | 1 + ui/flutter/lib/l10n/app_fr.arb | 1 + ui/flutter/lib/l10n/app_hu.arb | 1 + ui/flutter/lib/l10n/app_id.arb | 1 + ui/flutter/lib/l10n/app_it.arb | 1 + ui/flutter/lib/l10n/app_ja.arb | 1 + ui/flutter/lib/l10n/app_ko.arb | 1 + ui/flutter/lib/l10n/app_pl.arb | 1 + ui/flutter/lib/l10n/app_pt.arb | 1 + ui/flutter/lib/l10n/app_ru.arb | 1 + ui/flutter/lib/l10n/app_ta.arb | 1 + ui/flutter/lib/l10n/app_tr.arb | 1 + ui/flutter/lib/l10n/app_uk.arb | 1 + ui/flutter/lib/l10n/app_vi.arb | 1 + ui/flutter/lib/l10n/app_zh.arb | 1 + ui/flutter/lib/l10n/app_zh_TW.arb | 1 + .../test/app_deep_link_controller_test.dart | 48 +- ui/flutter/test/widget_test.dart | 683 +++++++++++++++--- 36 files changed, 869 insertions(+), 143 deletions(-) create mode 100644 ui/flutter/lib/features/extensions/presentation/widgets/extension_update_dialog.dart 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/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 6ccf05c5..d4effbec 100644 --- a/ui/flutter/lib/features/extensions/presentation/pages/extensions_page.dart +++ b/ui/flutter/lib/features/extensions/presentation/pages/extensions_page.dart @@ -26,6 +26,7 @@ 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 = 290.0; const _extensionGridSpacing = 10.0; @@ -47,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; @@ -78,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: [ @@ -154,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, @@ -169,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 { @@ -192,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, @@ -201,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( @@ -284,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, @@ -301,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; @@ -322,6 +361,7 @@ class _Content extends StatelessWidget { onSearch: onSearch, onSort: onSort, onRefresh: onRefresh, + installAnchorKey: installAnchorKey, onOpenInstall: onOpenInstall, onDevelopExtension: onDevelopExtension, onInstallFolder: onInstallFolder, @@ -614,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, @@ -624,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; @@ -631,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, @@ -673,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), ), ), @@ -764,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) { @@ -783,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(); @@ -861,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(); } } @@ -1035,11 +1063,8 @@ class _ExtensionCard extends ConsumerWidget { if (canUpdate && installed != null) shad.GhostButton( density: shad.ButtonDensity.icon, - onPressed: busy - ? null - : () => onAction( - () => ref.read(extensionsControllerProvider.notifier).upgradeExtension(installed), - ), + key: ValueKey('update-extension-${installed.identity}'), + onPressed: busy ? null : () => showExtensionUpdateDialog(context, installed), child: const Icon(Icons.refresh), ), if (installed == null && item.store != null) 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 eed9ec79..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 @@ -20,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}); @@ -132,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) 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/tasks/domain/task_record.dart b/ui/flutter/lib/features/tasks/domain/task_record.dart index 44a5a426..47aa0cd4 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 5819af27..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,7 @@ 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'; @@ -736,6 +737,8 @@ class _CreateTaskWindowPageState extends ConsumerState { } catch (error) { debugPrint('Unable to select downloading tasks: $error'); } + } else { + ref.read(taskListNavigationProvider.notifier).showDownloading(); } await _closeWindow(); } 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 ac08f324..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 @@ -106,7 +106,7 @@ class TaskCardFooter extends StatelessWidget { ), ), 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/l10n/app_ar.arb b/ui/flutter/lib/l10n/app_ar.arb index 174cb2d1..372a5777 100644 --- a/ui/flutter/lib/l10n/app_ar.arb +++ b/ui/flutter/lib/l10n/app_ar.arb @@ -103,6 +103,7 @@ "extensions": "الإضافات", "extensionInstallUrl": "عنوان التثبيت", "extensionInstallSuccess": "تم التثبيت بنجاح", + "extensionUpdateConfirm": "هل تريد تحديث «{name}» إلى أحدث إصدار؟", "extensionUpdateSuccess": "تم التحديث بنجاح", "extensionDelete": "حذف الإضافة", "extensionAlreadyLatest": "هذا هو أحدث إصدار بالفعل", diff --git a/ui/flutter/lib/l10n/app_az.arb b/ui/flutter/lib/l10n/app_az.arb index 4e9ba6ec..564f9999 100644 --- a/ui/flutter/lib/l10n/app_az.arb +++ b/ui/flutter/lib/l10n/app_az.arb @@ -110,6 +110,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", diff --git a/ui/flutter/lib/l10n/app_ca.arb b/ui/flutter/lib/l10n/app_ca.arb index dc3f81f7..bee2f371 100644 --- a/ui/flutter/lib/l10n/app_ca.arb +++ b/ui/flutter/lib/l10n/app_ca.arb @@ -103,6 +103,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", diff --git a/ui/flutter/lib/l10n/app_de.arb b/ui/flutter/lib/l10n/app_de.arb index a27d800c..5c95653f 100644 --- a/ui/flutter/lib/l10n/app_de.arb +++ b/ui/flutter/lib/l10n/app_de.arb @@ -103,6 +103,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", diff --git a/ui/flutter/lib/l10n/app_en.arb b/ui/flutter/lib/l10n/app_en.arb index d1897ce8..c483375b 100644 --- a/ui/flutter/lib/l10n/app_en.arb +++ b/ui/flutter/lib/l10n/app_en.arb @@ -110,6 +110,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", diff --git a/ui/flutter/lib/l10n/app_es.arb b/ui/flutter/lib/l10n/app_es.arb index 34d2343c..a9588f9a 100644 --- a/ui/flutter/lib/l10n/app_es.arb +++ b/ui/flutter/lib/l10n/app_es.arb @@ -103,6 +103,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", diff --git a/ui/flutter/lib/l10n/app_fa.arb b/ui/flutter/lib/l10n/app_fa.arb index bafb1bb3..66235220 100644 --- a/ui/flutter/lib/l10n/app_fa.arb +++ b/ui/flutter/lib/l10n/app_fa.arb @@ -103,6 +103,7 @@ "extensions": "افزونه‌ها", "extensionInstallUrl": "نشانی نصب", "extensionInstallSuccess": "نصب شده با موفقیت", + "extensionUpdateConfirm": "«{name}» به آخرین نسخه به‌روزرسانی شود؟", "extensionUpdateSuccess": "به روز رسانی موفقیت آمیز", "extensionDelete": "حذف افزونه", "extensionAlreadyLatest": "این نسخه در حال حاضر آخرین نسخه است", diff --git a/ui/flutter/lib/l10n/app_fr.arb b/ui/flutter/lib/l10n/app_fr.arb index e708aca4..de714190 100644 --- a/ui/flutter/lib/l10n/app_fr.arb +++ b/ui/flutter/lib/l10n/app_fr.arb @@ -103,6 +103,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", diff --git a/ui/flutter/lib/l10n/app_hu.arb b/ui/flutter/lib/l10n/app_hu.arb index 2823ae6d..7b24e524 100644 --- a/ui/flutter/lib/l10n/app_hu.arb +++ b/ui/flutter/lib/l10n/app_hu.arb @@ -103,6 +103,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ó", diff --git a/ui/flutter/lib/l10n/app_id.arb b/ui/flutter/lib/l10n/app_id.arb index 43a57bdc..4f883708 100644 --- a/ui/flutter/lib/l10n/app_id.arb +++ b/ui/flutter/lib/l10n/app_id.arb @@ -103,6 +103,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", diff --git a/ui/flutter/lib/l10n/app_it.arb b/ui/flutter/lib/l10n/app_it.arb index 8d0f62b9..168e2fd6 100644 --- a/ui/flutter/lib/l10n/app_it.arb +++ b/ui/flutter/lib/l10n/app_it.arb @@ -103,6 +103,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", diff --git a/ui/flutter/lib/l10n/app_ja.arb b/ui/flutter/lib/l10n/app_ja.arb index 097488ef..98ea7b15 100644 --- a/ui/flutter/lib/l10n/app_ja.arb +++ b/ui/flutter/lib/l10n/app_ja.arb @@ -103,6 +103,7 @@ "extensions": "拡張機能", "extensionInstallUrl": "インストールURL", "extensionInstallSuccess": "正常にインストールされました", + "extensionUpdateConfirm": "「{name}」を最新バージョンに更新しますか?", "extensionUpdateSuccess": "正常に更新されました", "extensionDelete": "拡張機能を削除", "extensionAlreadyLatest": "既に最新バージョンです", diff --git a/ui/flutter/lib/l10n/app_ko.arb b/ui/flutter/lib/l10n/app_ko.arb index b7c66da1..28d64a32 100644 --- a/ui/flutter/lib/l10n/app_ko.arb +++ b/ui/flutter/lib/l10n/app_ko.arb @@ -103,6 +103,7 @@ "extensions": "확장 프로그램", "extensionInstallUrl": "설치 URL", "extensionInstallSuccess": "설치되었습니다", + "extensionUpdateConfirm": "“{name}”을(를) 최신 버전으로 업데이트할까요?", "extensionUpdateSuccess": "업데이트되었습니다", "extensionDelete": "확장 프로그램 삭제", "extensionAlreadyLatest": "이미 최신 버전입니다", diff --git a/ui/flutter/lib/l10n/app_pl.arb b/ui/flutter/lib/l10n/app_pl.arb index ff542d50..6256937f 100644 --- a/ui/flutter/lib/l10n/app_pl.arb +++ b/ui/flutter/lib/l10n/app_pl.arb @@ -103,6 +103,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", diff --git a/ui/flutter/lib/l10n/app_pt.arb b/ui/flutter/lib/l10n/app_pt.arb index c0d66de9..eca4a034 100644 --- a/ui/flutter/lib/l10n/app_pt.arb +++ b/ui/flutter/lib/l10n/app_pt.arb @@ -103,6 +103,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", diff --git a/ui/flutter/lib/l10n/app_ru.arb b/ui/flutter/lib/l10n/app_ru.arb index 67e42710..08811c7d 100644 --- a/ui/flutter/lib/l10n/app_ru.arb +++ b/ui/flutter/lib/l10n/app_ru.arb @@ -103,6 +103,7 @@ "extensions": "Расширения", "extensionInstallUrl": "URL для установки", "extensionInstallSuccess": "Установка завершена", + "extensionUpdateConfirm": "Обновить «{name}» до последней версии?", "extensionUpdateSuccess": "Обновление завершено", "extensionDelete": "Удалить расширение", "extensionAlreadyLatest": "Это последняя версия", diff --git a/ui/flutter/lib/l10n/app_ta.arb b/ui/flutter/lib/l10n/app_ta.arb index 51ee4819..a4544b27 100644 --- a/ui/flutter/lib/l10n/app_ta.arb +++ b/ui/flutter/lib/l10n/app_ta.arb @@ -103,6 +103,7 @@ "extensions": "நீட்டிப்புகள்", "extensionInstallUrl": "நிறுவப்படும் URL", "extensionInstallSuccess": "வெற்றிகரமாக நிறுவப்பட்டது", + "extensionUpdateConfirm": "“{name}” ஐ சமீபத்திய பதிப்பிற்குப் புதுப்பிக்கவா?", "extensionUpdateSuccess": "வெற்றிகரமாக புதுப்பிக்கப்பட்டது", "extensionDelete": "நீட்டிப்பை நீக்கு", "extensionAlreadyLatest": "இது ஏற்கனவே சமீபத்திய பதிப்பாகும்", diff --git a/ui/flutter/lib/l10n/app_tr.arb b/ui/flutter/lib/l10n/app_tr.arb index 0b160d9b..e96f138a 100644 --- a/ui/flutter/lib/l10n/app_tr.arb +++ b/ui/flutter/lib/l10n/app_tr.arb @@ -103,6 +103,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", diff --git a/ui/flutter/lib/l10n/app_uk.arb b/ui/flutter/lib/l10n/app_uk.arb index 8632668f..2c659f04 100644 --- a/ui/flutter/lib/l10n/app_uk.arb +++ b/ui/flutter/lib/l10n/app_uk.arb @@ -103,6 +103,7 @@ "extensions": "Розширення", "extensionInstallUrl": "URL для встановлення", "extensionInstallSuccess": "Встановлено успішно", + "extensionUpdateConfirm": "Оновити «{name}» до останньої версії?", "extensionUpdateSuccess": "Оновлено успішно", "extensionDelete": "Видалити розширення", "extensionAlreadyLatest": "Вже остання версія", diff --git a/ui/flutter/lib/l10n/app_vi.arb b/ui/flutter/lib/l10n/app_vi.arb index ab4171ab..8e3e36f5 100644 --- a/ui/flutter/lib/l10n/app_vi.arb +++ b/ui/flutter/lib/l10n/app_vi.arb @@ -103,6 +103,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", diff --git a/ui/flutter/lib/l10n/app_zh.arb b/ui/flutter/lib/l10n/app_zh.arb index 0b801d25..2395f69b 100644 --- a/ui/flutter/lib/l10n/app_zh.arb +++ b/ui/flutter/lib/l10n/app_zh.arb @@ -103,6 +103,7 @@ "extensions": "扩展", "extensionInstallUrl": "安装链接", "extensionInstallSuccess": "安装成功", + "extensionUpdateConfirm": "确定将“{name}”更新到最新版本吗?", "extensionUpdateSuccess": "更新成功", "extensionDelete": "删除扩展", "extensionAlreadyLatest": "已经是最新版本", diff --git a/ui/flutter/lib/l10n/app_zh_TW.arb b/ui/flutter/lib/l10n/app_zh_TW.arb index 21a2bc72..2692840b 100644 --- a/ui/flutter/lib/l10n/app_zh_TW.arb +++ b/ui/flutter/lib/l10n/app_zh_TW.arb @@ -103,6 +103,7 @@ "extensions": "擴充功能", "extensionInstallUrl": "安裝 URL", "extensionInstallSuccess": "安裝成功", + "extensionUpdateConfirm": "確定將「{name}」更新到最新版本嗎?", "extensionUpdateSuccess": "更新成功", "extensionDelete": "刪除擴充功能", "extensionAlreadyLatest": "已是最新版本", 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/widget_test.dart b/ui/flutter/test/widget_test.dart index 3a6c087c..3f0732e3 100644 --- a/ui/flutter/test/widget_test.dart +++ b/ui/flutter/test/widget_test.dart @@ -1,7 +1,8 @@ import 'dart:async'; import 'dart:io'; -import 'package:flutter/foundation.dart' show TargetPlatform, debugDefaultTargetPlatformOverride; +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; @@ -15,6 +16,12 @@ 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'; @@ -23,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'; @@ -97,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( @@ -676,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 { @@ -885,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, @@ -3077,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), @@ -3301,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)); }); @@ -4851,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 { @@ -5020,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 @@ -5187,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'}, + ); } }