fix(ui): enable task detail text selection and compact extension stats (#1511)

This commit is contained in:
Levi authored and GitHub committed 2026-09-13 23:30:45 +08:00
1 parent a29f4bb42a
commit a760290213
6 files changed
+236 -71

No files matched your search

@@ -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}';
}
}
@@ -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),
),
],
),
],
),
],
@@ -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)),
],
);
}
@@ -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<TaskDetailsView> {
? 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<TaskDetailsView> {
),
],
);
return SelectionArea(key: const ValueKey('task-details-selection-area'), child: content);
}
bool get _canOpenStoragePath {
@@ -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');
});
}
+85 -1
View File
@@ -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<SelectionArea>());
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<SizedBox>(find.byKey(const ValueKey('app-detail-drawer-header')));
@@ -5043,6 +5081,52 @@ class FakeExtensionsController extends ExtensionsController {
}
}
class CrowdedExtensionCardController extends ExtensionsController {
@override
Future<ExtensionsState> 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);