From 603d68386112304dd9b9cdadc0b6be1279d7c2ee Mon Sep 17 00:00:00 2001 From: franzap <_@franzap.com> Date: Thu, 26 Mar 2026 20:50:00 -0300 Subject: [PATCH] Query fine tuning, load release in parallel --- lib/screens/app_detail_screen.dart | 292 ++++++++++------------------ lib/utils/app_query.dart | 45 ++++- lib/utils/extensions.dart | 14 +- lib/widgets/app_detail_widgets.dart | 26 +++ 4 files changed, 168 insertions(+), 209 deletions(-) diff --git a/lib/screens/app_detail_screen.dart b/lib/screens/app_detail_screen.dart index 4375145..92151c8 100644 --- a/lib/screens/app_detail_screen.dart +++ b/lib/screens/app_detail_screen.dart @@ -44,12 +44,8 @@ class AppDetailScreen extends HookConsumerWidget { }, limit: 1, and: (a) => { - a.latestRelease.query( - and: (release) => { - release.latestMetadata.query(), - release.latestAsset.query(), - }, - ), + a.latestAsset.query(), + a.latestRelease.query(), }, source: const LocalAndRemoteSource(relays: 'AppCatalog', stream: false), subscriptionPrefix: 'app-detail-$appId', @@ -66,12 +62,8 @@ class AppDetailScreen extends HookConsumerWidget { }, limit: 1, and: (a) => { - a.latestRelease.query( - and: (release) => { - release.latestMetadata.query(), - release.latestAsset.query(), - }, - ), + a.latestAsset.query(), + a.latestRelease.query(), }, source: const LocalAndRemoteSource(relays: 'AppCatalog', stream: false), subscriptionPrefix: 'app-detail-$appId', @@ -95,16 +87,15 @@ class AppDetailScreen extends HookConsumerWidget { }, ); - // Handle loading/error states when app not yet available - final isLoading = appState is StorageLoading; final app = appState.models.firstOrNull; if (appState case StorageError(:final exception)) { return _ErrorScaffold(message: exception.toString()); } - // Show skeleton only if loading with no models yet - if (isLoading && app == null) { + if (app == null) { + // StorageLoading with no models yet: show skeleton + // StorageData with no models: app not found (handled by ref.listen pop) return const Scaffold( body: SafeArea( child: SingleChildScrollView( @@ -115,11 +106,7 @@ class AppDetailScreen extends HookConsumerWidget { ); } - return _AppDetailContent( - app: app, - appState: appState, - isLoading: isLoading, - ); + return _AppDetailContent(app: app); } } @@ -150,22 +137,12 @@ class _ErrorScaffold extends StatelessWidget { /// Internal widget that displays app details class _AppDetailContent extends HookConsumerWidget { - final App? app; - final StorageState appState; - final bool isLoading; + final App app; - const _AppDetailContent({ - required this.app, - required this.appState, - required this.isLoading, - }); + const _AppDetailContent({required this.app}); @override Widget build(BuildContext context, WidgetRef ref) { - final app = this.app; - if (app == null) { - return const _ErrorScaffold(message: 'App not found'); - } final signedInPubkey = ref.watch(Signer.activePubkeyProvider); final isSignedIn = signedInPubkey != null; @@ -182,10 +159,7 @@ class _AppDetailContent extends HookConsumerWidget { subscriptionPrefix: 'app-detail-profile', ), ); - final author = switch (authorState) { - StorageData(:final models) => models.firstOrNull, - _ => null, - }; + final author = authorState.models.firstOrNull; final latestRelease = app.latestRelease.value; final latestMetadata = app.installable; @@ -196,29 +170,6 @@ class _AppDetailContent extends HookConsumerWidget { ); final isInstalled = installedPackage != null; - // Show skeleton while relationships are loading - // Always show loading indicator here - relationships are still pending - if (latestRelease == null || latestMetadata == null) { - return Scaffold( - body: SafeArea( - child: Stack( - children: [ - const SingleChildScrollView( - padding: EdgeInsets.only(top: 16, bottom: 80), - child: Padding( - padding: EdgeInsets.symmetric(horizontal: 16), - child: AppDetailSkeleton(), - ), - ), - InstallButton(app: app, release: latestRelease), - _buildFloatingMenu(context, ref, app, isInstalled, isSignedIn), - _buildLoadingIndicator(context), - ], - ), - ), - ); - } - return Scaffold( body: SafeArea( child: Stack( @@ -235,7 +186,7 @@ class _AppDetailContent extends HookConsumerWidget { ), // Published by / Released at section - if (app.isRelaySigned) + if (app.isRelaySigned && latestMetadata != null) Padding( padding: const EdgeInsets.only( left: 16, @@ -322,98 +273,103 @@ class _AppDetailContent extends HookConsumerWidget { ), ), - // Latest release section - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const SizedBox(height: 12), - Row( - children: [ - Expanded( - child: Container( - height: 1, - color: Theme.of( - context, - ).colorScheme.onSurface.withValues(alpha: 0.2), - ), - ), - Padding( - padding: const EdgeInsets.symmetric( - horizontal: 16, - ), - child: Text( - 'LATEST RELEASE', - style: context.textTheme.labelLarge?.copyWith( - color: Theme.of(context).colorScheme.onSurface - .withValues(alpha: 0.85), - letterSpacing: 1.5, - fontWeight: FontWeight.bold, - ), - ), - ), - Expanded( - child: Container( - height: 1, - color: Theme.of( - context, - ).colorScheme.onSurface.withValues(alpha: 0.2), - ), - ), - ], - ), - const SizedBox(height: 16), - Container( - padding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 8, - ), - decoration: BoxDecoration( - color: Theme.of( - context, - ).colorScheme.onSurface.withValues(alpha: 0.05), - borderRadius: BorderRadius.circular(8), - ), - child: Row( - mainAxisSize: MainAxisSize.min, + // Latest release section — shown as soon as version is known + if (latestMetadata != null) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 12), + Row( children: [ - Text( - 'Version:', - style: context.textTheme.bodyMedium, - ), - Gap(4), - Text( - latestMetadata.version, - style: context.textTheme.bodyMedium?.copyWith( - fontWeight: FontWeight.bold, + Expanded( + child: Container( + height: 1, + color: Theme.of( + context, + ).colorScheme.onSurface.withValues(alpha: 0.2), ), ), - Gap(4), - Text( - '(${formatDate(latestRelease.createdAt)})', - style: context.textTheme.bodyMedium?.copyWith( - color: Theme.of(context).colorScheme.onSurface - .withValues(alpha: 0.6), + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16, + ), + child: Text( + 'LATEST RELEASE', + style: context.textTheme.labelLarge?.copyWith( + color: Theme.of(context).colorScheme.onSurface + .withValues(alpha: 0.85), + letterSpacing: 1.5, + fontWeight: FontWeight.bold, + ), + ), + ), + Expanded( + child: Container( + height: 1, + color: Theme.of( + context, + ).colorScheme.onSurface.withValues(alpha: 0.2), ), ), ], ), - ), - Padding( - padding: const EdgeInsets.symmetric(vertical: 20), - child: ReleaseNotes(release: latestRelease), - ), - ], + const SizedBox(height: 16), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 8, + ), + decoration: BoxDecoration( + color: Theme.of( + context, + ).colorScheme.onSurface.withValues(alpha: 0.05), + borderRadius: BorderRadius.circular(8), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'Version:', + style: context.textTheme.bodyMedium, + ), + Gap(4), + Text( + latestMetadata.version, + style: context.textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + Gap(4), + Text( + '(${formatDate(latestMetadata.createdAt)})', + style: context.textTheme.bodyMedium?.copyWith( + color: Theme.of(context).colorScheme.onSurface + .withValues(alpha: 0.6), + ), + ), + ], + ), + ), + Padding( + padding: const EdgeInsets.symmetric(vertical: 20), + child: latestRelease != null + ? ReleaseNotes(release: latestRelease) + : const ReleaseNotesSkeleton(), + ), + ], + ), ), - ), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: AppInfoTable(app: app, fileMetadata: latestMetadata), - ), + if (latestMetadata != null) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: AppInfoTable(app: app, fileMetadata: latestMetadata), + ), - CommentsSection(app: app, fileMetadata: latestMetadata), + if (latestMetadata != null) + CommentsSection(app: app, fileMetadata: latestMetadata), // Debug section if (showDebugSections) @@ -433,62 +389,12 @@ class _AppDetailContent extends HookConsumerWidget { // Floating three-dot menu _buildFloatingMenu(context, ref, app, isInstalled, isSignedIn), - // Loading indicator while fetching more data - if (isLoading) _buildLoadingIndicator(context), ], ), ), ); } - Widget _buildLoadingIndicator(BuildContext context) { - return Positioned( - top: 8, - left: 0, - right: 0, - child: Center( - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), - decoration: BoxDecoration( - color: Theme.of( - context, - ).colorScheme.surface.withValues(alpha: 0.95), - borderRadius: BorderRadius.circular(20), - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.1), - blurRadius: 8, - offset: const Offset(0, 2), - ), - ], - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - SizedBox( - width: 14, - height: 14, - child: CircularProgressIndicator( - strokeWidth: 2, - color: Theme.of(context).colorScheme.primary, - ), - ), - const SizedBox(width: 8), - Text( - 'Refreshing...', - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Theme.of( - context, - ).colorScheme.onSurface.withValues(alpha: 0.7), - ), - ), - ], - ), - ), - ), - ); - } - Widget _buildFloatingMenu( BuildContext context, WidgetRef ref, diff --git a/lib/utils/app_query.dart b/lib/utils/app_query.dart index 6d0f8bd..f48c2fa 100644 --- a/lib/utils/app_query.dart +++ b/lib/utils/app_query.dart @@ -7,8 +7,11 @@ import 'package:models/models.dart'; /// This is the primary query path for app listings. Each SoftwareAsset carries /// version, versionCode, hash, urls, and platform — everything needed for /// app cards and update detection — and links directly to its App. -AutoDisposeStateNotifierProvider, - StorageState> appAssetsQuery({ +AutoDisposeStateNotifierProvider< + RequestNotifier, + StorageState +> +appAssetsQuery({ Set? authors, Map>? tags, String? search, @@ -25,7 +28,16 @@ AutoDisposeStateNotifierProvider, since: since, until: until, limit: limit, - and: (asset) => {asset.app.query(), asset.author.query()}, + and: (asset) => { + asset.app.query(source: const LocalAndRemoteSource(stream: false)), + asset.author.query( + source: const LocalAndRemoteSource( + relays: {'vertex', 'social'}, + cachedFor: Duration(hours: 2), + stream: false, + ), + ), + }, source: source, subscriptionPrefix: subscriptionPrefix, ); @@ -36,8 +48,11 @@ AutoDisposeStateNotifierProvider, /// /// Same shape as [appAssetsQuery] but for legacy 1063-only apps. /// Delete this function when legacy 1063 support is fully removed. -AutoDisposeStateNotifierProvider, - StorageState> legacyAppQuery({ +AutoDisposeStateNotifierProvider< + RequestNotifier, + StorageState +> +legacyAppQuery({ Set? authors, Map>? tags, String? search, @@ -54,7 +69,15 @@ AutoDisposeStateNotifierProvider, since: since, until: until, limit: limit, - and: (fm) => {fm.app.query(), fm.author.query()}, + and: (fm) => { + fm.app.query(source: const LocalAndRemoteSource(stream: false)), + fm.author.query( + source: const LocalAndRemoteSource( + cachedFor: Duration(hours: 2), + stream: false, + ), + ), + }, source: source, subscriptionPrefix: subscriptionPrefix, ); @@ -104,7 +127,7 @@ Future fetchAppsByAsset( subscriptionPrefix: '$subscriptionPrefix-apps', ); - await _loadAuthors(storage, apps, source, '$subscriptionPrefix-authors'); + await _loadAuthors(storage, apps, '$subscriptionPrefix-authors'); return AssetFetchResult(apps, assets.length); } @@ -145,7 +168,7 @@ Future fetchLegacyAppsByMetadata( subscriptionPrefix: '$subscriptionPrefix-apps', ); - await _loadAuthors(storage, apps, source, '$subscriptionPrefix-authors'); + await _loadAuthors(storage, apps, '$subscriptionPrefix-authors'); return AssetFetchResult(apps, metadatas.length); } @@ -153,7 +176,6 @@ Future fetchLegacyAppsByMetadata( Future _loadAuthors( StorageNotifier storage, List apps, - Source? source, String subscriptionPrefix, ) async { if (apps.isEmpty) return; @@ -164,7 +186,10 @@ Future _loadAuthors( if (authorFilters.isEmpty) return; await storage.query( Request(authorFilters), - source: source, + source: const LocalAndRemoteSource( + relays: {'vertex', 'social'}, + cachedFor: Duration(hours: 2), + ), subscriptionPrefix: subscriptionPrefix, ); } diff --git a/lib/utils/extensions.dart b/lib/utils/extensions.dart index ce4b3eb..1084125 100644 --- a/lib/utils/extensions.dart +++ b/lib/utils/extensions.dart @@ -34,12 +34,10 @@ extension AppExt on App { ref.read(packageManagerProvider.notifier).isInstalled(identifier); /// Resolved installable for the current platform. - /// Prefers direct SoftwareAsset (3063) via `latestAsset` relationship, - /// falls back to Release chain (1063). + /// Uses direct SoftwareAsset (3063) via `latestAsset`, or legacy + /// FileMetadata (1063) via `latestRelease` for apps not yet on 3063. Installable? get installable => - latestAsset.value ?? - latestRelease.value?.latestAsset.value ?? - latestRelease.value?.latestMetadata.value; + latestAsset.value ?? latestRelease.value?.latestMetadata.value; /// Whether there is an update available for the installed app. /// Delegates to PackageManager (versionCode-only comparison). @@ -65,8 +63,12 @@ extension WidgetRefExt on WidgetRef { Ref get asRef => read(Provider((ref) => ref)); } -/// Extension to get APK certificate hashes from either format. +/// Extension adding event-level accessors to Installable implementations. extension InstallableExt on Installable { + /// Creation timestamp from the underlying Nostr event. + DateTime get createdAt => (this as Model).event.createdAt; + + /// Returns the primary APK certificate hash. /// SoftwareAsset: uses apkCertificateHashes. FileMetadata: uses apkSignatureHash. String? get certificateHash { diff --git a/lib/widgets/app_detail_widgets.dart b/lib/widgets/app_detail_widgets.dart index 4629ab5..ff0d62f 100644 --- a/lib/widgets/app_detail_widgets.dart +++ b/lib/widgets/app_detail_widgets.dart @@ -64,6 +64,32 @@ class ReleaseNotes extends StatelessWidget { } } +/// Multi-line skeleton shown while release notes are loading +class ReleaseNotesSkeleton extends StatelessWidget { + const ReleaseNotesSkeleton({super.key}); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (int i = 0; i < 4; i++) + Padding( + padding: const EdgeInsets.only(bottom: 8), + child: ClipRRect( + borderRadius: BorderRadius.circular(4), + child: SizedBox( + height: 14, + width: i == 3 ? 160 : double.infinity, + child: buildGradientLoader(context), + ), + ), + ), + ], + ); + } +} + /// Row of social action buttons (zap, bookmark, app stack, share) class SocialActionsRow extends HookConsumerWidget { const SocialActionsRow({super.key, required this.app, required this.author});