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'));