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'},
+ );
}
}