From 51b7abed487de8ebba083973440664b8b02d4d0a Mon Sep 17 00:00:00 2001 From: Levi Date: Sat, 19 Sep 2026 19:47:02 +0800 Subject: [PATCH] refactor(ui): replace update dot with tappable status chip Card and detail headers now show a green update chip next to the extension title; on cards it opens the update dialog directly. The red dot and the red card button are gone, and the status chip no longer duplicates inside the detail content. Shorten the English label so the chip fits narrow cards. --- .../pages/extension_details_page.dart | 9 ++++ .../presentation/pages/extensions_page.dart | 24 +++-------- .../widgets/extension_detail_view.dart | 41 ++++-------------- .../widgets/extension_update_status.dart | 43 +++++++++++++++++++ ui/flutter/lib/l10n/app_en.arb | 2 +- .../detail/app_detail_surface_desktop.dart | 27 ++++++++---- .../detail/app_detail_surface_mobile.dart | 21 ++++++--- ui/flutter/test/widget_test.dart | 4 +- 8 files changed, 103 insertions(+), 68 deletions(-) create mode 100644 ui/flutter/lib/features/extensions/presentation/widgets/extension_update_status.dart diff --git a/ui/flutter/lib/features/extensions/presentation/pages/extension_details_page.dart b/ui/flutter/lib/features/extensions/presentation/pages/extension_details_page.dart index e719fdfd..3ae0b6ec 100644 --- a/ui/flutter/lib/features/extensions/presentation/pages/extension_details_page.dart +++ b/ui/flutter/lib/features/extensions/presentation/pages/extension_details_page.dart @@ -8,6 +8,7 @@ import '../../../../shared/theme/app_palette.dart'; import '../../../../shared/widgets/detail/app_detail_surface.dart'; import '../../application/extensions_controller.dart'; import '../widgets/extension_detail_view.dart'; +import '../widgets/extension_update_status.dart'; class ExtensionDetailsPage extends ConsumerWidget { const ExtensionDetailsPage({super.key, required this.extensionId, this.initialItem}); @@ -24,6 +25,9 @@ class ExtensionDetailsPage extends ConsumerWidget { return AppDetailPage( title: item?.title ?? context.l10n.extensions, onBack: () => context.canPop() ? context.pop() : context.go('/extensions'), + titleTrailing: _canUpdate(stateAsync.value, item) + ? ExtensionUpdateStatus(label: context.l10n.extensionCanUpdate) + : null, child: item == null ? Center( child: stateAsync.isLoading @@ -33,4 +37,9 @@ class ExtensionDetailsPage extends ConsumerWidget { : ExtensionDetailView(key: ValueKey(item.id), item: item, mobile: true), ); } + + bool _canUpdate(ExtensionsState? state, ExtensionListItem? item) { + final installed = item?.installed; + return item != null && installed != null && (state?.updateFlags.containsKey(installed.identity) ?? false); + } } 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 dc8fe431..437b4971 100644 --- a/ui/flutter/lib/features/extensions/presentation/pages/extensions_page.dart +++ b/ui/flutter/lib/features/extensions/presentation/pages/extensions_page.dart @@ -28,6 +28,7 @@ import '../widgets/extension_detail_view.dart'; import '../widgets/extension_icon.dart'; import '../widgets/extension_setting_field.dart'; import '../widgets/extension_update_dialog.dart'; +import '../widgets/extension_update_status.dart'; const _extensionCardMinWidth = 290.0; const _extensionGridSpacing = 10.0; @@ -992,15 +993,12 @@ class _ExtensionCard extends ConsumerWidget { style: TextStyle(color: palette.textPrimary, fontSize: 14, fontWeight: FontWeight.w700), ), ), - if (canUpdate) ...[ - const SizedBox(width: 6), - Container( - key: ValueKey('extension-card-update-dot-${item.id}'), - width: 8, - height: 8, - decoration: BoxDecoration(color: palette.error, shape: BoxShape.circle), + if (canUpdate && installed != null) + ExtensionUpdateStatus( + key: ValueKey('extension-card-update-status-${item.id}'), + label: context.l10n.extensionCanUpdate, + onTap: busy ? null : () => showExtensionUpdateDialog(context, installed), ), - ], ], ), const SizedBox(height: 2), @@ -1070,16 +1068,6 @@ class _ExtensionCard extends ConsumerWidget { key: ValueKey('extension-card-actions-${item.id}'), mainAxisSize: MainAxisSize.min, children: [ - if (canUpdate && installed != null) - shad.GhostButton( - key: ValueKey('update-extension-${installed.identity}'), - density: shad.ButtonDensity.dense, - onPressed: busy ? null : () => showExtensionUpdateDialog(context, installed), - child: Text( - context.l10n.extensionUpdateAction, - style: TextStyle(color: palette.error, fontWeight: FontWeight.w600), - ), - ), if (installed == null && item.store != null) shad.GhostButton( key: ValueKey('install-store-extension-${item.store!.id}'), 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 f7337291..ad159938 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 @@ -21,20 +21,26 @@ import '../../../../util/util.dart'; import '../../application/extensions_controller.dart'; import 'extension_icon.dart'; import 'extension_update_dialog.dart'; +import 'extension_update_status.dart'; -class ExtensionDetailDrawer extends StatelessWidget { +class ExtensionDetailDrawer extends ConsumerWidget { const ExtensionDetailDrawer({super.key, required this.item, required this.onClose}); final ExtensionListItem? item; final VoidCallback onClose; @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { + final state = ref.watch(extensionsControllerProvider).value; + final installed = item?.installed; + final canUpdate = + item != null && installed != null && (state?.updateFlags.containsKey(installed.identity) ?? false); return AppDetailDrawer( open: item != null, title: item?.title ?? '', onClose: onClose, drawerKey: const ValueKey('extension-details-drawer'), + titleTrailing: canUpdate ? ExtensionUpdateStatus(label: context.l10n.extensionCanUpdate) : null, child: item == null ? const SizedBox.shrink() : ExtensionDetailView(item: item!), ); } @@ -104,8 +110,6 @@ class ExtensionDetailView extends ConsumerWidget { style: TextStyle(color: palette.textMuted, fontSize: 11.5, fontWeight: FontWeight.w600), ), if (store != null) _ExtensionStats(store: store), - if (installed != null && canUpdate) - _ExtensionStatus(label: context.l10n.extensionCanUpdate, emphasized: true), ], ), ], @@ -276,35 +280,6 @@ class _ExtensionReadmeState extends State<_ExtensionReadme> { } } -class _ExtensionStatus extends StatelessWidget { - const _ExtensionStatus({required this.label, required this.emphasized}); - - final String label; - final bool emphasized; - - @override - Widget build(BuildContext context) { - final palette = AppPalette.of(context); - final dotColor = emphasized ? palette.brand : palette.success; - final textColor = emphasized ? palette.textPrimary : palette.success; - return Row( - mainAxisSize: MainAxisSize.min, - children: [ - Container( - width: 5, - height: 5, - decoration: BoxDecoration(color: dotColor, shape: BoxShape.circle), - ), - const SizedBox(width: 5), - Text( - label, - style: TextStyle(color: textColor, fontSize: 11.5, fontWeight: FontWeight.w600), - ), - ], - ); - } -} - class _LinkButtonContent extends StatelessWidget { const _LinkButtonContent({required this.icon, required this.label}); diff --git a/ui/flutter/lib/features/extensions/presentation/widgets/extension_update_status.dart b/ui/flutter/lib/features/extensions/presentation/widgets/extension_update_status.dart new file mode 100644 index 00000000..046b5a21 --- /dev/null +++ b/ui/flutter/lib/features/extensions/presentation/widgets/extension_update_status.dart @@ -0,0 +1,43 @@ +import 'package:flutter/widgets.dart'; + +import '../../../../shared/theme/app_palette.dart'; + +/// Green status dot with an optional label, marking an available update. +/// Pass [onTap] to make it open the update prompt. +class ExtensionUpdateStatus extends StatelessWidget { + const ExtensionUpdateStatus({super.key, this.label, this.onTap}); + + final String? label; + final VoidCallback? onTap; + + @override + Widget build(BuildContext context) { + final palette = AppPalette.of(context); + final content = Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 6, + height: 6, + decoration: BoxDecoration(color: palette.brand, shape: BoxShape.circle), + ), + if (label != null) ...[ + const SizedBox(width: 5), + Text( + label!, + style: TextStyle(color: palette.textPrimary, fontSize: 11.5, fontWeight: FontWeight.w600), + ), + ], + ], + ); + if (onTap == null) return content; + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: onTap, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 6), + child: content, + ), + ); + } +} diff --git a/ui/flutter/lib/l10n/app_en.arb b/ui/flutter/lib/l10n/app_en.arb index 97fb6f36..48059151 100644 --- a/ui/flutter/lib/l10n/app_en.arb +++ b/ui/flutter/lib/l10n/app_en.arb @@ -139,7 +139,7 @@ "extensionLoadMore": "Load More", "extensionNoMore": "No more extensions", "extensionInstalled": "Installed", - "extensionCanUpdate": "Update Available", + "extensionCanUpdate": "Update", "extensionInstall": "Install", "extensionLoadLocal": "Install Local Folder", "extensionFilterAll": "All", diff --git a/ui/flutter/lib/shared/widgets/detail/app_detail_surface_desktop.dart b/ui/flutter/lib/shared/widgets/detail/app_detail_surface_desktop.dart index 75f722f5..3c9294ad 100644 --- a/ui/flutter/lib/shared/widgets/detail/app_detail_surface_desktop.dart +++ b/ui/flutter/lib/shared/widgets/detail/app_detail_surface_desktop.dart @@ -16,6 +16,7 @@ class AppDetailDrawer extends StatelessWidget { required this.child, this.drawerKey, this.width, + this.titleTrailing, }); final bool open; @@ -24,6 +25,7 @@ class AppDetailDrawer extends StatelessWidget { final Widget child; final Key? drawerKey; final double? width; + final Widget? titleTrailing; @override Widget build(BuildContext context) { @@ -82,15 +84,22 @@ class AppDetailDrawer extends StatelessWidget { child: Row( children: [ Expanded( - child: Text( - title, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - color: palette.textPrimary, - fontSize: 16, - fontWeight: FontWeight.w700, - ), + child: Row( + children: [ + Flexible( + child: Text( + title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: palette.textPrimary, + fontSize: 16, + fontWeight: FontWeight.w700, + ), + ), + ), + if (titleTrailing != null) ...[const SizedBox(width: 8), titleTrailing!], + ], ), ), const SizedBox(width: 12), diff --git a/ui/flutter/lib/shared/widgets/detail/app_detail_surface_mobile.dart b/ui/flutter/lib/shared/widgets/detail/app_detail_surface_mobile.dart index 71f340ff..1b191e85 100644 --- a/ui/flutter/lib/shared/widgets/detail/app_detail_surface_mobile.dart +++ b/ui/flutter/lib/shared/widgets/detail/app_detail_surface_mobile.dart @@ -13,12 +13,14 @@ class AppDetailPage extends StatelessWidget { required this.onBack, required this.child, this.backgroundColor, + this.titleTrailing, }); final String title; final VoidCallback onBack; final Widget child; final Color? backgroundColor; + final Widget? titleTrailing; @override Widget build(BuildContext context) { @@ -53,12 +55,19 @@ class AppDetailPage extends StatelessWidget { ), ), Expanded( - child: Text( - title, - textAlign: TextAlign.center, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle(color: palette.textPrimary, fontSize: 17, fontWeight: FontWeight.w700), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Flexible( + child: Text( + title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle(color: palette.textPrimary, fontSize: 17, fontWeight: FontWeight.w700), + ), + ), + if (titleTrailing != null) ...[const SizedBox(width: 8), titleTrailing!], + ], ), ), const SizedBox(width: 44), diff --git a/ui/flutter/test/widget_test.dart b/ui/flutter/test/widget_test.dart index 7269398e..9b8ec608 100644 --- a/ui/flutter/test/widget_test.dart +++ b/ui/flutter/test/widget_test.dart @@ -817,7 +817,9 @@ void main() { 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 entry = find.byKey( + ValueKey(details ? 'extension-details-update' : 'extension-card-update-status-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'));