mirror of
https://github.com/GopeedLab/gopeed.git
synced 2026-11-05 16:15:43 +00:00
fix(ui): enable task detail text selection and compact extension stats (#1511)
This commit is contained in:
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');
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
|
||||
Reference in new issue
Block a user