diff --git a/ui/flutter/lib/features/extensions/application/extensions_controller.dart b/ui/flutter/lib/features/extensions/application/extensions_controller.dart index 74e439d3..110af4ce 100644 --- a/ui/flutter/lib/features/extensions/application/extensions_controller.dart +++ b/ui/flutter/lib/features/extensions/application/extensions_controller.dart @@ -185,12 +185,11 @@ class ExtensionsController extends AsyncNotifier { } bool canUpdateItem(ExtensionListItem item) { - final current = _current; - if (item.installed == null) return false; - if (current.listFilter == ExtensionListFilter.market && item.store != null) { - return _compareVersion(item.store!.version, item.installed!.version) > 0; - } - return current.updateFlags.containsKey(item.installed!.identity); + final installed = item.installed; + if (installed == null) return false; + // The backend checks GitHub live for every installed extension; the store + // index may lag behind and must not decide update visibility. + return _current.updateFlags.containsKey(installed.identity); } Future installFromStore(StoreExtension extension) async { @@ -240,6 +239,9 @@ class ExtensionsController extends AsyncNotifier { await _runBusy(extension.identity, () async { await ref.read(gopeedServiceProvider).updateExtension(extension.identity); await loadInstalled(refreshUpdates: false); + // Drop the stale flag immediately; the background re-check confirms it. + final flags = Map.of(_current.updateFlags)..remove(extension.identity); + state = AsyncValue.data(_current.copyWith(updateFlags: flags)); unawaited(checkUpdate()); _bumpStoreInstallCount(extension.identity); _reportInstallSafe(extension.identity); @@ -354,24 +356,4 @@ class ExtensionsController extends AsyncNotifier { }()); } - static int _compareVersion(String a, String b) { - final aNums = _toVersionNumbers(a); - final bNums = _toVersionNumbers(b); - final maxLen = aNums.length > bNums.length ? aNums.length : bNums.length; - for (var i = 0; i < maxLen; i++) { - final left = i < aNums.length ? aNums[i] : 0; - final right = i < bNums.length ? bNums[i] : 0; - if (left > right) return 1; - if (left < right) return -1; - } - return 0; - } - - static List _toVersionNumbers(String version) { - return version - .split(RegExp(r'[^0-9]+')) - .where((part) => part.isNotEmpty) - .map((part) => int.tryParse(part) ?? 0) - .toList(); - } } 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..8c81eca5 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,8 @@ 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_dialog.dart'; +import '../widgets/extension_update_status.dart'; class ExtensionDetailsPage extends ConsumerWidget { const ExtensionDetailsPage({super.key, required this.extensionId, this.initialItem}); @@ -24,6 +26,15 @@ 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( + key: const ValueKey('extension-details-update-mobile'), + label: context.l10n.extensionCanUpdate, + onTap: item?.installed == null + ? null + : () => showExtensionUpdateDialog(context, item!.installed!), + ) + : null, child: item == null ? Center( child: stateAsync.isLoading @@ -33,4 +44,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 f2bd73b9..9d720605 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; @@ -485,25 +486,7 @@ class _Content extends StatelessWidget { } bool _canUpdate(ExtensionsState state, ExtensionListItem item) { - if (item.installed == null) return false; - if (state.listFilter == ExtensionListFilter.market && item.store != null) { - return _compareVersion(item.store!.version, item.installed!.version) > 0; - } - return state.updateFlags.containsKey(item.installed!.identity); - } - - int _compareVersion(String a, String b) { - final aNums = a.split(RegExp(r'[^0-9]+')).where((part) => part.isNotEmpty).map((part) => int.tryParse(part) ?? 0); - final bNums = b.split(RegExp(r'[^0-9]+')).where((part) => part.isNotEmpty).map((part) => int.tryParse(part) ?? 0); - final left = aNums.toList(); - final right = bNums.toList(); - final maxLen = left.length > right.length ? left.length : right.length; - for (var i = 0; i < maxLen; i++) { - final l = i < left.length ? left[i] : 0; - final r = i < right.length ? right[i] : 0; - if (l != r) return l.compareTo(r); - } - return 0; + return item.installed != null && state.updateFlags.containsKey(item.installed!.identity); } } @@ -994,11 +977,28 @@ class _ExtensionCard extends ConsumerWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - item.title, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle(color: palette.textPrimary, fontSize: 14, fontWeight: FontWeight.w700), + Row( + children: [ + Flexible( + child: Text( + item.title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle(color: palette.textPrimary, fontSize: 14, fontWeight: FontWeight.w700), + ), + ), + if (canUpdate && installed != null) ...[ + const SizedBox(width: 4), + Flexible( + child: ExtensionUpdateStatus( + key: ValueKey('extension-card-update-status-${item.id}'), + label: context.l10n.extensionCanUpdate, + onTap: busy ? null : () => showExtensionUpdateDialog(context, installed), + compact: true, + ), + ), + ], + ], ), const SizedBox(height: 2), Text( @@ -1067,13 +1067,6 @@ class _ExtensionCard extends ConsumerWidget { key: ValueKey('extension-card-actions-${item.id}'), mainAxisSize: MainAxisSize.min, children: [ - if (canUpdate && installed != null) - shad.GhostButton( - density: shad.ButtonDensity.icon, - key: ValueKey('update-extension-${installed.identity}'), - onPressed: busy ? null : () => showExtensionUpdateDialog(context, installed), - child: const Icon(Icons.refresh), - ), if (installed == null && item.store != null) shad.GhostButton( key: ValueKey('install-store-extension-${item.store!.id}'), @@ -1127,21 +1120,17 @@ class _ExtensionCard extends ConsumerWidget { ], ), ); - if (item.store == null) return card; - return MouseRegion( - cursor: SystemMouseCursors.click, - child: GestureDetector( - key: ValueKey('open-extension-details-${item.id}'), - behavior: HitTestBehavior.opaque, - onTap: () { - if (MediaQuery.sizeOf(context).width < Breakpoints.mobile) { - context.push('/extensions/${Uri.encodeComponent(item.id)}', extra: item); - return; - } - onOpenDetails(item); - }, - child: card, - ), + return GestureDetector( + key: ValueKey('open-extension-details-${item.id}'), + behavior: HitTestBehavior.opaque, + onTap: () { + if (MediaQuery.sizeOf(context).width < Breakpoints.mobile) { + context.push('/extensions/${Uri.encodeComponent(item.id)}', extra: item); + return; + } + onOpenDetails(item); + }, + child: card, ); } } 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 87837560..7acf0c65 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,33 @@ 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); + final updateChip = canUpdate + ? ExtensionUpdateStatus( + key: const ValueKey('extension-details-update'), + label: context.l10n.extensionCanUpdate, + onTap: () => showExtensionUpdateDialog(context, installed), + ) + : null; return AppDetailDrawer( open: item != null, title: item?.title ?? '', onClose: onClose, drawerKey: const ValueKey('extension-details-drawer'), + titleTrailing: updateChip, child: item == null ? const SizedBox.shrink() : ExtensionDetailView(item: item!), ); } @@ -104,8 +117,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), ], ), ], @@ -130,15 +141,6 @@ class ExtensionDetailView extends ConsumerWidget { _runAction(context, () => ref.read(extensionsControllerProvider.notifier).installFromStore(store)), child: Text(context.l10n.extensionInstall), ), - if (installed != null && canUpdate) - AppLoadingButton( - key: const ValueKey('extension-details-update'), - loading: false, - variant: AppLoadingButtonVariant.primary, - icon: const Icon(Icons.refresh, size: 16), - onPressed: busy ? null : () => showExtensionUpdateDialog(context, installed), - child: Text(context.l10n.newVersionUpdate), - ), if (installed != null && !canUpdate) AppLoadingButton( key: const ValueKey('extension-details-installed'), @@ -162,7 +164,13 @@ class ExtensionDetailView extends ConsumerWidget { ), ], ), - if (store != null) ...[SizedBox(height: mobile ? 24 : 28), _ExtensionReadme(item: current, mobile: mobile)], + SizedBox(height: mobile ? 24 : 28), + _ExtensionReadme( + // Snapshot the version so upgrades reload the installed README. + key: ValueKey((current.id, installed?.version, installed?.devMode, installed?.devPath, store?.readme)), + item: current, + mobile: mobile, + ), ], ); } @@ -180,7 +188,7 @@ class ExtensionDetailView extends ConsumerWidget { } class _ExtensionReadme extends StatefulWidget { - const _ExtensionReadme({required this.item, required this.mobile}); + const _ExtensionReadme({super.key, required this.item, required this.mobile}); final ExtensionListItem item; final bool mobile; @@ -190,7 +198,7 @@ class _ExtensionReadme extends StatefulWidget { } class _ExtensionReadmeState extends State<_ExtensionReadme> { - late Future<_ReadmeInfo> _readme; + late final _ReadmeInfo _readme; @override void initState() { @@ -198,109 +206,48 @@ class _ExtensionReadmeState extends State<_ExtensionReadme> { _readme = _loadReadme(widget.item); } - @override - void didUpdateWidget(covariant _ExtensionReadme oldWidget) { - super.didUpdateWidget(oldWidget); - final previous = oldWidget.item.installed; - final current = widget.item.installed; - if (previous?.identity != current?.identity || - previous?.devMode != current?.devMode || - previous?.devPath != current?.devPath || - oldWidget.item.store?.readme != widget.item.store?.readme) { - _readme = _loadReadme(widget.item); - } - } - @override Widget build(BuildContext context) { final palette = AppPalette.of(context); - return FutureBuilder<_ReadmeInfo>( - future: _readme, - builder: (context, snapshot) { - final info = snapshot.data; - if (info == null) { - return const SizedBox.shrink(); - } - if (info.content.trim().isEmpty) { - return const SizedBox.shrink(); - } - return Padding( - key: const ValueKey('extension-details-readme'), - padding: EdgeInsets.only(top: widget.mobile ? 32 : 36), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'README', - style: TextStyle( - color: palette.textMuted, - fontSize: 10.5, - fontWeight: FontWeight.w800, - letterSpacing: 1.2, - ), - ), - const SizedBox(height: 16), - MarkdownBody( - data: info.content, - selectable: true, - styleSheet: _markdownStyle(palette, mobile: widget.mobile), - onTapLink: (_, href, _) { - final resolved = _resolveReadmeUrl(widget.item, href, info: info, forImage: false); - if (resolved != null) unawaited(_openUrl(resolved)); - }, - imageBuilder: (uri, title, alt) { - final resolved = _resolveReadmeUrl(widget.item, uri.toString(), info: info, forImage: true); - if (resolved == null) return const SizedBox.shrink(); - final image = resolved.startsWith('file:') - ? Image.file( - File(Uri.parse(resolved).toFilePath()), - fit: BoxFit.contain, - errorBuilder: (_, _, _) => const SizedBox.shrink(), - ) - : Image.network( - resolved, - fit: BoxFit.contain, - errorBuilder: (_, _, _) => const SizedBox.shrink(), - ); - return Padding( - padding: const EdgeInsets.symmetric(vertical: 10), - child: ClipRRect(borderRadius: BorderRadius.circular(6), child: image), - ); - }, - ), - ], + final info = _readme; + if (info.content.trim().isEmpty) return const SizedBox.shrink(); + return Padding( + key: const ValueKey('extension-details-readme'), + padding: EdgeInsets.only(top: widget.mobile ? 32 : 36), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'README', + style: TextStyle(color: palette.textMuted, fontSize: 10.5, fontWeight: FontWeight.w800, letterSpacing: 1.2), ), - ); - }, - ); - } -} - -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), - ), - ], + const SizedBox(height: 16), + MarkdownBody( + data: info.content, + selectable: true, + styleSheet: _markdownStyle(palette, mobile: widget.mobile), + onTapLink: (_, href, _) { + final resolved = _resolveReadmeUrl(widget.item, href, info: info, forImage: false); + if (resolved != null) unawaited(_openUrl(resolved)); + }, + imageBuilder: (uri, title, alt) { + final resolved = _resolveReadmeUrl(widget.item, uri.toString(), info: info, forImage: true); + if (resolved == null) return const SizedBox.shrink(); + final image = resolved.startsWith('file:') + ? Image.file( + File(Uri.parse(resolved).toFilePath()), + fit: BoxFit.contain, + errorBuilder: (_, _, _) => const SizedBox.shrink(), + ) + : Image.network(resolved, fit: BoxFit.contain, errorBuilder: (_, _, _) => const SizedBox.shrink()); + return Padding( + padding: const EdgeInsets.symmetric(vertical: 10), + child: ClipRRect(borderRadius: BorderRadius.circular(6), child: image), + ); + }, + ), + ], + ), ); } } @@ -407,7 +354,7 @@ MarkdownStyleSheet _markdownStyle(AppPalette palette, {required bool mobile}) { ); } -Future<_ReadmeInfo> _loadReadme(ExtensionListItem item) async { +_ReadmeInfo _loadReadme(ExtensionListItem item) { final installed = item.installed; final remote = item.store?.readme ?? ''; if (installed == null || Util.isWeb()) return _ReadmeInfo(content: remote); diff --git a/ui/flutter/lib/features/extensions/presentation/widgets/extension_update_dialog.dart b/ui/flutter/lib/features/extensions/presentation/widgets/extension_update_dialog.dart index 107a46bc..605f5445 100644 --- a/ui/flutter/lib/features/extensions/presentation/widgets/extension_update_dialog.dart +++ b/ui/flutter/lib/features/extensions/presentation/widgets/extension_update_dialog.dart @@ -58,7 +58,7 @@ class _ExtensionUpdateDialogState extends ConsumerState<_ExtensionUpdateDialog> canPop: !_updating, child: AlertDialog( key: const ValueKey('extension-update-dialog'), - title: Text(context.l10n.extensionCanUpdate), + title: Text(context.l10n.newVersionAvailable), content: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, 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..f1382b12 --- /dev/null +++ b/ui/flutter/lib/features/extensions/presentation/widgets/extension_update_status.dart @@ -0,0 +1,59 @@ +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, this.compact = false}); + + final String? label; + final VoidCallback? onTap; + final bool compact; + + @override + Widget build(BuildContext context) { + final palette = AppPalette.of(context); + final content = Container( + padding: EdgeInsets.symmetric(horizontal: 8, vertical: compact ? 2 : 3), + decoration: BoxDecoration( + color: palette.brandSoft, + borderRadius: BorderRadius.circular(999), + border: Border.all(color: palette.brand.withValues(alpha: 0.35)), + ), + child: 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), + Flexible( + child: Text( + label!, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle(color: palette.textPrimary, fontSize: 11.5, fontWeight: FontWeight.w600), + ), + ), + ], + ], + ), + ); + if (onTap == null) return content; + return MouseRegion( + cursor: SystemMouseCursors.click, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: onTap, + child: Padding( + padding: EdgeInsets.symmetric(horizontal: 2, vertical: compact ? 2 : 4), + child: content, + ), + ), + ); + } +} diff --git a/ui/flutter/lib/l10n/app_ar.arb b/ui/flutter/lib/l10n/app_ar.arb index f25ea37b..1fcbf1ab 100644 --- a/ui/flutter/lib/l10n/app_ar.arb +++ b/ui/flutter/lib/l10n/app_ar.arb @@ -99,7 +99,7 @@ "add": "إضافة", "edit": "تعديل", "newVersionTitle": "يتوفر إصدار جديد @version", - "newVersionUpdate": "تحديث الآن", + "newVersionUpdate": "تحديث", "newVersionLater": "لاحقاً", "extensions": "الإضافات", "extensionInstallUrl": "عنوان التثبيت", diff --git a/ui/flutter/lib/l10n/app_az.arb b/ui/flutter/lib/l10n/app_az.arb index 4a74c52f..f1f09732 100644 --- a/ui/flutter/lib/l10n/app_az.arb +++ b/ui/flutter/lib/l10n/app_az.arb @@ -106,7 +106,7 @@ "add": "Əlavə et", "edit": "Redaktə et", "newVersionTitle": "Yeni versiya @version tapıldı", - "newVersionUpdate": "İndi Yenilə", + "newVersionUpdate": "Yenilə", "newVersionLater": "Sonra", "extensions": "Uzantılar", "extensionInstallUrl": "Quraşdırma URL-i", diff --git a/ui/flutter/lib/l10n/app_ca.arb b/ui/flutter/lib/l10n/app_ca.arb index a1ddc641..0bad8028 100644 --- a/ui/flutter/lib/l10n/app_ca.arb +++ b/ui/flutter/lib/l10n/app_ca.arb @@ -99,7 +99,7 @@ "add": "Afegeix", "edit": "Edita", "newVersionTitle": "Descobriu la nova versió @version", - "newVersionUpdate": "Actualitza ara", + "newVersionUpdate": "Actualitza", "newVersionLater": "Més tard", "extensions": "Extensions", "extensionInstallUrl": "URL d'instal·lació", diff --git a/ui/flutter/lib/l10n/app_de.arb b/ui/flutter/lib/l10n/app_de.arb index 81f3fc7f..ac742216 100644 --- a/ui/flutter/lib/l10n/app_de.arb +++ b/ui/flutter/lib/l10n/app_de.arb @@ -99,7 +99,7 @@ "add": "Hinzugefügt", "edit": "Bearbeiten", "newVersionTitle": "Entdecken Sie die neue Version @version", - "newVersionUpdate": "Jetzt aktualisieren", + "newVersionUpdate": "Aktualisieren", "newVersionLater": "Später", "extensions": "Erweiterungen", "extensionInstallUrl": "Installations-URL", diff --git a/ui/flutter/lib/l10n/app_en.arb b/ui/flutter/lib/l10n/app_en.arb index 883bed0c..8eac5fdd 100644 --- a/ui/flutter/lib/l10n/app_en.arb +++ b/ui/flutter/lib/l10n/app_en.arb @@ -106,7 +106,7 @@ "add": "Add", "edit": "Edit", "newVersionTitle": "Discover new version @version", - "newVersionUpdate": "Update Now", + "newVersionUpdate": "Update", "newVersionLater": "Later", "extensions": "Extensions", "extensionInstallUrl": "Install URL", @@ -137,7 +137,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/l10n/app_es.arb b/ui/flutter/lib/l10n/app_es.arb index 002c8597..9c6cd25e 100644 --- a/ui/flutter/lib/l10n/app_es.arb +++ b/ui/flutter/lib/l10n/app_es.arb @@ -99,7 +99,7 @@ "add": "Añadir", "edit": "Editar", "newVersionTitle": "Nueva versión @version disponible", - "newVersionUpdate": "Actualizar Ahora", + "newVersionUpdate": "Actualizar", "newVersionLater": "Más tarde", "extensions": "Extensiones", "extensionInstallUrl": "URL de instalación", diff --git a/ui/flutter/lib/l10n/app_fa.arb b/ui/flutter/lib/l10n/app_fa.arb index 59a17a59..31db8a2c 100644 --- a/ui/flutter/lib/l10n/app_fa.arb +++ b/ui/flutter/lib/l10n/app_fa.arb @@ -99,7 +99,7 @@ "add": "افزودن", "edit": "ویرایش", "newVersionTitle": "@version عنوان: کشف نسخه جدید", - "newVersionUpdate": "بروزرسانی", + "newVersionUpdate": "به‌روزرسانی", "newVersionLater": "بعداً", "extensions": "افزونه‌ها", "extensionInstallUrl": "نشانی نصب", diff --git a/ui/flutter/lib/l10n/app_fr.arb b/ui/flutter/lib/l10n/app_fr.arb index 66274439..6d609842 100644 --- a/ui/flutter/lib/l10n/app_fr.arb +++ b/ui/flutter/lib/l10n/app_fr.arb @@ -99,7 +99,7 @@ "add": "Ajouter", "edit": "Modifier", "newVersionTitle": "Nouvelle version @version disponible", - "newVersionUpdate": "Mettre à jour maintenant", + "newVersionUpdate": "Mettre à jour", "newVersionLater": "Plus tard", "extensions": "Extensions", "extensionInstallUrl": "URL d'installation", diff --git a/ui/flutter/lib/l10n/app_hu.arb b/ui/flutter/lib/l10n/app_hu.arb index d0fe3092..ca5382cc 100644 --- a/ui/flutter/lib/l10n/app_hu.arb +++ b/ui/flutter/lib/l10n/app_hu.arb @@ -99,7 +99,7 @@ "add": "Hozzáad", "edit": "Szerk", "newVersionTitle": "Új verzió felfedezése @version", - "newVersionUpdate": "Frissítés most", + "newVersionUpdate": "Frissítés", "newVersionLater": "Később", "extensions": "Bővítmények", "extensionInstallUrl": "Telepítési URL", diff --git a/ui/flutter/lib/l10n/app_id.arb b/ui/flutter/lib/l10n/app_id.arb index 9ad8eddd..8c1fa043 100644 --- a/ui/flutter/lib/l10n/app_id.arb +++ b/ui/flutter/lib/l10n/app_id.arb @@ -99,7 +99,7 @@ "add": "Tambah", "edit": "Sunting", "newVersionTitle": "Temukan versi batu @version", - "newVersionUpdate": "Perbarui Sekarang", + "newVersionUpdate": "Perbarui", "newVersionLater": "Nanti", "extensions": "Ekstensi", "extensionInstallUrl": "URL Instal", diff --git a/ui/flutter/lib/l10n/app_it.arb b/ui/flutter/lib/l10n/app_it.arb index 0500ce26..cc655095 100644 --- a/ui/flutter/lib/l10n/app_it.arb +++ b/ui/flutter/lib/l10n/app_it.arb @@ -99,7 +99,7 @@ "add": "Aggiungi", "edit": "Modifica", "newVersionTitle": "Scopri la nuova versione @version", - "newVersionUpdate": "Aggiorna ora", + "newVersionUpdate": "Aggiorna", "newVersionLater": "Dopo", "extensions": "Estensioni", "extensionInstallUrl": "Installa URL", diff --git a/ui/flutter/lib/l10n/app_ja.arb b/ui/flutter/lib/l10n/app_ja.arb index 2544161c..f51400c4 100644 --- a/ui/flutter/lib/l10n/app_ja.arb +++ b/ui/flutter/lib/l10n/app_ja.arb @@ -99,7 +99,7 @@ "add": "追加", "edit": "編集", "newVersionTitle": "新しいバージョン @version を発見する", - "newVersionUpdate": "アップデート", + "newVersionUpdate": "更新", "newVersionLater": "後で", "extensions": "拡張機能", "extensionInstallUrl": "インストールURL", diff --git a/ui/flutter/lib/l10n/app_ko.arb b/ui/flutter/lib/l10n/app_ko.arb index 504b25b0..fc5db70a 100644 --- a/ui/flutter/lib/l10n/app_ko.arb +++ b/ui/flutter/lib/l10n/app_ko.arb @@ -99,7 +99,7 @@ "add": "추가", "edit": "편집", "newVersionTitle": "새 버전 @version 발견", - "newVersionUpdate": "지금 업데이트", + "newVersionUpdate": "업데이트", "newVersionLater": "나중에", "extensions": "확장 프로그램", "extensionInstallUrl": "설치 URL", diff --git a/ui/flutter/lib/l10n/app_pl.arb b/ui/flutter/lib/l10n/app_pl.arb index 4d512aa4..d5f513ee 100644 --- a/ui/flutter/lib/l10n/app_pl.arb +++ b/ui/flutter/lib/l10n/app_pl.arb @@ -99,7 +99,7 @@ "add": "Dodaj", "edit": "Edycja", "newVersionTitle": "Sprawdź aktualizację @version", - "newVersionUpdate": "Aktualizuj teraz", + "newVersionUpdate": "Aktualizuj", "newVersionLater": "Później", "extensions": "Wtyczki", "extensionInstallUrl": "Instaluj z url", diff --git a/ui/flutter/lib/l10n/app_pt.arb b/ui/flutter/lib/l10n/app_pt.arb index f93da09e..a307d075 100644 --- a/ui/flutter/lib/l10n/app_pt.arb +++ b/ui/flutter/lib/l10n/app_pt.arb @@ -99,7 +99,7 @@ "add": "Adicionar", "edit": "Editar", "newVersionTitle": "Nova Versão Disponível @version", - "newVersionUpdate": "Atualizar Agora", + "newVersionUpdate": "Atualizar", "newVersionLater": "Mais Tarde", "extensions": "Extensões", "extensionInstallUrl": "Link de Instalação", diff --git a/ui/flutter/lib/l10n/app_ta.arb b/ui/flutter/lib/l10n/app_ta.arb index 04193bd1..80eb3201 100644 --- a/ui/flutter/lib/l10n/app_ta.arb +++ b/ui/flutter/lib/l10n/app_ta.arb @@ -99,7 +99,7 @@ "add": "சேர்", "edit": "திருத்துக", "newVersionTitle": "புதிய பதிப்பைக் கண்டறியவும் @version", - "newVersionUpdate": "இப்பொழுது புதுப்பி", + "newVersionUpdate": "புதுப்பிப்பு", "newVersionLater": "பின்னர்", "extensions": "நீட்டிப்புகள்", "extensionInstallUrl": "நிறுவப்படும் URL", diff --git a/ui/flutter/lib/l10n/app_tr.arb b/ui/flutter/lib/l10n/app_tr.arb index 7c01725b..2a0e4df5 100644 --- a/ui/flutter/lib/l10n/app_tr.arb +++ b/ui/flutter/lib/l10n/app_tr.arb @@ -99,7 +99,7 @@ "add": "Ekle", "edit": "Düzenle", "newVersionTitle": "Yeni sürüm @version keşfedildi", - "newVersionUpdate": "Şimdi Güncelle", + "newVersionUpdate": "Güncelle", "newVersionLater": "Daha Sonra", "extensions": "Eklentiler", "extensionInstallUrl": "Kurulum URL'si", diff --git a/ui/flutter/lib/l10n/app_uk.arb b/ui/flutter/lib/l10n/app_uk.arb index 65fc23a9..eac67523 100644 --- a/ui/flutter/lib/l10n/app_uk.arb +++ b/ui/flutter/lib/l10n/app_uk.arb @@ -99,7 +99,7 @@ "add": "Додати", "edit": "Редагування", "newVersionTitle": "Доступна нова версія @Version", - "newVersionUpdate": "Оновити зараз", + "newVersionUpdate": "Оновити", "newVersionLater": "Пізніше", "extensions": "Розширення", "extensionInstallUrl": "URL для встановлення", diff --git a/ui/flutter/lib/l10n/app_vi.arb b/ui/flutter/lib/l10n/app_vi.arb index 8cbd8393..81224d99 100644 --- a/ui/flutter/lib/l10n/app_vi.arb +++ b/ui/flutter/lib/l10n/app_vi.arb @@ -99,7 +99,7 @@ "add": "Thêm", "edit": "Sửa", "newVersionTitle": "Khám phá phiên bản mới @version", - "newVersionUpdate": "Cập nhật ngay", + "newVersionUpdate": "Cập nhật", "newVersionLater": "Sau", "extensions": "Tiện ích mở rộng", "extensionInstallUrl": "Liên kết cài đặt", diff --git a/ui/flutter/lib/l10n/app_zh.arb b/ui/flutter/lib/l10n/app_zh.arb index fed70a8d..4d586e2f 100644 --- a/ui/flutter/lib/l10n/app_zh.arb +++ b/ui/flutter/lib/l10n/app_zh.arb @@ -99,7 +99,7 @@ "add": "添加", "edit": "编辑", "newVersionTitle": "发现新版本 @version", - "newVersionUpdate": "立即更新", + "newVersionUpdate": "更新", "newVersionLater": "稍后再说", "extensions": "扩展", "extensionInstallUrl": "安装链接", diff --git a/ui/flutter/lib/l10n/app_zh_TW.arb b/ui/flutter/lib/l10n/app_zh_TW.arb index 9d8a4b94..b903a205 100644 --- a/ui/flutter/lib/l10n/app_zh_TW.arb +++ b/ui/flutter/lib/l10n/app_zh_TW.arb @@ -99,7 +99,7 @@ "add": "新增", "edit": "編輯", "newVersionTitle": "發現新版本 @version", - "newVersionUpdate": "立即更新", + "newVersionUpdate": "更新", "newVersionLater": "稍後", "extensions": "擴充功能", "extensionInstallUrl": "安裝 URL", 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..509248d8 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,25 @@ 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), + Flexible(child: 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..cc807e68 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,22 @@ 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), + Flexible(child: titleTrailing!), + ], + ], ), ), const SizedBox(width: 44), diff --git a/ui/flutter/test/features/extensions/presentation/widgets/extension_readme_test.dart b/ui/flutter/test/features/extensions/presentation/widgets/extension_readme_test.dart new file mode 100644 index 00000000..67161b28 --- /dev/null +++ b/ui/flutter/test/features/extensions/presentation/widgets/extension_readme_test.dart @@ -0,0 +1,162 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter/widgets.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:gopeed/api/model/extension.dart'; +import 'package:gopeed/api/model/store_extension.dart'; +import 'package:gopeed/features/extensions/application/extensions_controller.dart'; +import 'package:gopeed/features/extensions/presentation/pages/extension_details_page.dart'; +import 'package:gopeed/features/extensions/presentation/widgets/extension_detail_view.dart'; +import 'package:gopeed/shared/theme/app_theme.dart'; +import 'package:path/path.dart' as path; +import 'package:shadcn_flutter/shadcn_flutter.dart' as shad; + +void main() { + for (final mobile in [false, true]) { + testWidgets('local README renders while the market request is pending (mobile: $mobile)', (tester) async { + final directory = Directory.systemTemp.createTempSync('gopeed-readme-'); + addTearDown(() => directory.deleteSync(recursive: true)); + File(path.join(directory.path, 'README.md')).writeAsStringSync('# Local README'); + final installed = _installed(directory.path); + final controller = _ReadmeController( + ExtensionsState(installedExtensions: [installed], loadingStore: true), + pendingMarket: true, + ); + await _pumpDetails(tester, controller, ExtensionListItem(installed: installed), mobile: mobile); + + expect(controller.marketRequest.isCompleted, isFalse); + expect(find.text('Local README'), findsOneWidget); + expect(tester.takeException(), isNull); + + // A stale market response must not replace the installed documentation. + controller.finishMarket(); + await tester.pumpAndSettle(); + expect(find.text('Local README'), findsOneWidget); + expect(find.text('Old market README'), findsNothing); + }); + + for (final mutate in [false, true]) { + testWidgets('open README reloads after an upgrade (mobile: $mobile, mutate: $mutate)', (tester) async { + final directory = Directory.systemTemp.createTempSync('gopeed-readme-'); + addTearDown(() => directory.deleteSync(recursive: true)); + final readme = File(path.join(directory.path, 'README.md'))..writeAsStringSync('# Before upgrade'); + final installed = _installed(directory.path); + final controller = _ReadmeController( + ExtensionsState(installedExtensions: [installed], storeExtensions: [_store()]), + ); + await _pumpDetails( + tester, + controller, + ExtensionListItem(installed: installed, store: _store()), + mobile: mobile, + ); + expect(find.text('Before upgrade'), findsOneWidget); + + readme.writeAsStringSync('# After upgrade'); + controller.publishVersion('2.0.0', mutate: mutate); + await tester.pumpAndSettle(); + + expect(find.text('After upgrade'), findsOneWidget); + expect(find.text('Before upgrade'), findsNothing); + expect(find.text('Old market README'), findsNothing); + expect(tester.takeException(), isNull); + }); + } + } + + testWidgets('missing local README falls back to the store copy', (tester) async { + final directory = Directory.systemTemp.createTempSync('gopeed-readme-'); + addTearDown(() => directory.deleteSync(recursive: true)); + final installed = _installed(directory.path); + final controller = _ReadmeController( + ExtensionsState(installedExtensions: [installed], storeExtensions: [_store()]), + ); + await _pumpDetails(tester, controller, ExtensionListItem(installed: installed, store: _store()), mobile: false); + expect(find.text('Old market README'), findsOneWidget); + expect(tester.takeException(), isNull); + }); +} + +Future _pumpDetails( + WidgetTester tester, + _ReadmeController controller, + ExtensionListItem item, { + required bool mobile, +}) async { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = Size(mobile ? 390 : 1100, 900); + addTearDown(tester.view.resetDevicePixelRatio); + addTearDown(tester.view.resetPhysicalSize); + await tester.pumpWidget( + ProviderScope( + overrides: [extensionsControllerProvider.overrideWith(() => controller)], + child: shad.ShadcnApp( + theme: AppTheme.light(), + materialTheme: AppTheme.materialLight(), + home: mobile + ? ExtensionDetailsPage(extensionId: item.id, initialItem: item) + : ExtensionDetailDrawer(item: item, onClose: () {}), + ), + ), + ); + await tester.pumpAndSettle(); +} + +class _ReadmeController extends ExtensionsController { + _ReadmeController(this.initial, {this.pendingMarket = false}); + + final ExtensionsState initial; + final bool pendingMarket; + final marketRequest = Completer(); + + @override + Future build() async { + if (!pendingMarket) return initial; + state = AsyncValue.data(initial); + return marketRequest.future; + } + + void finishMarket() { + marketRequest.complete(state.requireValue.copyWith(storeExtensions: [_store()], loadingStore: false)); + } + + void publishVersion(String version, {required bool mutate}) { + final current = state.requireValue; + final installed = current.installedExtensions.single; + final updated = mutate ? installed : Extension.fromJson(installed.toJson()); + updated.version = version; + state = AsyncValue.data(current.copyWith(installedExtensions: [updated])); + } +} + +Extension _installed(String directory) => Extension( + identity: 'gopeed@test', + name: 'test', + author: 'Gopeed', + title: 'Test extension', + description: 'Installed extension', + icon: '', + version: '1.0.0', + homepage: '', + repository: null, + disabled: false, + devMode: true, + devPath: directory, +); + +StoreExtension _store() => StoreExtension( + id: 'gopeed@test', + repoFullName: 'GopeedLab/test', + repoUrl: 'https://github.com/GopeedLab/test', + name: 'test', + author: 'Gopeed', + title: 'Test extension', + description: 'Store extension', + version: '1.0.0', + readme: '# Old market README', + installCount: 1, + stars: 1, + topics: const [], +); diff --git a/ui/flutter/test/widget_test.dart b/ui/flutter/test/widget_test.dart index 3365a275..2d7ae42f 100644 --- a/ui/flutter/test/widget_test.dart +++ b/ui/flutter/test/widget_test.dart @@ -50,6 +50,7 @@ import 'package:gopeed/features/home/presentation/widgets/tasks_top_bar.dart'; import 'package:gopeed/features/extensions/application/extensions_controller.dart'; import 'package:gopeed/features/extensions/presentation/pages/extension_details_page.dart'; import 'package:gopeed/features/extensions/presentation/pages/extensions_page.dart'; +import 'package:gopeed/features/extensions/presentation/widgets/extension_icon.dart'; import 'package:gopeed/features/home/presentation/widgets/primary_rail.dart'; import 'package:gopeed/features/tasks/application/pending_update_task.dart'; import 'package:gopeed/features/tasks/application/task_batch_selection_controller.dart'; @@ -688,6 +689,30 @@ void main() { expect(tester.takeException(), isNull); }); + for (final width in [320.0, 1100.0]) { + testWidgets('extension update status keeps the card header at icon height at $width', (tester) async { + await _setTestSize(tester, Size(width, 900)); + await tester.pumpWidget( + ProviderScope( + overrides: [extensionsControllerProvider.overrideWith(UpdateableExtensionsController.new)], + child: shad.ShadcnApp( + theme: AppTheme.light(), + materialTheme: AppTheme.materialLight(), + home: const ExtensionsPage(), + ), + ), + ); + await tester.pumpAndSettle(); + + final card = find.byKey(const ValueKey('extension-card-extension-0')); + final icon = find.descendant(of: card, matching: find.byType(ExtensionIcon)); + final description = find.descendant(of: card, matching: find.text('Installed extension')); + expect(find.byKey(const ValueKey('extension-card-update-status-extension-0')), findsOneWidget); + expect(tester.getRect(description).top - tester.getRect(icon).bottom, closeTo(10, 0.01)); + 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(); @@ -817,7 +842,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')); @@ -2327,7 +2354,7 @@ void main() { final progress = find.descendant(of: updateButton, matching: find.byType(shad.CircularProgressIndicator)); expect(progress, findsOneWidget); expect(tester.getSize(progress), const Size.square(14)); - expect(find.descendant(of: updateButton, matching: find.text('Update Now')), findsOneWidget); + expect(find.descendant(of: updateButton, matching: find.text('Update')), findsOneWidget); runtimeController.completeTrackerUpdate(); await tester.pumpAndSettle();