From a760290213a5e8ee17dedee9cf21ee3d9843eb97 Mon Sep 17 00:00:00 2001 From: Levi Date: Sun, 13 Sep 2026 23:30:45 +0800 Subject: [PATCH] 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);