From a760290213a5e8ee17dedee9cf21ee3d9843eb97 Mon Sep 17 00:00:00 2001 From: Levi Date: Sun, 13 Sep 2026 23:30:45 +0800 Subject: [PATCH 1/3] fix(ui): enable task detail text selection and compact extension stats (#1511) --- .../core/utils/compact_count_formatter.dart | 23 +++ .../presentation/pages/extensions_page.dart | 162 +++++++++++------- .../widgets/extension_detail_view.dart | 5 +- .../presentation/widgets/task_drawer.dart | 5 +- .../utils/compact_count_formatter_test.dart | 26 +++ ui/flutter/test/widget_test.dart | 86 +++++++++- 6 files changed, 236 insertions(+), 71 deletions(-) create mode 100644 ui/flutter/lib/core/utils/compact_count_formatter.dart create mode 100644 ui/flutter/test/core/utils/compact_count_formatter_test.dart diff --git a/ui/flutter/lib/core/utils/compact_count_formatter.dart b/ui/flutter/lib/core/utils/compact_count_formatter.dart new file mode 100644 index 00000000..f0aadfe1 --- /dev/null +++ b/ui/flutter/lib/core/utils/compact_count_formatter.dart @@ -0,0 +1,23 @@ +abstract final class CompactCountFormatter { + // Counts start compacting at ten thousand by product convention, then use + // progressively larger decimal units so the rendered label stays bounded. + static const _units = [ + (divisor: 1000000000000000000, suffix: 'qi'), + (divisor: 1000000000000000, suffix: 'q'), + (divisor: 1000000000000, suffix: 't'), + (divisor: 1000000000, suffix: 'b'), + (divisor: 1000000, suffix: 'm'), + (divisor: 10000, suffix: 'w'), + ]; + + static String format(int count) { + if (count <= 0) return '0'; + if (count < _units.last.divisor) return count.toString(); + + final unit = _units.firstWhere((unit) => count >= unit.divisor); + final whole = count ~/ unit.divisor; + final tenth = (count % unit.divisor) ~/ (unit.divisor ~/ 10); + final value = whole < 10 && tenth > 0 ? '$whole.$tenth' : '$whole'; + return '$value${unit.suffix}'; + } +} diff --git a/ui/flutter/lib/features/extensions/presentation/pages/extensions_page.dart b/ui/flutter/lib/features/extensions/presentation/pages/extensions_page.dart index b77cbe21..6ccf05c5 100644 --- a/ui/flutter/lib/features/extensions/presentation/pages/extensions_page.dart +++ b/ui/flutter/lib/features/extensions/presentation/pages/extensions_page.dart @@ -11,6 +11,7 @@ import 'package:url_launcher/url_launcher.dart'; import '../../../../api/model/extension.dart' as api_extension; import '../../../../api/model/store_extension.dart'; import '../../../../core/utils/breakpoints.dart'; +import '../../../../core/utils/compact_count_formatter.dart'; import '../../../../core/window/app_window_chrome.dart'; import '../../../../shared/theme/app_design_tokens.dart'; import '../../../../shared/theme/app_palette.dart'; @@ -26,7 +27,7 @@ import '../widgets/extension_detail_view.dart'; import '../widgets/extension_icon.dart'; import '../widgets/extension_setting_field.dart'; -const _extensionCardMinWidth = 280.0; +const _extensionCardMinWidth = 290.0; const _extensionGridSpacing = 10.0; int _extensionGridColumnCount(double width) { @@ -995,71 +996,100 @@ class _ExtensionCard extends ConsumerWidget { const Spacer(), Row( children: [ - if (item.store != null) ...[ - Icon(Icons.star_rounded, size: 15, color: palette.textMuted), - const SizedBox(width: 3), - Text('${item.stars}', style: TextStyle(color: palette.textSecondary, fontSize: 12)), - const SizedBox(width: 10), - Icon(Icons.download_outlined, size: 15, color: palette.textMuted), - const SizedBox(width: 3), - Text('${item.installCount}', style: TextStyle(color: palette.textSecondary, fontSize: 12)), - ], - const Spacer(), - if (canUpdate && installed != null) - shad.GhostButton( - density: shad.ButtonDensity.icon, - onPressed: busy - ? null - : () => - onAction(() => ref.read(extensionsControllerProvider.notifier).upgradeExtension(installed)), - child: const Icon(Icons.refresh), - ), - if (installed == null && item.store != null) - shad.GhostButton( - key: ValueKey('install-store-extension-${item.store!.id}'), - density: shad.ButtonDensity.icon, - onPressed: busy - ? null - : () => onAction( - () => ref.read(extensionsControllerProvider.notifier).installFromStore(item.store!), - ), - child: busy - ? const SizedBox.square(dimension: 14, child: shad.CircularProgressIndicator()) - : const Icon(Icons.download), - ), - if ((item.homepage ?? '').isNotEmpty) - shad.GhostButton( - density: shad.ButtonDensity.icon, - onPressed: () => unawaited(launchUrl(Uri.parse(item.homepage!))), - child: const Icon(Icons.home_outlined), - ), - if ((item.repoUrl ?? '').isNotEmpty) - shad.GhostButton( - density: shad.ButtonDensity.icon, - onPressed: () => unawaited(launchUrl(Uri.parse(item.repoUrl!))), - child: const Icon(Icons.code), - ), - if (installed?.settings?.isNotEmpty == true) - shad.GhostButton( - density: shad.ButtonDensity.icon, - onPressed: busy ? null : () => onOpenSettings(installed!), - child: const Icon(Icons.settings_outlined), - ), - if (installed != null) - shad.GhostButton( - key: ValueKey('remove-extension-${installed.identity}'), - density: shad.ButtonDensity.icon, - onPressed: busy - ? null - : () async { - final confirmed = await _showRemoveExtensionDialog(context, installed.title); - if (!confirmed || !context.mounted) return; - await onAction( - () => ref.read(extensionsControllerProvider.notifier).removeExtension(installed), - ); - }, - child: const Icon(Icons.delete_outline), - ), + if (item.store != null) + Expanded( + child: Align( + alignment: Alignment.centerLeft, + child: FittedBox( + fit: BoxFit.scaleDown, + alignment: Alignment.centerLeft, + child: Row( + key: ValueKey('extension-card-stats-${item.id}'), + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.star_rounded, size: 15, color: palette.textMuted), + const SizedBox(width: 3), + Text( + CompactCountFormatter.format(item.stars), + style: TextStyle(color: palette.textSecondary, fontSize: 12), + ), + const SizedBox(width: 10), + Icon(Icons.download_outlined, size: 15, color: palette.textMuted), + const SizedBox(width: 3), + Text( + CompactCountFormatter.format(item.installCount), + style: TextStyle(color: palette.textSecondary, fontSize: 12), + ), + ], + ), + ), + ), + ) + else + const Spacer(), + const SizedBox(width: 8), + Row( + key: ValueKey('extension-card-actions-${item.id}'), + mainAxisSize: MainAxisSize.min, + children: [ + if (canUpdate && installed != null) + shad.GhostButton( + density: shad.ButtonDensity.icon, + onPressed: busy + ? null + : () => onAction( + () => ref.read(extensionsControllerProvider.notifier).upgradeExtension(installed), + ), + child: const Icon(Icons.refresh), + ), + if (installed == null && item.store != null) + shad.GhostButton( + key: ValueKey('install-store-extension-${item.store!.id}'), + density: shad.ButtonDensity.icon, + onPressed: busy + ? null + : () => onAction( + () => ref.read(extensionsControllerProvider.notifier).installFromStore(item.store!), + ), + child: busy + ? const SizedBox.square(dimension: 14, child: shad.CircularProgressIndicator()) + : const Icon(Icons.download), + ), + if ((item.homepage ?? '').isNotEmpty) + shad.GhostButton( + density: shad.ButtonDensity.icon, + onPressed: () => unawaited(launchUrl(Uri.parse(item.homepage!))), + child: const Icon(Icons.home_outlined), + ), + if ((item.repoUrl ?? '').isNotEmpty) + shad.GhostButton( + density: shad.ButtonDensity.icon, + onPressed: () => unawaited(launchUrl(Uri.parse(item.repoUrl!))), + child: const Icon(Icons.code), + ), + if (installed?.settings?.isNotEmpty == true) + shad.GhostButton( + density: shad.ButtonDensity.icon, + onPressed: busy ? null : () => onOpenSettings(installed!), + child: const Icon(Icons.settings_outlined), + ), + if (installed != null) + shad.GhostButton( + key: ValueKey('remove-extension-${installed.identity}'), + density: shad.ButtonDensity.icon, + onPressed: busy + ? null + : () async { + final confirmed = await _showRemoveExtensionDialog(context, installed.title); + if (!confirmed || !context.mounted) return; + await onAction( + () => ref.read(extensionsControllerProvider.notifier).removeExtension(installed), + ); + }, + child: const Icon(Icons.delete_outline), + ), + ], + ), ], ), ], diff --git a/ui/flutter/lib/features/extensions/presentation/widgets/extension_detail_view.dart b/ui/flutter/lib/features/extensions/presentation/widgets/extension_detail_view.dart index 886be8ef..eed9ec79 100644 --- a/ui/flutter/lib/features/extensions/presentation/widgets/extension_detail_view.dart +++ b/ui/flutter/lib/features/extensions/presentation/widgets/extension_detail_view.dart @@ -10,6 +10,7 @@ import 'package:shadcn_flutter/shadcn_flutter.dart' as shad; import 'package:url_launcher/url_launcher.dart'; import '../../../../api/model/store_extension.dart'; +import '../../../../core/utils/compact_count_formatter.dart'; import '../../../../l10n/l10n.dart'; import '../../../../shared/theme/app_design_tokens.dart'; import '../../../../shared/theme/app_palette.dart'; @@ -338,9 +339,9 @@ class _ExtensionStats extends StatelessWidget { key: const ValueKey('extension-details-stats'), mainAxisSize: MainAxisSize.min, children: [ - _MetadataItem(icon: Icons.star_rounded, value: store.stars.toString()), + _MetadataItem(icon: Icons.star_rounded, value: CompactCountFormatter.format(store.stars)), const SizedBox(width: 12), - _MetadataItem(icon: Icons.download_outlined, value: store.installCount.toString()), + _MetadataItem(icon: Icons.download_outlined, value: CompactCountFormatter.format(store.installCount)), ], ); } diff --git a/ui/flutter/lib/features/tasks/presentation/widgets/task_drawer.dart b/ui/flutter/lib/features/tasks/presentation/widgets/task_drawer.dart index 0c96de5c..49de9689 100644 --- a/ui/flutter/lib/features/tasks/presentation/widgets/task_drawer.dart +++ b/ui/flutter/lib/features/tasks/presentation/widgets/task_drawer.dart @@ -1,7 +1,7 @@ import 'dart:async'; import 'package:flutter/foundation.dart' show TargetPlatform, defaultTargetPlatform, kIsWeb; -import 'package:flutter/material.dart' show Colors, Icons; +import 'package:flutter/material.dart' show Colors, Icons, SelectionArea; import 'package:flutter/widgets.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart' as shad; @@ -122,7 +122,7 @@ class _TaskDetailsViewState extends ConsumerState { ? AppDesignTokens.taskDetailsMobilePadding : AppDesignTokens.taskDetailsDesktopPadding; final runtimeStatus = _tabIndex == 1 ? ref.watch(taskRuntimeStatusProvider(widget.task.id)).value : null; - return Column( + final content = Column( children: [ Container( height: 48, @@ -204,6 +204,7 @@ class _TaskDetailsViewState extends ConsumerState { ), ], ); + return SelectionArea(key: const ValueKey('task-details-selection-area'), child: content); } bool get _canOpenStoragePath { diff --git a/ui/flutter/test/core/utils/compact_count_formatter_test.dart b/ui/flutter/test/core/utils/compact_count_formatter_test.dart new file mode 100644 index 00000000..0cf064ca --- /dev/null +++ b/ui/flutter/test/core/utils/compact_count_formatter_test.dart @@ -0,0 +1,26 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:gopeed/core/utils/compact_count_formatter.dart'; + +void main() { + test('keeps small counts exact and compacts larger counts to a bounded label', () { + expect(CompactCountFormatter.format(-1), '0'); + expect(CompactCountFormatter.format(0), '0'); + expect(CompactCountFormatter.format(9999), '9999'); + expect(CompactCountFormatter.format(10000), '1w'); + expect(CompactCountFormatter.format(12345), '1.2w'); + expect(CompactCountFormatter.format(99999), '9.9w'); + expect(CompactCountFormatter.format(123456), '12w'); + expect(CompactCountFormatter.format(1000000), '1m'); + expect(CompactCountFormatter.format(1234567), '1.2m'); + expect(CompactCountFormatter.format(10000000), '10m'); + expect(CompactCountFormatter.format(12345678), '12m'); + expect(CompactCountFormatter.format(100000000), '100m'); + expect(CompactCountFormatter.format(123456789), '123m'); + expect(CompactCountFormatter.format(1000000000), '1b'); + expect(CompactCountFormatter.format(1234567890), '1.2b'); + expect(CompactCountFormatter.format(12345678901), '12b'); + expect(CompactCountFormatter.format(1234567890123), '1.2t'); + expect(CompactCountFormatter.format(1234567890123456), '1.2q'); + expect(CompactCountFormatter.format(9223372036854775807), '9.2qi'); + }); +} diff --git a/ui/flutter/test/widget_test.dart b/ui/flutter/test/widget_test.dart index 4db9414e..3a6c087c 100644 --- a/ui/flutter/test/widget_test.dart +++ b/ui/flutter/test/widget_test.dart @@ -2,7 +2,7 @@ import 'dart:async'; import 'dart:io'; import 'package:flutter/foundation.dart' show TargetPlatform, debugDefaultTargetPlatformOverride; -import 'package:flutter/material.dart' show Icons, Scrollbar; +import 'package:flutter/material.dart' show Icons, Scrollbar, SelectionArea; import 'package:flutter/gestures.dart' show PointerDeviceKind, kDoubleTapMinTime, kSecondaryMouseButton; import 'package:flutter/rendering.dart' show RenderParagraph; import 'package:flutter/services.dart' show MethodChannel, SystemChannels; @@ -504,6 +504,11 @@ void main() { closeTo(tester.getRect(find.byKey(const ValueKey('extension-card-extension-2'))).right, 0.01), ); + tester.view.physicalSize = const Size(1024, 768); + await tester.pumpAndSettle(); + expect(gridColumns(), 3); + expect(tester.getSize(find.byKey(const ValueKey('extension-card-extension-0'))).width, greaterThanOrEqualTo(290)); + tester.view.physicalSize = const Size(1028, 608); await tester.pumpAndSettle(); expect(gridColumns(), 3); @@ -536,6 +541,31 @@ void main() { expect(tester.takeException(), isNull); }); + testWidgets('extension cards compact large counts without crowding their actions', (WidgetTester tester) async { + await _setTestSize(tester, const Size(722, 608)); + await tester.pumpWidget( + ProviderScope( + overrides: [extensionsControllerProvider.overrideWith(CrowdedExtensionCardController.new)], + child: shad.ShadcnApp( + theme: AppTheme.light(), + materialTheme: AppTheme.materialLight(), + home: const AppComponentThemes(child: ExtensionsPage()), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('1.2w'), findsOneWidget); + expect(find.text('1.2b'), findsOneWidget); + final cardRect = tester.getRect(find.byKey(const ValueKey('extension-card-crowded-extension'))); + final statsRect = tester.getRect(find.byKey(const ValueKey('extension-card-stats-crowded-extension'))); + final actionsRect = tester.getRect(find.byKey(const ValueKey('extension-card-actions-crowded-extension'))); + expect(cardRect.width, closeTo(290, 0.01)); + expect(statsRect.right, lessThanOrEqualTo(actionsRect.left - 8)); + expect(actionsRect.right, lessThanOrEqualTo(cardRect.right - 14)); + expect(tester.takeException(), isNull); + }); + testWidgets('extension details reuse the desktop detail drawer', (WidgetTester tester) async { await _setTestSize(tester, const Size(1100, 900)); final controller = FakeExtensionsController(); @@ -3546,6 +3576,14 @@ void main() { expect(urlValue.data, contains('\u200B')); expect(urlValue.data!.replaceAll('\u200B', ''), task.url); expect(urlValue.semanticsLabel, task.url); + final selectionArea = find.byKey(const ValueKey('task-details-selection-area')); + expect(selectionArea, findsOneWidget); + expect(tester.widget(selectionArea), isA()); + expect(find.ancestor(of: find.text('details.zip'), matching: selectionArea), findsOneWidget); + expect( + find.ancestor(of: find.byKey(const ValueKey('task-details-url-value')), matching: selectionArea), + findsOneWidget, + ); expect(find.text('Task Details'), findsOneWidget); expect(find.text('Task Name'), findsNothing); final detailHeader = tester.widget(find.byKey(const ValueKey('app-detail-drawer-header'))); @@ -5043,6 +5081,52 @@ class FakeExtensionsController extends ExtensionsController { } } +class CrowdedExtensionCardController extends ExtensionsController { + @override + Future build() async { + final installed = + api_extension.Extension( + identity: 'crowded-extension', + name: 'crowded-extension', + author: 'Gopeed', + title: 'Crowded extension', + description: 'An extension with every available card action.', + icon: '', + version: '1.0.0', + homepage: 'https://gopeed.com', + repository: api_extension.Repository(url: 'https://github.com/GopeedLab/gopeed', directory: ''), + disabled: false, + devMode: false, + devPath: '', + ) + ..settings = [ + api_extension.Setting( + name: 'enabled', + title: 'Enabled', + description: 'Enable the extension.', + required: false, + type: api_extension.SettingType.boolean, + ), + ]; + final store = StoreExtension( + id: 'crowded-extension', + repoFullName: 'GopeedLab/gopeed', + repoUrl: 'https://github.com/GopeedLab/gopeed', + name: 'crowded-extension', + author: 'Gopeed', + title: 'Crowded extension', + description: 'An extension with every available card action.', + readme: '# Crowded extension', + homepage: 'https://gopeed.com', + version: '1.1.0', + installCount: 1234567890, + stars: 12345, + topics: ['download'], + ); + return ExtensionsState(installedExtensions: [installed], storeExtensions: [store]); + } +} + class DevReadmeExtensionsController extends FakeExtensionsController { DevReadmeExtensionsController(this.extensionDirectory); From cee5f3336ef0910c4198ad59a1f2f0cf4b910d24 Mon Sep 17 00:00:00 2001 From: Levi Date: Sun, 13 Sep 2026 23:40:59 +0800 Subject: [PATCH 2/3] 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'}, + ); } } From e47a20a1bd927b9714c8611aad0720df5d0ebc56 Mon Sep 17 00:00:00 2001 From: UltraGeek0102 Date: Tue, 15 Sep 2026 20:19:11 +0530 Subject: [PATCH 3/3] feat(ios): add iOS 26+ continued processing and Live Activities (#1514) --- bind/mobile/main.go | 12 + pkg/api/events.go | 22 + .../GopeedLiveActivityWidget.swift | 236 ++++ ui/flutter/ios/GopeedLiveActivity/Info.plist | 24 + .../ios/Runner.xcodeproj/project.pbxproj | 179 +++ ui/flutter/ios/Runner/AppDelegate.swift | 65 +- .../GopeedContinuedProcessingManager.swift | 1117 +++++++++++++++++ .../ios/Runner/GopeedDownloadAttributes.swift | 28 + .../Runner/GopeedLiveActivityManager.swift | 1050 ++++++++++++++++ .../ios/Runner/GopeedTaskEventForwarder.h | 12 + .../ios/Runner/GopeedTaskEventForwarder.m | 116 +- ui/flutter/ios/Runner/Info.plist | 10 + .../lib/api/model/downloader_config.dart | 2 + .../lib/api/model/downloader_config.g.dart | 6 + .../application/app_runtime_controller.dart | 14 + .../app/application/continued_processing.dart | 39 + .../presentation/pages/settings_page.dart | 51 + ui/flutter/lib/l10n/app_en.arb | 3 + 18 files changed, 2983 insertions(+), 3 deletions(-) create mode 100644 ui/flutter/ios/GopeedLiveActivity/GopeedLiveActivityWidget.swift create mode 100644 ui/flutter/ios/GopeedLiveActivity/Info.plist create mode 100644 ui/flutter/ios/Runner/GopeedContinuedProcessingManager.swift create mode 100644 ui/flutter/ios/Runner/GopeedDownloadAttributes.swift create mode 100644 ui/flutter/ios/Runner/GopeedLiveActivityManager.swift create mode 100644 ui/flutter/lib/app/application/continued_processing.dart diff --git a/bind/mobile/main.go b/bind/mobile/main.go index 741335c7..380671b3 100644 --- a/bind/mobile/main.go +++ b/bind/mobile/main.go @@ -7,6 +7,7 @@ import ( nativebridge "github.com/GopeedLab/gopeed/bind/native" "github.com/GopeedLab/gopeed/internal/webview/rpcprovider" + goapi "github.com/GopeedLab/gopeed/pkg/api" "github.com/GopeedLab/gopeed/pkg/rest" "github.com/GopeedLab/gopeed/pkg/rest/model" ) @@ -90,3 +91,14 @@ func applyWebViewProvider(config *model.StartConfig) { } config.WebViewProvider = rpcprovider.New(*config.WebViewRPCConfig) } + +func LiveActivityTaskEventMask() int64 { + return int64( + goapi.TaskEventDone | + goapi.TaskEventError | + goapi.TaskEventStart | + goapi.TaskEventProgress | + goapi.TaskEventPause | + goapi.TaskEventDelete, + ) +} diff --git a/pkg/api/events.go b/pkg/api/events.go index fa2e416f..ac973440 100644 --- a/pkg/api/events.go +++ b/pkg/api/events.go @@ -10,8 +10,15 @@ import ( type TaskEventMask uint64 const ( + // Keep these first two unchanged because Flutter already uses + // bit 0 for done and bit 1 for error. TaskEventDone TaskEventMask = 1 << iota TaskEventError + + TaskEventStart + TaskEventProgress + TaskEventPause + TaskEventDelete ) type TaskEvent struct { @@ -42,11 +49,26 @@ func (s *Service) emitTaskEvent(event *download.Event) { } var mask TaskEventMask + switch event.Key { case download.EventKeyDone: mask = TaskEventDone + case download.EventKeyError: mask = TaskEventError + + case download.EventKeyStart: + mask = TaskEventStart + + case download.EventKeyProgress: + mask = TaskEventProgress + + case download.EventKeyPause: + mask = TaskEventPause + + case download.EventKeyDelete: + mask = TaskEventDelete + default: return } diff --git a/ui/flutter/ios/GopeedLiveActivity/GopeedLiveActivityWidget.swift b/ui/flutter/ios/GopeedLiveActivity/GopeedLiveActivityWidget.swift new file mode 100644 index 00000000..3d353cd9 --- /dev/null +++ b/ui/flutter/ios/GopeedLiveActivity/GopeedLiveActivityWidget.swift @@ -0,0 +1,236 @@ +import ActivityKit +import SwiftUI +import WidgetKit + +@main +struct GopeedLiveActivityBundle: WidgetBundle { + var body: some Widget { + GopeedLiveActivityWidget() + } +} + +struct GopeedLiveActivityWidget: Widget { + + var body: some WidgetConfiguration { + + ActivityConfiguration( + for: GopeedDownloadAttributes.self + ) { context in + + // MARK: Lock Screen / notification banner + + VStack(spacing: 10) { + + HStack { + + Image(systemName: statusIcon(context.state.status)) + .font(.title3) + + VStack(alignment: .leading, spacing: 2) { + + Text(context.attributes.fileName) + .font(.headline) + .lineLimit(1) + + Text(statusText(context.state.status)) + .font(.caption) + .foregroundStyle(.secondary) + } + + Spacer() + + Text( + "\(Int(context.state.progress * 100))%" + ) + .font(.headline) + .monospacedDigit() + } + + progressView(context) + + HStack { + + Text( + "\(formatBytes(context.state.downloaded)) / \(formatBytes(context.state.total))" + ) + + Spacer() + + if context.state.speed > 0 { + Text( + "\(formatBytes(context.state.speed))/s" + ) + } + } + .font(.caption) + .foregroundStyle(.secondary) + } + .padding() + .activityBackgroundTint(.black.opacity(0.85)) + .activitySystemActionForegroundColor(.white) + + } dynamicIsland: { context in + + DynamicIsland { + + // MARK: Expanded Dynamic Island + + DynamicIslandExpandedRegion(.leading) { + Image( + systemName: statusIcon( + context.state.status + ) + ) + .font(.title3) + } + + DynamicIslandExpandedRegion(.center) { + Text(context.attributes.fileName) + .lineLimit(1) + .font(.headline) + } + + DynamicIslandExpandedRegion(.trailing) { + Text( + "\(Int(context.state.progress * 100))%" + ) + .monospacedDigit() + .font(.headline) + } + + DynamicIslandExpandedRegion(.bottom) { + + VStack(spacing: 6) { + + progressView(context) + + HStack { + + Text( + "\(formatBytes(context.state.downloaded)) / \(formatBytes(context.state.total))" + ) + + Spacer() + + if context.state.speed > 0 { + Text( + "\(formatBytes(context.state.speed))/s" + ) + } + } + .font(.caption2) + .foregroundStyle(.secondary) + } + } + + } compactLeading: { + + Image( + systemName: statusIcon( + context.state.status + ) + ) + + } compactTrailing: { + + Text( + "\(Int(context.state.progress * 100))%" + ) + .monospacedDigit() + + } minimal: { + + Image(systemName: "arrow.down") + } + } + } + + + // MARK: - Progress bar + + @ViewBuilder + private func progressView( + _ context: + ActivityViewContext + ) -> some View { + + if context.state.usesEstimatedProgress, + context.state.estimatedEnd > + context.state.estimatedStart { + + ProgressView( + timerInterval: + context.state.estimatedStart + ... + context.state.estimatedEnd, + countsDown: false + ) + + } else { + + ProgressView( + value: context.state.progress, + total: 1.0 + ) + } + } + + + // MARK: - Formatting + + private func formatBytes( + _ bytes: Int64 + ) -> String { + + guard bytes > 0 else { + return "0 B" + } + + return ByteCountFormatter.string( + fromByteCount: bytes, + countStyle: .file + ) + } + + + private func statusIcon( + _ status: String + ) -> String { + + switch status { + + case "done": + return "checkmark.circle.fill" + + case "error": + return "exclamationmark.triangle.fill" + + case "pause", "paused": + return "pause.circle.fill" + + default: + return "arrow.down.circle.fill" + } + } + + + private func statusText( + _ status: String + ) -> String { + + switch status { + + case "done": + return "Download complete" + + case "error": + return "Download failed" + + case "pause", "paused": + return "Paused" + + default: + return "Downloading" + } + } +} diff --git a/ui/flutter/ios/GopeedLiveActivity/Info.plist b/ui/flutter/ios/GopeedLiveActivity/Info.plist new file mode 100644 index 00000000..3d6cefc8 --- /dev/null +++ b/ui/flutter/ios/GopeedLiveActivity/Info.plist @@ -0,0 +1,24 @@ + + + + + + + CFBundleDisplayName + Gopeed + + CFBundleShortVersionString + $(MARKETING_VERSION) + + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + + NSExtension + + NSExtensionPointIdentifier + com.apple.widgetkit-extension + + + + diff --git a/ui/flutter/ios/Runner.xcodeproj/project.pbxproj b/ui/flutter/ios/Runner.xcodeproj/project.pbxproj index faa07d38..677ed142 100644 --- a/ui/flutter/ios/Runner.xcodeproj/project.pbxproj +++ b/ui/flutter/ios/Runner.xcodeproj/project.pbxproj @@ -7,13 +7,19 @@ objects = { /* Begin PBXBuildFile section */ + 032447D610CFAF8A75642212 /* GopeedLiveActivityManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB8C142A455C80367DDD68AE /* GopeedLiveActivityManager.swift */; }; 0C585CBA2D41E28900FF2EC0 /* ShareViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C585CB92D41E28900FF2EC0 /* ShareViewController.swift */; }; 0C585CBD2D41E28900FF2EC0 /* Base in Resources */ = {isa = PBXBuildFile; fileRef = 0C585CBC2D41E28900FF2EC0 /* Base */; }; 0C585CC12D41E28900FF2EC0 /* ShareExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 0C585CB72D41E28900FF2EC0 /* ShareExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + 1045ACE898D35C0D8D8A993E /* GopeedDownloadAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 09AC32C4BF90EAC54D048584 /* GopeedDownloadAttributes.swift */; }; 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; + 1E29080079AAFD1CF2C71262 /* GopeedDownloadAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 09AC32C4BF90EAC54D048584 /* GopeedDownloadAttributes.swift */; }; 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; }; 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; + 3EF29839EA13551EB8144269 /* GopeedLiveActivityExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 76ED41E0163DFA97F018D1AC /* GopeedLiveActivityExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + 5BF69191A426106A38B62F9F /* GopeedContinuedProcessingManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3879B6DB5398A19281BA5F8F /* GopeedContinuedProcessingManager.swift */; }; 6085D504396791024CEC496B /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 8823E31DA135C410E362683E /* Pods_RunnerTests.framework */; }; + 6243BE8F995F5284DBDD7DE3 /* GopeedLiveActivityWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 35C9F8DE4B991051758F2BE1 /* GopeedLiveActivityWidget.swift */; }; 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; 7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */; }; 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; @@ -30,6 +36,13 @@ /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ + 03B416C78831516F6591BD24 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 97C146E61CF9000F007C117D /* Project object */; + proxyType = 1; + remoteGlobalIDString = FFA6D07C1B1CB56543DE4B67; + remoteInfo = GopeedLiveActivityExtension; + }; 0C585CBF2D41E28900FF2EC0 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 97C146E61CF9000F007C117D /* Project object */; @@ -54,6 +67,7 @@ dstSubfolderSpec = 13; files = ( 0C585CC12D41E28900FF2EC0 /* ShareExtension.appex in Embed Foundation Extensions */, + 3EF29839EA13551EB8144269 /* GopeedLiveActivityExtension.appex in Embed Foundation Extensions */, ); name = "Embed Foundation Extensions"; runOnlyForDeploymentPostprocessing = 0; @@ -72,6 +86,7 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + 09AC32C4BF90EAC54D048584 /* GopeedDownloadAttributes.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = GopeedDownloadAttributes.swift; sourceTree = ""; }; 0C585CB72D41E28900FF2EC0 /* ShareExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = ShareExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; 0C585CB92D41E28900FF2EC0 /* ShareViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareViewController.swift; sourceTree = ""; }; 0C585CBC2D41E28900FF2EC0 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/MainInterface.storyboard; sourceTree = ""; }; @@ -85,11 +100,15 @@ 3217CFC346202B3E2A8C52DD /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = ""; }; 331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; 331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 35C9F8DE4B991051758F2BE1 /* GopeedLiveActivityWidget.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = GopeedLiveActivityWidget.swift; sourceTree = ""; }; + 3879B6DB5398A19281BA5F8F /* GopeedContinuedProcessingManager.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = GopeedContinuedProcessingManager.swift; sourceTree = ""; }; 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; + 52A13633B52EDC9D3F479DFD /* Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 55356FCC7E573C3FEEC2DF4D /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = ""; }; 5B918B8D5281BAE2DEA6BE3C /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 76ED41E0163DFA97F018D1AC /* GopeedLiveActivityExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = GopeedLiveActivityExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; 78EE8538B1DF04DAF73E75D3 /* Pods-ShareExtension.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-ShareExtension.profile.xcconfig"; path = "Target Support Files/Pods-ShareExtension/Pods-ShareExtension.profile.xcconfig"; sourceTree = ""; }; 78F0D48FED63C04B9A3FFDE4 /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = ""; }; @@ -112,6 +131,7 @@ D554135997A5C7A2C62E1B64 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; D081C25B294826C0006EB10B /* libc++.tbd */ = {isa = PBXFileReference; lastKnownFileType = "sourcecode.text-based-dylib-definition"; name = "libc++.tbd"; path = "usr/lib/libc++.tbd"; sourceTree = SDKROOT; }; D0E623A729482D030001185E /* Libgopeed.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; name = Libgopeed.xcframework; path = Frameworks/Libgopeed.xcframework; sourceTree = ""; }; + EB8C142A455C80367DDD68AE /* GopeedLiveActivityManager.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = GopeedLiveActivityManager.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -123,6 +143,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 2134C1710299C602C39EE9D6 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; 97C146EB1CF9000F007C117D /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; @@ -194,6 +221,16 @@ name = Frameworks; sourceTree = ""; }; + 5FB22DCA9F2EA11B7C1F1A22 /* GopeedLiveActivity */ = { + isa = PBXGroup; + children = ( + 35C9F8DE4B991051758F2BE1 /* GopeedLiveActivityWidget.swift */, + 52A13633B52EDC9D3F479DFD /* Info.plist */, + ); + name = GopeedLiveActivity; + path = GopeedLiveActivity; + sourceTree = ""; + }; 9740EEB11CF90186004384FC /* Flutter */ = { isa = PBXGroup; children = ( @@ -215,6 +252,7 @@ 331C8082294A63A400263BE5 /* RunnerTests */, 2B6C6F082D376286EF3D5FA3 /* Pods */, 44BA5D7C75A3E4957F6560C7 /* Frameworks */, + 5FB22DCA9F2EA11B7C1F1A22 /* GopeedLiveActivity */, ); sourceTree = ""; }; @@ -224,6 +262,7 @@ 97C146EE1CF9000F007C117D /* Runner.app */, 0C585CB72D41E28900FF2EC0 /* ShareExtension.appex */, 331C8081294A63A400263BE5 /* RunnerTests.xctest */, + 76ED41E0163DFA97F018D1AC /* GopeedLiveActivityExtension.appex */, ); name = Products; sourceTree = ""; @@ -244,6 +283,9 @@ A20000000000000000000003 /* GopeedTaskEventForwarder.h */, A20000000000000000000002 /* GopeedTaskEventForwarder.m */, 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, + 09AC32C4BF90EAC54D048584 /* GopeedDownloadAttributes.swift */, + EB8C142A455C80367DDD68AE /* GopeedLiveActivityManager.swift */, + 3879B6DB5398A19281BA5F8F /* GopeedContinuedProcessingManager.swift */, ); path = Runner; sourceTree = ""; @@ -306,12 +348,30 @@ ); dependencies = ( 0C585CC02D41E28900FF2EC0 /* PBXTargetDependency */, + FBEBBD3469D2B152FDD0B424 /* PBXTargetDependency */, ); name = Runner; productName = Runner; productReference = 97C146EE1CF9000F007C117D /* Runner.app */; productType = "com.apple.product-type.application"; }; + FFA6D07C1B1CB56543DE4B67 /* GopeedLiveActivityExtension */ = { + isa = PBXNativeTarget; + buildConfigurationList = DAF208923F589C87CBB49D56 /* Build configuration list for PBXNativeTarget "GopeedLiveActivityExtension" */; + buildPhases = ( + 1A1326FD8C4C86ED5A529666 /* Sources */, + 2134C1710299C602C39EE9D6 /* Frameworks */, + 9AB02E797D34C8878FDBED22 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = GopeedLiveActivityExtension; + productName = GopeedLiveActivityExtension; + productReference = 76ED41E0163DFA97F018D1AC /* GopeedLiveActivityExtension.appex */; + productType = "com.apple.product-type.app-extension"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -351,6 +411,7 @@ 97C146ED1CF9000F007C117D /* Runner */, 0C585CB62D41E28900FF2EC0 /* ShareExtension */, 331C8080294A63A400263BE5 /* RunnerTests */, + FFA6D07C1B1CB56543DE4B67 /* GopeedLiveActivityExtension */, ); }; /* End PBXProject section */ @@ -382,6 +443,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 9AB02E797D34C8878FDBED22 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ @@ -510,6 +578,15 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 1A1326FD8C4C86ED5A529666 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 6243BE8F995F5284DBDD7DE3 /* GopeedLiveActivityWidget.swift in Sources */, + 1045ACE898D35C0D8D8A993E /* GopeedDownloadAttributes.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; 331C807D294A63A400263BE5 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -527,6 +604,9 @@ 7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */, A10000000000000000000001 /* LocationKeepAliveManager.swift in Sources */, A20000000000000000000001 /* GopeedTaskEventForwarder.m in Sources */, + 1E29080079AAFD1CF2C71262 /* GopeedDownloadAttributes.swift in Sources */, + 032447D610CFAF8A75642212 /* GopeedLiveActivityManager.swift in Sources */, + 5BF69191A426106A38B62F9F /* GopeedContinuedProcessingManager.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -543,6 +623,12 @@ target = 97C146ED1CF9000F007C117D /* Runner */; targetProxy = 331C8085294A63A400263BE5 /* PBXContainerItemProxy */; }; + FBEBBD3469D2B152FDD0B424 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + name = GopeedLiveActivityExtension; + target = FFA6D07C1B1CB56543DE4B67 /* GopeedLiveActivityExtension */; + targetProxy = 03B416C78831516F6591BD24 /* PBXContainerItemProxy */; + }; /* End PBXTargetDependency section */ /* Begin PBXVariantGroup section */ @@ -774,6 +860,61 @@ }; name = Profile; }; + 6CADF5E48DB3AF5F00998F2A /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB31CF90195004384FC /* Generated.xcconfig */; + buildSettings = { + APPLICATION_EXTENSION_API_ONLY = YES; + CLANG_ENABLE_OBJC_WEAK = NO; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = GopeedLiveActivity/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = Gopeed; + IPHONEOS_DEPLOYMENT_TARGET = 16.2; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = "$(FLUTTER_BUILD_NAME)"; + PRODUCT_BUNDLE_IDENTIFIER = com.gopeed.gopeed.GopeedLiveActivityExtension; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SKIP_INSTALL = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 91F84B2C36416D1F9C500144 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB31CF90195004384FC /* Generated.xcconfig */; + buildSettings = { + APPLICATION_EXTENSION_API_ONLY = YES; + CLANG_ENABLE_OBJC_WEAK = NO; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = GopeedLiveActivity/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = Gopeed; + IPHONEOS_DEPLOYMENT_TARGET = 16.2; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = "$(FLUTTER_BUILD_NAME)"; + PRODUCT_BUNDLE_IDENTIFIER = com.gopeed.gopeed.GopeedLiveActivityExtension; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SKIP_INSTALL = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; 97C147031CF9000F007C117D /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -930,6 +1071,34 @@ }; name = Release; }; + ABCC87B2280A98A3C9E482CF /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB31CF90195004384FC /* Generated.xcconfig */; + buildSettings = { + APPLICATION_EXTENSION_API_ONLY = YES; + CLANG_ENABLE_OBJC_WEAK = NO; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = GopeedLiveActivity/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = Gopeed; + IPHONEOS_DEPLOYMENT_TARGET = 16.2; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = "$(FLUTTER_BUILD_NAME)"; + PRODUCT_BUNDLE_IDENTIFIER = com.gopeed.gopeed.GopeedLiveActivityExtension; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SKIP_INSTALL = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Profile; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ @@ -973,6 +1142,16 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + DAF208923F589C87CBB49D56 /* Build configuration list for PBXNativeTarget "GopeedLiveActivityExtension" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 6CADF5E48DB3AF5F00998F2A /* Release */, + 91F84B2C36416D1F9C500144 /* Debug */, + ABCC87B2280A98A3C9E482CF /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; /* End XCConfigurationList section */ }; rootObject = 97C146E61CF9000F007C117D /* Project object */; diff --git a/ui/flutter/ios/Runner/AppDelegate.swift b/ui/flutter/ios/Runner/AppDelegate.swift index e99ee6de..997da44a 100644 --- a/ui/flutter/ios/Runner/AppDelegate.swift +++ b/ui/flutter/ios/Runner/AppDelegate.swift @@ -63,8 +63,19 @@ import UIKit GopeedInvokeAsyncWithResult(method, path, query, body, requestID, result) case "subscribeTaskEvents": let arguments = call.arguments as? [String: Any] - let mask = (arguments?["mask"] as? NSNumber)?.int64Value ?? 0 - GopeedSubscribeTaskEventsWithForwarder(mask, mask == 0 ? nil : taskEventForwarder) + let flutterMask = + (arguments?["mask"] as? NSNumber)?.int64Value ?? 0 + + let liveActivityMask = + LibgopeedLiveActivityTaskEventMask() + + let combinedMask = + flutterMask | liveActivityMask + + GopeedSubscribeTaskEventsWithForwarder( + combinedMask, + taskEventForwarder + ) result(nil) default: result(FlutterMethodNotImplemented) @@ -91,5 +102,55 @@ import UIKit result(FlutterMethodNotImplemented) } } + let continuedProcessingChannel = + FlutterMethodChannel( + name: "gopeed/continued_processing", + binaryMessenger: messenger + ) + + continuedProcessingChannel + .setMethodCallHandler { call, result in + + switch call.method { + + case "isSupported": + + if #available(iOS 26.0, *) { + result(true) + } else { + result(false) + } + + case "setEnabled": + + let arguments = + call.arguments + as? [String: Any] + + let enabled = + arguments?["enabled"] + as? Bool + ?? false + + if #available(iOS 26.0, *) { + + GopeedContinuedProcessingManager + .shared + .setEnabled(enabled) { + success in + result(success) + } + + } else { + + result(!enabled) + } + + default: + result( + FlutterMethodNotImplemented + ) + } + } } } diff --git a/ui/flutter/ios/Runner/GopeedContinuedProcessingManager.swift b/ui/flutter/ios/Runner/GopeedContinuedProcessingManager.swift new file mode 100644 index 00000000..7ef2b852 --- /dev/null +++ b/ui/flutter/ios/Runner/GopeedContinuedProcessingManager.swift @@ -0,0 +1,1117 @@ +import BackgroundTasks +import Foundation +import Libgopeed +import UIKit + +@available(iOS 26.0, *) +@objcMembers +final class GopeedContinuedProcessingManager: NSObject { + + static let shared = GopeedContinuedProcessingManager() + + // MARK: - Worker / state synchronization + + private let workerQueue = DispatchQueue( + label: "com.gopeed.continued-processing", + qos: .userInitiated + ) + + private let stateLock = NSLock() + private var enabledSnapshot = false + private var handledTaskIDs = Set() + + // MARK: - BGCPT state + + private var registeredIdentifiers = Set() + private var pendingTaskIDs = Set() + private struct ExpiringTaskState { + let identifier: String + let generation: UUID + } + + private var expiringTasks: + [String: ExpiringTaskState] = [:] + private var taskGenerations: [String: UUID] = [:] + + private var activeTasks: + [String: BGContinuedProcessingTask] = [:] + + private var taskIdentifiers: + [String: String] = [:] + + private var taskNames: + [String: String] = [:] + + private var lastProgressUpdate: + [String: Date] = [:] + + private var lastTitleUpdate: + [String: Date] = [:] + + private let minimumProgressUpdateInterval: + TimeInterval = 1.0 + + private let minimumTitleUpdateInterval: + TimeInterval = 1.0 + + private override init() { + super.init() + } + + + // MARK: - Synchronization helpers + + private func currentEnabledSnapshot() -> Bool { + stateLock.lock() + let value = enabledSnapshot + stateLock.unlock() + + return value + } + + private func setEnabledSnapshot( + _ enabled: Bool + ) { + stateLock.lock() + enabledSnapshot = enabled + stateLock.unlock() + } + + private func setHandledSnapshot( + taskID: String, + handled: Bool + ) { + stateLock.lock() + + if handled { + handledTaskIDs.insert(taskID) + } else { + handledTaskIDs.remove(taskID) + } + + stateLock.unlock() + } + + private func handledTaskIDsSnapshot() -> [String] { + stateLock.lock() + let ids = Array(handledTaskIDs) + stateLock.unlock() + + return ids + } + + private func setTaskOwnership( + taskID: String, + handled: Bool + ) { + setHandledSnapshot( + taskID: taskID, + handled: handled + ) + + // This is a second layer of protection in addition to the + // Objective-C forwarder. If Continued Processing owns a task, + // the custom ActivityKit manager must not create a second + // Live Activity for it. + GopeedLiveActivityManager.shared + .setTaskSuppressed( + handled, + taskID: taskID + ) + } + + + // MARK: - Setting + + func setEnabled( + _ enabled: Bool, + completion: @escaping (Bool) -> Void + ) { + workerQueue.async { [weak self] in + guard let self else { + DispatchQueue.main.async { + completion(false) + } + return + } + + self.setEnabledSnapshot(enabled) + + if !enabled { + self.stopAllContinuedTasks() + } + + print( + "ContinuedProcessing: enabled =", + enabled + ) + + DispatchQueue.main.async { + completion(true) + } + } + } + + + // MARK: - Event entry point + + func handleTaskEventPayload( + _ payload: String + ) { + + guard currentEnabledSnapshot() else { + return + } + + // Pre-mark task.start so the Objective-C forwarder can + // immediately suppress the custom ActivityKit Live Activity. + if + let data = payload.data(using: .utf8), + let json = try? JSONSerialization.jsonObject( + with: data + ) as? [String: Any], + let type = json["type"] as? String, + type == "task.start", + let taskID = json["taskId"] as? String + { + setTaskOwnership( + taskID: taskID, + handled: true + ) + } + + // IMPORTANT: asynchronous, so Gopeed's event thread + // is never blocked by BGCPT bookkeeping. + workerQueue.async { [weak self] in + self?.handleTaskEventPayloadOnWorker( + payload + ) + } + } + + private func handleTaskEventPayloadOnWorker( + _ payload: String + ) { + + guard currentEnabledSnapshot() else { + return + } + + guard + let data = payload.data( + using: .utf8 + ), + let json = + try? JSONSerialization + .jsonObject( + with: data + ) as? [String: Any], + let type = + json["type"] as? String, + let taskID = + json["taskId"] as? String + else { + print( + "ContinuedProcessing: invalid event" + ) + return + } + + let name = + json["name"] as? String + ?? "Download" + + switch type { + + case "task.start": + beginTask( + taskID: taskID, + name: name + ) + + case "task.progress": + if let generation = + taskGenerations[taskID] { + updateProgress( + taskID: taskID, + generation: generation, + force: false + ) + } + + case "task.pause": + if let expiring = + expiringTasks[taskID] { + finishExpirationCleanup( + taskID: taskID, + identifier: expiring.identifier, + generation: expiring.generation + ) + } else { + finishTask( + taskID: taskID, + success: true, + finalSubtitle: "Paused" + ) + } + + case "task.done": + finishTask( + taskID: taskID, + success: true, + finalSubtitle: + "Download complete" + ) + + case "task.error": + finishTask( + taskID: taskID, + success: false, + finalSubtitle: + "Download failed" + ) + + case "task.delete": + finishTask( + taskID: taskID, + success: true, + finalSubtitle: + "Download removed" + ) + + default: + break + } + } + + + // MARK: - Used by Objective-C forwarder + + @objc(isHandlingTaskId:) + func isHandlingTaskId( + _ taskID: String + ) -> Bool { + + // Do not wait for workerQueue here. The forwarder calls + // this for frequent progress events. + stateLock.lock() + let handled = handledTaskIDs.contains(taskID) + stateLock.unlock() + + return handled + } + + + // MARK: - Start BGCPT + + private func beginTask( + taskID: String, + name: String + ) { + + guard currentEnabledSnapshot() else { + setTaskOwnership( + taskID: taskID, + handled: false + ) + return + } + + guard + !pendingTaskIDs.contains(taskID), + activeTasks[taskID] == nil + else { + setTaskOwnership( + taskID: taskID, + handled: true + ) + return + } + + // BGCPT should originate from a foreground user action. + // This main-thread check happens only once per task start. + let appIsActive: Bool + + if Thread.isMainThread { + appIsActive = + UIApplication.shared + .applicationState == .active + } else { + appIsActive = + DispatchQueue.main.sync { + UIApplication.shared + .applicationState == .active + } + } + + guard appIsActive else { + print( + "ContinuedProcessing:", + "ignored non-foreground start", + taskID + ) + + setTaskOwnership( + taskID: taskID, + handled: false + ) + return + } + + let generation = UUID() + taskGenerations[taskID] = generation + + let identifier = + makeIdentifier( + taskID: taskID, + generation: generation + ) + + taskIdentifiers[taskID] = identifier + taskNames[taskID] = name + + let registered = + BGTaskScheduler.shared.register( + forTaskWithIdentifier: identifier, + using: nil + ) { [weak self] task in + guard + let self, + let continuedTask = + task as? BGContinuedProcessingTask + else { + task.setTaskCompleted( + success: false + ) + return + } + + self.workerQueue.async { + self.activateTask( + continuedTask, + taskID: taskID, + name: name, + identifier: identifier, + generation: generation + ) + } + } + + guard registered else { + print( + "ContinuedProcessing:", + "registration failed:", + identifier + ) + + if taskGenerations[taskID] == generation { + taskGenerations.removeValue( + forKey: taskID + ) + taskIdentifiers.removeValue( + forKey: taskID + ) + taskNames.removeValue( + forKey: taskID + ) + setTaskOwnership( + taskID: taskID, + handled: false + ) + } + return + } + + registeredIdentifiers.insert(identifier) + + let request = + BGContinuedProcessingTaskRequest( + identifier: identifier, + title: name, + subtitle: "Preparing download…" + ) + + request.strategy = .fail + + pendingTaskIDs.insert(taskID) + + setTaskOwnership( + taskID: taskID, + handled: true + ) + + do { + try BGTaskScheduler.shared.submit( + request + ) + + print( + "ContinuedProcessing:", + "submitted:", + identifier + ) + } catch { + if taskGenerations[taskID] == generation { + pendingTaskIDs.remove(taskID) + taskGenerations.removeValue( + forKey: taskID + ) + + if taskIdentifiers[taskID] + == identifier { + taskIdentifiers.removeValue( + forKey: taskID + ) + } + + taskNames.removeValue( + forKey: taskID + ) + + setTaskOwnership( + taskID: taskID, + handled: false + ) + } + + print( + "ContinuedProcessing:", + "submission failed:", + error + ) + } + } + + + // MARK: - System launched task + + private func activateTask( + _ task: BGContinuedProcessingTask, + taskID: String, + name: String, + identifier: String, + generation: UUID + ) { + guard + currentEnabledSnapshot(), + taskGenerations[taskID] == generation, + pendingTaskIDs.contains(taskID), + taskIdentifiers[taskID] == identifier + else { + task.setTaskCompleted( + success: false + ) + return + } + + pendingTaskIDs.remove(taskID) + + activeTasks[taskID] = task + taskNames[taskID] = name + + setTaskOwnership( + taskID: taskID, + handled: true + ) + + task.progress.totalUnitCount = 100 + task.progress.completedUnitCount = 0 + + task.expirationHandler = { + [weak self, weak task] in + guard + let self, + let task + else { + return + } + + self.workerQueue.async { + self.handleExpiration( + task, + taskID: taskID, + identifier: identifier, + generation: generation + ) + } + } + + print( + "ContinuedProcessing:", + "started:", + taskID + ) + + updateProgress( + taskID: taskID, + generation: generation, + force: true + ) + } + + + // MARK: - Async Gopeed invocation + + private func invokeGopeed( + method: String, + path: String, + query: String = "", + body: String = "", + completion: + @escaping (String?) -> Void + ) { + + GopeedInvokeAsyncNative( + method, + path, + query, + body + ) { [weak self] success, payload in + + guard let self else { + return + } + + self.workerQueue.async { + completion( + success + ? payload + : nil + ) + } + } + } + + + // MARK: - Progress + + private struct RuntimeStatus { + let status: String + let downloaded: Int64 + let total: Int64 + let speed: Int64 + } + + private func getRuntimeStatus( + taskID: String, + completion: + @escaping (RuntimeStatus?) -> Void + ) { + + invokeGopeed( + method: "GET", + path: + "/api/v1/tasks/\(taskID)/status" + ) { response in + + guard + let response, + let data = + response.data( + using: .utf8 + ), + let root = + try? JSONSerialization + .jsonObject( + with: data + ) as? [String: Any], + let code = + (root["code"] as? NSNumber)? + .intValue, + code == 0, + let body = + root["data"] + as? [String: Any] + else { + completion(nil) + return + } + + completion( + RuntimeStatus( + status: + body["status"] + as? String ?? "", + downloaded: + (body["downloaded"] + as? NSNumber)? + .int64Value ?? 0, + total: + (body["total"] + as? NSNumber)? + .int64Value ?? 0, + speed: + (body["speed"] + as? NSNumber)? + .int64Value ?? 0 + ) + ) + } + } + + private func updateProgress( + taskID: String, + generation: UUID, + force: Bool + ) { + + guard + taskGenerations[taskID] == generation, + activeTasks[taskID] != nil + else { + return + } + + let requestTime = Date() + + if !force, + let previous = + lastProgressUpdate[taskID], + requestTime.timeIntervalSince(previous) + < minimumProgressUpdateInterval { + + return + } + + // Mark before starting the async request so frequent + // task.progress events cannot create overlapping + // status requests. + lastProgressUpdate[taskID] = + requestTime + + getRuntimeStatus( + taskID: taskID + ) { [weak self] runtime in + + guard + let self, + let runtime, + self.taskGenerations[taskID] + == generation, + let task = + self.activeTasks[taskID] + else { + return + } + + let now = Date() + + let shouldUpdateTitle: Bool + + if force { + + shouldUpdateTitle = true + + } else if let previous = + self.lastTitleUpdate[ + taskID + ] { + + shouldUpdateTitle = + now.timeIntervalSince( + previous + ) + >= + self.minimumTitleUpdateInterval + + } else { + + shouldUpdateTitle = true + } + + let downloaded = + max( + runtime.downloaded, + 0 + ) + + let name = + self.taskNames[taskID] + ?? "Download" + + if runtime.total > 0 { + + let total = + max(runtime.total, 1) + + let completed = + min( + downloaded, + total + ) + + task.progress + .totalUnitCount = + total + + task.progress + .completedUnitCount = + completed + + if shouldUpdateTitle { + + let percent = + Int( + ( + Double( + completed + ) + / Double(total) + * 100.0 + ).rounded() + ) + + var subtitle = + "\(percent)% • " + + "\(self.formatBytes(completed)) / " + + "\(self.formatBytes(total))" + + if runtime.speed > 0 { + subtitle += + " • " + + "\(self.formatBytes(runtime.speed))/s" + } + + task.updateTitle( + name, + subtitle: + subtitle + ) + + self.lastTitleUpdate[ + taskID + ] = now + } + + } else { + + task.progress + .totalUnitCount = 100 + + task.progress + .completedUnitCount = 0 + + if shouldUpdateTitle { + + var subtitle = + self.formatBytes( + downloaded + ) + + if runtime.speed > 0 { + subtitle += + " • " + + "\(self.formatBytes(runtime.speed))/s" + } + + task.updateTitle( + name, + subtitle: + subtitle + ) + + self.lastTitleUpdate[ + taskID + ] = now + } + } + } + } + + // MARK: - Completion + + private func finishTask( + taskID: String, + success: Bool, + finalSubtitle: String + ) { + taskGenerations.removeValue( + forKey: taskID + ) + + expiringTasks.removeValue( + forKey: taskID + ) + + if let identifier = + taskIdentifiers[taskID] { + + BGTaskScheduler.shared.cancel( + taskRequestWithIdentifier: + identifier + ) + } + + pendingTaskIDs.remove(taskID) + + if let task = + activeTasks.removeValue( + forKey: taskID + ) { + + if success, + finalSubtitle == + "Download complete", + task.progress + .totalUnitCount > 0 { + + task.progress + .completedUnitCount = + task.progress + .totalUnitCount + } + + task.updateTitle( + taskNames[taskID] + ?? "Download", + subtitle: + finalSubtitle + ) + + task.setTaskCompleted( + success: success + ) + } + + taskIdentifiers.removeValue( + forKey: taskID + ) + + taskNames.removeValue( + forKey: taskID + ) + + lastProgressUpdate.removeValue( + forKey: taskID + ) + + lastTitleUpdate.removeValue( + forKey: taskID + ) + + setTaskOwnership( + taskID: taskID, + handled: false + ) + + print( + "ContinuedProcessing:", + "finished:", + taskID, + "success:", + success + ) + } + + // MARK: - Expiration / system cancellation + + private func handleExpiration( + _ task: BGContinuedProcessingTask, + taskID: String, + identifier: String, + generation: UUID + ) { + guard + taskGenerations[taskID] == generation, + taskIdentifiers[taskID] == identifier + else { + task.setTaskCompleted( + success: false + ) + return + } + + taskGenerations.removeValue( + forKey: taskID + ) + + activeTasks.removeValue( + forKey: taskID + ) + + pendingTaskIDs.remove(taskID) + expiringTasks[taskID] = + ExpiringTaskState( + identifier: identifier, + generation: generation + ) + + lastProgressUpdate.removeValue( + forKey: taskID + ) + + lastTitleUpdate.removeValue( + forKey: taskID + ) + + // Keep ownership/suppression active until the pause request + // has been delivered. Otherwise progress events arriving in + // this small window can start a custom ActivityKit activity. + setTaskOwnership( + taskID: taskID, + handled: true + ) + + print( + "ContinuedProcessing:", + "expired/cancelled:", + taskID + ) + + task.setTaskCompleted( + success: false + ) + + invokeGopeed( + method: "PUT", + path: + "/api/v1/tasks/\(taskID)/pause" + ) { [weak self] _ in + self?.finishExpirationCleanup( + taskID: taskID, + identifier: identifier, + generation: generation + ) + } + + // Defensive fallback in case the async bridge never calls + // back because the process is being suspended. + workerQueue.asyncAfter( + deadline: .now() + 2.0 + ) { [weak self] in + self?.finishExpirationCleanup( + taskID: taskID, + identifier: identifier, + generation: generation + ) + } + } + + private func finishExpirationCleanup( + taskID: String, + identifier: String, + generation: UUID + ) { + guard + let expiring = expiringTasks[taskID], + expiring.generation == generation, + expiring.identifier == identifier + else { + return + } + + expiringTasks.removeValue( + forKey: taskID + ) + + if taskIdentifiers[taskID] + == identifier { + taskIdentifiers.removeValue( + forKey: taskID + ) + + if taskGenerations[taskID] == nil { + taskNames.removeValue( + forKey: taskID + ) + } + } + + // Never clear ownership for a newer start of the same task ID. + if taskGenerations[taskID] == nil { + setTaskOwnership( + taskID: taskID, + handled: false + ) + } + } + + + // MARK: - Disable all BGCPT tasks + + private func stopAllContinuedTasks() { + + let ownedTaskIDs = + Set( + handledTaskIDsSnapshot() + + Array(taskIdentifiers.keys) + + Array(pendingTaskIDs) + + Array(activeTasks.keys) + + Array(expiringTasks.keys) + ) + + for ( + taskID, + identifier + ) in taskIdentifiers { + + BGTaskScheduler.shared.cancel( + taskRequestWithIdentifier: + identifier + ) + + if let task = + activeTasks[taskID] { + + task.setTaskCompleted( + success: true + ) + } + } + + activeTasks.removeAll() + pendingTaskIDs.removeAll() + expiringTasks.removeAll() + taskGenerations.removeAll() + taskIdentifiers.removeAll() + taskNames.removeAll() + lastProgressUpdate.removeAll() + lastTitleUpdate.removeAll() + + for taskID in ownedTaskIDs { + setTaskOwnership( + taskID: taskID, + handled: false + ) + } + } + + + // MARK: - Helpers + + private func makeIdentifier( + taskID: String, + generation: UUID + ) -> String { + + let bundleID = + Bundle.main.bundleIdentifier + ?? "com.gopeed.gopeed" + + let safeID = + taskID.replacingOccurrences( + of: "[^A-Za-z0-9_-]", + with: "-", + options: + .regularExpression + ) + + return + "\(bundleID)" + + ".continuedDownload." + + safeID + + "." + + generation.uuidString.lowercased() + } + + private func formatBytes( + _ bytes: Int64 + ) -> String { + + return ByteCountFormatter.string( + fromByteCount: + max(bytes, 0), + countStyle: .file + ) + } +} diff --git a/ui/flutter/ios/Runner/GopeedDownloadAttributes.swift b/ui/flutter/ios/Runner/GopeedDownloadAttributes.swift new file mode 100644 index 00000000..a4a127b0 --- /dev/null +++ b/ui/flutter/ios/Runner/GopeedDownloadAttributes.swift @@ -0,0 +1,28 @@ +import ActivityKit +import Foundation + +@available(iOS 16.2, *) +struct GopeedDownloadAttributes: ActivityAttributes { + + struct ContentState: Codable, Hashable { + + // Real confirmed progress. + var progress: Double + + var downloaded: Int64 + var total: Int64 + var speed: Int64 + + var status: String + + // Used by the self-moving estimated progress bar. + var estimatedStart: Date + var estimatedEnd: Date + var usesEstimatedProgress: Bool + } + + // Static properties. + var taskId: String + var fileName: String + var generation: String +} diff --git a/ui/flutter/ios/Runner/GopeedLiveActivityManager.swift b/ui/flutter/ios/Runner/GopeedLiveActivityManager.swift new file mode 100644 index 00000000..84f965ba --- /dev/null +++ b/ui/flutter/ios/Runner/GopeedLiveActivityManager.swift @@ -0,0 +1,1050 @@ +import ActivityKit +import Foundation +import Libgopeed +import UIKit + +@objcMembers +final class GopeedLiveActivityManager: NSObject { + + static let shared = GopeedLiveActivityManager() + + // Gopeed emits progress approximately every 350 ms. + // There is no reason to hit ActivityKit that frequently. + private let minimumUpdateInterval: TimeInterval = 2.0 + + private let stateLock = NSLock() + + private var lastUpdateTime: [String: Date] = [:] + private var suppressedTaskIDs = Set() + private var taskGenerations: [String: UUID] = [:] + private var activityCreationInProgress: + [String: UUID] = [:] + + private override init() { + super.init() + } + + + // MARK: - BGCPT coordination + + func setTaskSuppressed( + _ suppressed: Bool, + taskID: String + ) { + stateLock.lock() + + if suppressed { + suppressedTaskIDs.insert(taskID) + taskGenerations.removeValue( + forKey: taskID + ) + lastUpdateTime.removeValue( + forKey: taskID + ) + } else { + suppressedTaskIDs.remove(taskID) + } + + stateLock.unlock() + + if suppressed { + guard #available(iOS 16.2, *) else { + return + } + + Task { [weak self] in + guard let self else { + return + } + + // Suppression may have been removed again if BGCPT + // submission failed before this async cleanup runs. + guard self.isTaskSuppressed(taskID) else { + return + } + + await self.removeAllActivities( + taskID: taskID, + reason: "suppressed by continued processing" + ) + } + } + } + + private func isTaskSuppressed( + _ taskID: String + ) -> Bool { + stateLock.lock() + let suppressed = + suppressedTaskIDs.contains(taskID) + stateLock.unlock() + + return suppressed + } + + private func startGeneration( + taskID: String + ) -> UUID { + let generation = UUID() + + stateLock.lock() + taskGenerations[taskID] = generation + lastUpdateTime.removeValue( + forKey: taskID + ) + stateLock.unlock() + + return generation + } + + private func currentGeneration( + taskID: String + ) -> UUID? { + stateLock.lock() + let generation = + taskGenerations[taskID] + stateLock.unlock() + + return generation + } + + @discardableResult + private func invalidateGeneration( + taskID: String + ) -> UUID? { + stateLock.lock() + let generation = + taskGenerations.removeValue( + forKey: taskID + ) + lastUpdateTime.removeValue( + forKey: taskID + ) + stateLock.unlock() + + return generation + } + + private func isCurrentGeneration( + taskID: String, + generation: UUID + ) -> Bool { + stateLock.lock() + let isCurrent = + taskGenerations[taskID] + == generation + stateLock.unlock() + + return isCurrent + } + + private func beginActivityCreation( + taskID: String, + generation: UUID + ) -> Bool { + stateLock.lock() + defer { stateLock.unlock() } + + guard + !suppressedTaskIDs.contains(taskID), + taskGenerations[taskID] == generation + else { + return false + } + + if activityCreationInProgress[taskID] + == generation { + return false + } + + activityCreationInProgress[taskID] = + generation + + return true + } + + private func endActivityCreation( + taskID: String, + generation: UUID + ) { + stateLock.lock() + + if activityCreationInProgress[taskID] + == generation { + activityCreationInProgress + .removeValue( + forKey: taskID + ) + } + + stateLock.unlock() + } + + private func shouldHandleProgress( + taskID: String, + generation: UUID, + now: Date + ) -> Bool { + stateLock.lock() + defer { stateLock.unlock() } + + guard + !suppressedTaskIDs.contains(taskID), + taskGenerations[taskID] + == generation + else { + return false + } + + if let previous = lastUpdateTime[taskID], + now.timeIntervalSince(previous) + < minimumUpdateInterval { + return false + } + + lastUpdateTime[taskID] = now + return true + } + + // MARK: - Gopeed event entry point + + @objc + func handleTaskEventPayload(_ payload: String) { + + guard #available(iOS 16.2, *) else { + return + } + + guard + let data = payload.data(using: .utf8), + let json = try? JSONSerialization.jsonObject(with: data) + as? [String: Any], + let type = json["type"] as? String, + let taskID = json["taskId"] as? String + else { + print("LiveActivity: invalid task event payload") + return + } + + guard !isTaskSuppressed(taskID) else { + return + } + + let name = json["name"] as? String ?? "Download" + + switch type { + + case "task.start": + let generation = + startGeneration( + taskID: taskID + ) + + Task { + await refreshActivity( + taskID: taskID, + name: name, + generation: generation, + allowStart: true + ) + } + + case "task.progress": + guard + let generation = + currentGeneration( + taskID: taskID + ) + else { + return + } + + handleProgress( + taskID: taskID, + name: name, + generation: generation + ) + + case "task.pause": + if let generation = + invalidateGeneration( + taskID: taskID + ) { + Task { + await removeActivity( + taskID: taskID, + generation: generation, + reason: "paused" + ) + } + } + + case "task.done": + if let generation = + invalidateGeneration( + taskID: taskID + ) { + Task { + await finishActivity( + taskID: taskID, + generation: generation + ) + } + } + + case "task.error": + let error = + json["error"] as? String + ?? "Download failed" + + if let generation = + invalidateGeneration( + taskID: taskID + ) { + Task { + await failActivity( + taskID: taskID, + generation: generation, + error: error + ) + } + } + + case "task.delete": + if let generation = + invalidateGeneration( + taskID: taskID + ) { + Task { + await removeActivity( + taskID: taskID, + generation: generation, + reason: "removed" + ) + } + } + + default: + break + } + } + + + // MARK: - Progress throttling + + @available(iOS 16.2, *) + private func handleProgress( + taskID: String, + name: String, + generation: UUID + ) { + let now = Date() + + guard shouldHandleProgress( + taskID: taskID, + generation: generation, + now: now + ) else { + return + } + + Task { + await refreshActivity( + taskID: taskID, + name: name, + generation: generation, + allowStart: true + ) + } + } + + + // MARK: - Async Gopeed invocation + + private func invokeGopeed( + method: String, + path: String, + query: String = "", + body: String = "" + ) async -> String? { + + await withCheckedContinuation { + continuation in + + GopeedInvokeAsyncNative( + method, + path, + query, + body + ) { success, payload in + + continuation.resume( + returning: + success + ? payload + : nil + ) + } + } + } + + + // MARK: - Gopeed runtime status + + private struct RuntimeStatus { + let status: String + let downloaded: Int64 + let total: Int64 + let speed: Int64 + } + + private func getRuntimeStatus( + taskID: String + ) async -> RuntimeStatus? { + + guard + let response = + await invokeGopeed( + method: "GET", + path: + "/api/v1/tasks/\(taskID)/status" + ) + else { + print( + "LiveActivity: status request failed for \(taskID)" + ) + return nil + } + + guard + let data = response.data(using: .utf8), + let root = + try? JSONSerialization.jsonObject( + with: data + ) as? [String: Any] + else { + print( + "LiveActivity: failed to decode status for \(taskID)" + ) + return nil + } + + let code = + (root["code"] as? NSNumber)?.intValue + ?? -1 + + guard code == 0 else { + let message = + root["msg"] as? String + ?? "Unknown Gopeed API error" + + print( + "LiveActivity: Gopeed status error:", + message + ) + + return nil + } + + guard + let body = + root["data"] as? [String: Any] + else { + return nil + } + + return RuntimeStatus( + status: + body["status"] as? String ?? "", + downloaded: + (body["downloaded"] as? NSNumber)? + .int64Value ?? 0, + total: + (body["total"] as? NSNumber)? + .int64Value ?? 0, + speed: + (body["speed"] as? NSNumber)? + .int64Value ?? 0 + ) + } + + // MARK: - Build Live Activity state + + @available(iOS 16.2, *) + private func makeState( + from runtime: RuntimeStatus + ) -> GopeedDownloadAttributes.ContentState { + + let now = Date() + + let total = + max(runtime.total, 0) + + let downloaded: Int64 + + if total > 0 { + downloaded = + min( + max(runtime.downloaded, 0), + total + ) + } else { + downloaded = + max(runtime.downloaded, 0) + } + + let progress: Double + + if total > 0 { + progress = + min( + max( + Double(downloaded) + / Double(total), + 0 + ), + 1 + ) + } else { + progress = 0 + } + + // If we don't have enough information for an ETA, + // use the real static progress bar. + guard + runtime.status == "running", + total > 0, + runtime.speed > 0, + downloaded < total, + progress > 0, + progress < 1 + else { + return GopeedDownloadAttributes + .ContentState( + progress: progress, + downloaded: downloaded, + total: total, + speed: runtime.speed, + status: runtime.status, + estimatedStart: now, + estimatedEnd: + now.addingTimeInterval(1), + usesEstimatedProgress: false + ) + } + + let remainingBytes = + Double(total - downloaded) + + var remainingSeconds = + remainingBytes + / Double(runtime.speed) + + // Don't allow corrupt speed values to create + // ridiculous date ranges. + remainingSeconds = + min( + max(remainingSeconds, 1), + 86_400 + ) + + let remainingFraction = + max(1.0 - progress, 0.001) + + let estimatedTotalDuration = + remainingSeconds + / remainingFraction + + let elapsedEstimate = + estimatedTotalDuration + * progress + + let estimatedStart = + now.addingTimeInterval( + -elapsedEstimate + ) + + let estimatedEnd = + now.addingTimeInterval( + remainingSeconds + ) + + return GopeedDownloadAttributes + .ContentState( + progress: progress, + downloaded: downloaded, + total: total, + speed: runtime.speed, + status: runtime.status, + estimatedStart: estimatedStart, + estimatedEnd: estimatedEnd, + usesEstimatedProgress: true + ) + } + + + // MARK: - Find existing Activity + + @available(iOS 16.2, *) + private func findActivities( + taskID: String + ) -> [Activity] { + return Activity< + GopeedDownloadAttributes + > + .activities + .filter { + $0.attributes.taskId == taskID + } + } + + @available(iOS 16.2, *) + private func findActivities( + taskID: String, + generation: UUID + ) -> [Activity] { + let generationID = + generation.uuidString + + return findActivities( + taskID: taskID + ) + .filter { + $0.attributes.generation + == generationID + } + } + + @available(iOS 16.2, *) + private func collapseDuplicateActivities( + _ activities: [Activity], + content: ActivityContent + ) async { + guard let primary = activities.first else { + return + } + + await primary.update(content) + + if activities.count > 1 { + for duplicate in activities.dropFirst() { + await duplicate.end( + nil, + dismissalPolicy: .immediate + ) + } + + print( + "LiveActivity: removed", + activities.count - 1, + "duplicate activity/activities for", + primary.attributes.taskId + ) + } + } + + @available(iOS 16.2, *) + private func removeStaleActivities( + taskID: String, + keeping generation: UUID + ) async { + let generationID = + generation.uuidString + + let stale = + findActivities(taskID: taskID) + .filter { + $0.attributes.generation + != generationID + } + + for activity in stale { + await activity.end( + nil, + dismissalPolicy: .immediate + ) + } + } + + + // MARK: - Start / update Activity + + @available(iOS 16.2, *) + private func refreshActivity( + taskID: String, + name: String, + generation: UUID, + allowStart: Bool + ) async { + guard + !isTaskSuppressed(taskID), + isCurrentGeneration( + taskID: taskID, + generation: generation + ) + else { + return + } + + guard + let runtime = + await getRuntimeStatus( + taskID: taskID + ) + else { + return + } + + guard + !isTaskSuppressed(taskID), + isCurrentGeneration( + taskID: taskID, + generation: generation + ) + else { + return + } + + let state = + makeState(from: runtime) + + let content = + ActivityContent( + state: state, + staleDate: nil + ) + + let existing = + findActivities( + taskID: taskID, + generation: generation + ) + + if !existing.isEmpty { + await collapseDuplicateActivities( + existing, + content: content + ) + return + } + + guard + allowStart, + runtime.status == "running" + else { + return + } + + guard + ActivityAuthorizationInfo() + .areActivitiesEnabled + else { + print( + "LiveActivity: activities disabled" + ) + return + } + + let appIsActive = + await MainActor.run { + UIApplication.shared + .applicationState == .active + } + + guard + appIsActive, + !isTaskSuppressed(taskID), + isCurrentGeneration( + taskID: taskID, + generation: generation + ) + else { + return + } + + guard beginActivityCreation( + taskID: taskID, + generation: generation + ) else { + return + } + + defer { + endActivityCreation( + taskID: taskID, + generation: generation + ) + } + + guard + !isTaskSuppressed(taskID), + isCurrentGeneration( + taskID: taskID, + generation: generation + ) + else { + return + } + + await removeStaleActivities( + taskID: taskID, + keeping: generation + ) + + guard + !isTaskSuppressed(taskID), + isCurrentGeneration( + taskID: taskID, + generation: generation + ) + else { + return + } + + let recheck = + findActivities( + taskID: taskID, + generation: generation + ) + + if !recheck.isEmpty { + await collapseDuplicateActivities( + recheck, + content: content + ) + return + } + + let attributes = + GopeedDownloadAttributes( + taskId: taskID, + fileName: name, + generation: + generation.uuidString + ) + + do { + let activity = + try Activity.request( + attributes: attributes, + content: content, + pushType: nil + ) + + if + isTaskSuppressed(taskID) + || !isCurrentGeneration( + taskID: taskID, + generation: generation + ) + { + await activity.end( + nil, + dismissalPolicy: .immediate + ) + return + } + + print( + "LiveActivity: started", + activity.id, + taskID + ) + } catch { + print( + "LiveActivity: start failed:", + error + ) + } + } + + + // MARK: - Complete Activity + + @available(iOS 16.2, *) + private func finishActivity( + taskID: String, + generation: UUID + ) async { + let activities = + findActivities( + taskID: taskID, + generation: generation + ) + + guard !activities.isEmpty else { + return + } + + let now = Date() + + for activity in activities { + let oldState = + activity.content.state + + let finalTotal = + max( + oldState.total, + oldState.downloaded + ) + + let finalState = + GopeedDownloadAttributes + .ContentState( + progress: 1.0, + downloaded: finalTotal, + total: finalTotal, + speed: 0, + status: "done", + estimatedStart: now, + estimatedEnd: + now.addingTimeInterval(1), + usesEstimatedProgress: false + ) + + let finalContent = + ActivityContent( + state: finalState, + staleDate: nil + ) + + await activity.end( + finalContent, + dismissalPolicy: + .after( + Date() + .addingTimeInterval(15) + ) + ) + } + + print( + "LiveActivity: completed", + taskID + ) + } + + + // MARK: - Error Activity + + @available(iOS 16.2, *) + private func failActivity( + taskID: String, + generation: UUID, + error: String + ) async { + let activities = + findActivities( + taskID: taskID, + generation: generation + ) + + guard !activities.isEmpty else { + return + } + + let now = Date() + + for activity in activities { + let old = + activity.content.state + + let failedState = + GopeedDownloadAttributes + .ContentState( + progress: old.progress, + downloaded: old.downloaded, + total: old.total, + speed: 0, + status: "error", + estimatedStart: now, + estimatedEnd: + now.addingTimeInterval(1), + usesEstimatedProgress: false + ) + + let content = + ActivityContent( + state: failedState, + staleDate: nil + ) + + await activity.end( + content, + dismissalPolicy: + .after( + Date() + .addingTimeInterval(30) + ) + ) + } + + print( + "LiveActivity: task failed:", + taskID, + error + ) + } + + + // MARK: - Delete / suppression cleanup + + @available(iOS 16.2, *) + private func removeActivity( + taskID: String, + generation: UUID, + reason: String + ) async { + let activities = + findActivities( + taskID: taskID, + generation: generation + ) + + for activity in activities { + await activity.end( + nil, + dismissalPolicy: .immediate + ) + } + + if !activities.isEmpty { + print( + "LiveActivity:", + reason, + taskID, + "count:", + activities.count + ) + } + } + + @available(iOS 16.2, *) + private func removeAllActivities( + taskID: String, + reason: String + ) async { + let activities = + findActivities( + taskID: taskID + ) + + for activity in activities { + await activity.end( + nil, + dismissalPolicy: .immediate + ) + } + + if !activities.isEmpty { + print( + "LiveActivity:", + reason, + taskID, + "count:", + activities.count + ) + } + } + +} diff --git a/ui/flutter/ios/Runner/GopeedTaskEventForwarder.h b/ui/flutter/ios/Runner/GopeedTaskEventForwarder.h index 6f9869e6..2a63353d 100644 --- a/ui/flutter/ios/Runner/GopeedTaskEventForwarder.h +++ b/ui/flutter/ios/Runner/GopeedTaskEventForwarder.h @@ -13,6 +13,18 @@ FOUNDATION_EXPORT void GopeedSubscribeTaskEventsWithForwarder( int64_t mask, GopeedTaskEventForwarder * _Nullable listener); + +typedef void (^GopeedNativeInvokeCompletion)( + BOOL success, + NSString *payload); + +FOUNDATION_EXPORT void GopeedInvokeAsyncNative( + NSString *method, + NSString *path, + NSString *query, + NSString *body, + GopeedNativeInvokeCompletion completion); + FOUNDATION_EXPORT void GopeedInvokeAsyncWithResult( NSString *method, NSString *path, diff --git a/ui/flutter/ios/Runner/GopeedTaskEventForwarder.m b/ui/flutter/ios/Runner/GopeedTaskEventForwarder.m index e35bd5cf..d0330667 100644 --- a/ui/flutter/ios/Runner/GopeedTaskEventForwarder.m +++ b/ui/flutter/ios/Runner/GopeedTaskEventForwarder.m @@ -2,6 +2,8 @@ #import +#import "Runner-Swift.h" + @interface GopeedTaskEventForwarder () @property(nonatomic, strong) FlutterMethodChannel *channel; @@ -47,6 +49,45 @@ @end + +@interface GopeedNativeInvokeResultForwarder + : NSObject + +@property(nonatomic, copy, nullable) + GopeedNativeInvokeCompletion completion; + +- (instancetype)initWithCompletion: + (GopeedNativeInvokeCompletion)completion; + +@end + +@implementation GopeedNativeInvokeResultForwarder + +- (instancetype)initWithCompletion: + (GopeedNativeInvokeCompletion)completion { + self = [super init]; + if (self) { + _completion = [completion copy]; + } + return self; +} + +- (void)onResult:(int64_t)requestID + success:(BOOL)success + payload:(NSString * _Nullable)payload { + GopeedNativeInvokeCompletion completion = + self.completion; + self.completion = nil; + + if (completion == nil) { + return; + } + + completion(success, payload ?: @""); +} + +@end + @implementation GopeedTaskEventForwarder - (instancetype)initWithChannel:(FlutterMethodChannel *)channel { @@ -59,9 +100,59 @@ - (void)onTaskEvent:(NSString * _Nullable)payload { NSString *arguments = payload ?: @""; + + NSData *data = + [arguments dataUsingEncoding:NSUTF8StringEncoding]; + + NSDictionary *json = nil; + + if (data) { + json = + [NSJSONSerialization JSONObjectWithData:data + options:0 + error:nil]; + } + + NSString *type = json[@"type"]; + NSString *taskID = json[@"taskId"]; + + BOOL continuedProcessingHandlesTask = NO; + + // iOS 26 system continued-processing Live Activity. + if (@available(iOS 26.0, *)) { + GopeedContinuedProcessingManager *manager = + [GopeedContinuedProcessingManager shared]; + + [manager handleTaskEventPayload:arguments]; + + if (taskID.length > 0) { + continuedProcessingHandlesTask = + [manager isHandlingTaskId:taskID]; + } + } + + // When BGCPT is active, let Apple's system Live Activity + // represent progress. Otherwise keep using our existing + // custom Gopeed ActivityKit implementation. + if (!continuedProcessingHandlesTask) { + [[GopeedLiveActivityManager shared] + handleTaskEventPayload:arguments]; + } + + // Flutter currently understands only done/error. + BOOL flutterEvent = + [type isEqualToString:@"task.done"] || + [type isEqualToString:@"task.error"]; + + if (!flutterEvent) { + return; + } + FlutterMethodChannel *channel = self.channel; + dispatch_async(dispatch_get_main_queue(), ^{ - [channel invokeMethod:@"taskEvent" arguments:arguments]; + [channel invokeMethod:@"taskEvent" + arguments:arguments]; }); } @@ -84,3 +175,26 @@ void GopeedInvokeAsyncWithResult( [[GopeedInvokeResultForwarder alloc] initWithResult:result]; LibgopeedInvokeAsync(method, path, query, body, requestID, forwarder); } + +void GopeedInvokeAsyncNative( + NSString *method, + NSString *path, + NSString *query, + NSString *body, + GopeedNativeInvokeCompletion completion) { + if (completion == nil) { + return; + } + + GopeedNativeInvokeResultForwarder *forwarder = + [[GopeedNativeInvokeResultForwarder alloc] + initWithCompletion:completion]; + + LibgopeedInvokeAsync( + method, + path, + query, + body, + 0, + forwarder); +} diff --git a/ui/flutter/ios/Runner/Info.plist b/ui/flutter/ios/Runner/Info.plist index 4c19e631..61892b05 100644 --- a/ui/flutter/ios/Runner/Info.plist +++ b/ui/flutter/ios/Runner/Info.plist @@ -115,6 +115,7 @@ UIBackgroundModes location + processing UIApplicationSceneManifest @@ -156,5 +157,14 @@ UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight + NSSupportsLiveActivities + + + NSSupportsLiveActivitiesFrequentUpdates + + BGTaskSchedulerPermittedIdentifiers + + $(PRODUCT_BUNDLE_IDENTIFIER).continuedDownload.* + diff --git a/ui/flutter/lib/api/model/downloader_config.dart b/ui/flutter/lib/api/model/downloader_config.dart index 39b4bbee..6a0fc47f 100644 --- a/ui/flutter/lib/api/model/downloader_config.dart +++ b/ui/flutter/lib/api/model/downloader_config.dart @@ -125,6 +125,7 @@ class ExtraConfig { bool notifyWhenNewVersion; bool desktopNotification; bool backgroundLocationKeepAlive; + bool backgroundContinuedProcessing; WindowStateConfig windowState; Map bookmarks; List createHistory; @@ -146,6 +147,7 @@ class ExtraConfig { this.notifyWhenNewVersion = true, this.desktopNotification = true, this.backgroundLocationKeepAlive = false, + this.backgroundContinuedProcessing = false, WindowStateConfig? windowState, this.bookmarks = const {}, this.createHistory = const [], diff --git a/ui/flutter/lib/api/model/downloader_config.g.dart b/ui/flutter/lib/api/model/downloader_config.g.dart index 9c82c3ce..33d6e05a 100644 --- a/ui/flutter/lib/api/model/downloader_config.g.dart +++ b/ui/flutter/lib/api/model/downloader_config.g.dart @@ -119,6 +119,10 @@ ExtraConfig _$ExtraConfigFromJson(Map json) => notifyWhenNewVersion: json['notifyWhenNewVersion'] as bool? ?? true, desktopNotification: json['desktopNotification'] as bool? ?? true, backgroundLocationKeepAlive: json['backgroundLocationKeepAlive'] as bool? ?? false, + backgroundContinuedProcessing: + json['backgroundContinuedProcessing'] + as bool? ?? + false, windowState: json['windowState'] == null ? null : WindowStateConfig.fromJson(json['windowState'] as Map?), @@ -146,6 +150,8 @@ Map _$ExtraConfigToJson(ExtraConfig instance) => { ), ); unawaited(LocationKeepAliveCoordinator.instance.reconcile(enabled: config.extra.backgroundLocationKeepAlive)); + unawaited( + ContinuedProcessing.setEnabled( + config.extra.backgroundContinuedProcessing, + ), + ); } + Future _init() async { await AppInitializer.ensureStorageInitialized(); try { @@ -180,6 +187,13 @@ class AppRuntimeController extends AsyncNotifier { startupError: startupError, ); if (Util.isIOS()) { + // Make sure the native Continued Processing manager has the + // persisted setting before app initialization completes. This + // avoids the first download racing ahead of setEnabled(true). + await ContinuedProcessing.setEnabled( + config.extra.backgroundContinuedProcessing, + ); + LocationKeepAliveCoordinator.instance.start( () => state.value?.downloaderConfig.extra.backgroundLocationKeepAlive ?? config.extra.backgroundLocationKeepAlive, diff --git a/ui/flutter/lib/app/application/continued_processing.dart b/ui/flutter/lib/app/application/continued_processing.dart new file mode 100644 index 00000000..08bec054 --- /dev/null +++ b/ui/flutter/lib/app/application/continued_processing.dart @@ -0,0 +1,39 @@ +import 'dart:io'; + +import 'package:flutter/services.dart'; + +class ContinuedProcessing { + const ContinuedProcessing._(); + + static const _channel = + MethodChannel('gopeed/continued_processing'); + + static Future isSupported() async { + if (!Platform.isIOS) return false; + + try { + return await _channel.invokeMethod( + 'isSupported', + ) ?? + false; + } catch (_) { + return false; + } + } + + static Future setEnabled( + bool enabled, + ) async { + if (!Platform.isIOS) return false; + + try { + return await _channel.invokeMethod( + 'setEnabled', + {'enabled': enabled}, + ) ?? + false; + } catch (_) { + return false; + } + } +} diff --git a/ui/flutter/lib/features/settings/presentation/pages/settings_page.dart b/ui/flutter/lib/features/settings/presentation/pages/settings_page.dart index 67d41e74..291b504c 100644 --- a/ui/flutter/lib/features/settings/presentation/pages/settings_page.dart +++ b/ui/flutter/lib/features/settings/presentation/pages/settings_page.dart @@ -13,6 +13,7 @@ import '../../../../app/application/app_appearance_controller.dart'; import '../../../../app/application/app_platform_controller.dart'; import '../../../../app/application/app_runtime_controller.dart'; import '../../../../app/application/location_keep_alive.dart'; +import '../../../../app/application/continued_processing.dart'; import '../../../../core/common/start_config.dart'; import '../../../../core/network/gopeed/gopeed_transport.dart'; import '../../../../core/utils/breakpoints.dart'; @@ -377,6 +378,19 @@ class _SettingsPageState extends ConsumerState { onChanged: (value) => unawaited(_setBackgroundLocationKeepAlive(value)), ), ), + if (Util.isIOS()) + SettingsItem( + title: context.l10n.backgroundContinuedProcessing, + subtitle: + context.l10n.backgroundContinuedProcessingDescription, + child: shad.Switch( + value: + config.extra.backgroundContinuedProcessing, + onChanged: (value) => unawaited( + _setBackgroundContinuedProcessing(value), + ), + ), + ), ], ), ), @@ -1237,6 +1251,43 @@ class _SettingsPageState extends ConsumerState { _mutateConfig((config) => config.extra.backgroundLocationKeepAlive = enabled); await LocationKeepAliveCoordinator.instance.reconcile(enabled: enabled); } + Future + _setBackgroundContinuedProcessing( + bool enabled, + ) async { + if (enabled && + !await ContinuedProcessing.isSupported()) { + if (mounted) { + _toast( + context.l10n + .backgroundContinuedProcessingUnsupported, + ); + } + return; + } + + final applied = + await ContinuedProcessing.setEnabled( + enabled, + ); + + if (enabled && !applied) { + if (mounted) { + _toast( + context.l10n + .backgroundContinuedProcessingUnsupported, + ); + } + return; + } + + _mutateConfig( + (config) => + config.extra + .backgroundContinuedProcessing = + enabled, + ); + } List _lines(String text) { return text.split('\n').map((line) => line.trim()).where((line) => line.isNotEmpty).toList(); diff --git a/ui/flutter/lib/l10n/app_en.arb b/ui/flutter/lib/l10n/app_en.arb index c483375b..dcf3d820 100644 --- a/ui/flutter/lib/l10n/app_en.arb +++ b/ui/flutter/lib/l10n/app_en.arb @@ -248,6 +248,7 @@ "ed2kNodesDatHint": "nodes.dat path or URL, one per line", "desktopNotification": "Desktop Notifications", "backgroundLocationKeepAlive": "Background Downloads", + "backgroundContinuedProcessing": "Continued Processing (iOS 26+)", "notificationTaskDone": "Task completed", "notificationTaskError": "Task error", "appTitle": "Gopeed Download Manager", @@ -290,6 +291,8 @@ "browserExtensionDescription": "Send browser downloads directly to Gopeed after installation", "desktopNotificationDescription": "Show a system notification when a task completes or fails", "backgroundLocationKeepAliveDescription": "Use location access to keep downloads active in the background", + "backgroundContinuedProcessingDescription": "Use iOS continued processing and its system Live Activity to keep downloads running without location access", + "backgroundContinuedProcessingUnsupported": "Continued Processing requires iOS 26 or later", "currentVersion": "Current version {version}", "contributors": "Contributors", "viewContributors": "View contributors",