From e4cc62ec0b685efb23581cff66c70c7f5f22a0c7 Mon Sep 17 00:00:00 2001 From: franzap <_@franzap.com> Date: Fri, 23 Jan 2026 17:10:38 -0300 Subject: [PATCH 1/4] Prefix subscriptions with app- (we need it for the relay) --- lib/screens/search_screen.dart | 2 +- lib/screens/updates_screen.dart | 6 +- lib/screens/user_screen.dart | 2 +- lib/services/updates_service.dart | 2 +- lib/widgets/app_detail_widgets.dart | 2 +- lib/widgets/app_stack_container.dart | 68 +++++++++++++--------- lib/widgets/latest_releases_container.dart | 6 +- spec/guidelines/ARCHITECTURE.md | 4 ++ 8 files changed, 55 insertions(+), 37 deletions(-) diff --git a/lib/screens/search_screen.dart b/lib/screens/search_screen.dart index 3592314..de21796 100644 --- a/lib/screens/search_screen.dart +++ b/lib/screens/search_screen.dart @@ -172,7 +172,7 @@ class _SearchResultsSection extends HookConsumerWidget { // Force the search to hit the default relay group (relay.zapstore.dev) // so a connection appears in Debug Info when searching. source: const RemoteSource(relays: 'AppCatalog', stream: false), - subscriptionPrefix: 'search-results', + subscriptionPrefix: 'app-search-results', ), ); diff --git a/lib/screens/updates_screen.dart b/lib/screens/updates_screen.dart index f82f2a6..c565103 100644 --- a/lib/screens/updates_screen.dart +++ b/lib/screens/updates_screen.dart @@ -40,9 +40,9 @@ class _ConnectionStatusIndicator extends ConsumerWidget { final poolState = ref.watch(poolStateProvider); final subscriptions = poolState?.subscriptions ?? {}; - // Filter to only "updates" subscription + // Filter to only "app-updates" subscription final updatesSubs = subscriptions.entries - .where((e) => e.key.startsWith('updates')) + .where((e) => e.key.startsWith('app-updates')) .map((e) => e.value); // Check relay status for updates subscription only @@ -189,7 +189,7 @@ class _UpdatesListBodyWithInstallingAppIds extends ConsumerWidget { tags: {'#d': installingAppIds}, and: (app) => {app.latestRelease.query()}, source: const LocalAndRemoteSource(relays: 'AppCatalog'), - subscriptionPrefix: 'installing-apps', + subscriptionPrefix: 'app-installing-apps', ), ); diff --git a/lib/screens/user_screen.dart b/lib/screens/user_screen.dart index ff1d154..abab0a8 100644 --- a/lib/screens/user_screen.dart +++ b/lib/screens/user_screen.dart @@ -49,7 +49,7 @@ class UserScreen extends HookConsumerWidget { ), }, source: const LocalAndRemoteSource(relays: 'AppCatalog', stream: false), - subscriptionPrefix: 'user-apps', + subscriptionPrefix: 'app-user-apps', ), ); diff --git a/lib/services/updates_service.dart b/lib/services/updates_service.dart index 1a5942b..1d6ac14 100644 --- a/lib/services/updates_service.dart +++ b/lib/services/updates_service.dart @@ -104,7 +104,7 @@ class CategorizedAppsNotifier extends Notifier { ), }, source: const LocalAndRemoteSource(relays: 'AppCatalog', stream: true), - subscriptionPrefix: 'updates', + subscriptionPrefix: 'app-updates', ), ); diff --git a/lib/widgets/app_detail_widgets.dart b/lib/widgets/app_detail_widgets.dart index 6025c8b..22855f1 100644 --- a/lib/widgets/app_detail_widgets.dart +++ b/lib/widgets/app_detail_widgets.dart @@ -735,7 +735,7 @@ class DebugVersionsSection extends HookConsumerWidget { tags: app.event.addressableIdTagMap, and: (release) => {release.latestMetadata.query(), release.latestAsset.query()}, source: LocalAndRemoteSource(relays: 'AppCatalog', stream: false), - subscriptionPrefix: 'debug-releases', + subscriptionPrefix: 'app-debug-releases', ), ); diff --git a/lib/widgets/app_stack_container.dart b/lib/widgets/app_stack_container.dart index 5b642dd..c5acd40 100644 --- a/lib/widgets/app_stack_container.dart +++ b/lib/widgets/app_stack_container.dart @@ -31,11 +31,7 @@ String? _extractIdentifier(String addressableId) { List _getPreviewIdentifiers(AppStack stack) { final rawTags = _getRawAppTagValues(stack).toList() ..shuffle(Random(stack.id.hashCode)); - return rawTags - .take(3) - .map(_extractIdentifier) - .whereType() - .toList(); + return rawTags.take(3).map(_extractIdentifier).whereType().toList(); } /// Sort app stacks: franzap/following first, then by recency @@ -67,10 +63,12 @@ List _sortStacks( others.sort((a, b) => b.event.createdAt.compareTo(a.event.createdAt)); return [...followed, ...others]; } else { - final franzapStacks = - stacks.where((s) => s.pubkey == kFranzapPubkey).toList(); - final otherStacks = - stacks.where((s) => s.pubkey != kFranzapPubkey).toList(); + final franzapStacks = stacks + .where((s) => s.pubkey == kFranzapPubkey) + .toList(); + final otherStacks = stacks + .where((s) => s.pubkey != kFranzapPubkey) + .toList(); franzapStacks.shuffle(random); otherStacks.sort((a, b) => b.event.createdAt.compareTo(a.event.createdAt)); @@ -198,8 +196,10 @@ class AppStackContainer extends HookConsumerWidget { scrollController.position.maxScrollExtent - 200) { // Load more if we haven't shown all stacks yet if (visibleCount.value < sortedStacks.length) { - visibleCount.value = (visibleCount.value + _kBatchSize) - .clamp(0, sortedStacks.length); + visibleCount.value = (visibleCount.value + _kBatchSize).clamp( + 0, + sortedStacks.length, + ); } } } @@ -231,9 +231,13 @@ class AppStackContainer extends HookConsumerWidget { if (col * 2 < displayedStacks.length) _StackCard( stack: displayedStacks[col * 2], - author: authorsMap[displayedStacks[col * 2].event.pubkey], - isAuthorLoading: isAuthorLoading(displayedStacks[col * 2].event.pubkey), - previewIdentifiers: stackPreviewIds[displayedStacks[col * 2].id] ?? [], + author: + authorsMap[displayedStacks[col * 2].event.pubkey], + isAuthorLoading: isAuthorLoading( + displayedStacks[col * 2].event.pubkey, + ), + previewIdentifiers: + stackPreviewIds[displayedStacks[col * 2].id] ?? [], appsMap: appsMap, ), // Bottom item @@ -241,9 +245,16 @@ class AppStackContainer extends HookConsumerWidget { const SizedBox(height: 10), _StackCard( stack: displayedStacks[col * 2 + 1], - author: authorsMap[displayedStacks[col * 2 + 1].event.pubkey], - isAuthorLoading: isAuthorLoading(displayedStacks[col * 2 + 1].event.pubkey), - previewIdentifiers: stackPreviewIds[displayedStacks[col * 2 + 1].id] ?? [], + author: + authorsMap[displayedStacks[col * 2 + 1] + .event + .pubkey], + isAuthorLoading: isAuthorLoading( + displayedStacks[col * 2 + 1].event.pubkey, + ), + previewIdentifiers: + stackPreviewIds[displayedStacks[col * 2 + 1].id] ?? + [], appsMap: appsMap, ), ], @@ -450,9 +461,7 @@ class _AppIconsRow extends StatelessWidget { aspectRatio: 1, child: Container( decoration: BoxDecoration( - color: Theme.of(context) - .colorScheme - .surfaceContainerHighest + color: Theme.of(context).colorScheme.surfaceContainerHighest .withValues(alpha: 0.8), borderRadius: BorderRadius.circular(10), ), @@ -481,10 +490,9 @@ class _AppIconsRow extends StatelessWidget { aspectRatio: 1, child: Container( decoration: BoxDecoration( - color: Theme.of(context) - .colorScheme - .surfaceContainerHighest - .withValues(alpha: 0.5), + color: Theme.of( + context, + ).colorScheme.surfaceContainerHighest.withValues(alpha: 0.5), borderRadius: BorderRadius.circular(10), ), ), @@ -520,10 +528,14 @@ class _AppIconTile extends StatelessWidget { fit: BoxFit.cover, fadeInDuration: const Duration(milliseconds: 200), placeholder: (_, __) => Container( - color: Theme.of(context).colorScheme.surfaceContainerHighest, + color: Theme.of( + context, + ).colorScheme.surfaceContainerHighest, ), errorWidget: (_, __, ___) => Container( - color: Theme.of(context).colorScheme.surfaceContainerHighest, + color: Theme.of( + context, + ).colorScheme.surfaceContainerHighest, child: const Icon( Icons.broken_image_outlined, size: 16, @@ -532,7 +544,9 @@ class _AppIconTile extends StatelessWidget { ), ) : Container( - color: Theme.of(context).colorScheme.surfaceContainerHighest, + color: Theme.of( + context, + ).colorScheme.surfaceContainerHighest, child: const Icon( Icons.apps_outlined, size: 16, diff --git a/lib/widgets/latest_releases_container.dart b/lib/widgets/latest_releases_container.dart index d9dd42c..968824f 100644 --- a/lib/widgets/latest_releases_container.dart +++ b/lib/widgets/latest_releases_container.dart @@ -321,7 +321,7 @@ class LatestReleasesNotifier extends StateNotifier { }, // NOTE: It must stream=true source: const LocalAndRemoteSource(relays: 'AppCatalog', stream: true), - subscriptionPrefix: 'latest', + subscriptionPrefix: 'app-latest', ), (previous, next) async { // Always mirror storage state and ensure olderApps don't duplicate the live head @@ -446,9 +446,9 @@ class _LatestReleasesStatusDot extends ConsumerWidget { final poolState = ref.watch(poolStateProvider); final subscriptions = poolState?.subscriptions ?? {}; - // Filter to only "latest" subscription + // Filter to only "app-latest" subscription final latestSubs = subscriptions.entries - .where((e) => e.key.startsWith('latest')) + .where((e) => e.key.startsWith('app-latest')) .map((e) => e.value); // Check relay status for latest subscription diff --git a/spec/guidelines/ARCHITECTURE.md b/spec/guidelines/ARCHITECTURE.md index 5af9916..031bebe 100644 --- a/spec/guidelines/ARCHITECTURE.md +++ b/spec/guidelines/ARCHITECTURE.md @@ -95,4 +95,8 @@ onPressed: () async { } ``` +### Subscription prefix naming + +All queries using the `AppCatalog` relay group MUST prefix their `subscriptionPrefix` with `app-`. This is used in the backend. + For detailed API, see models/purplebase READMEs in pub cache. From 3ccb1b70ac0635820bac0902baf49d9f65c7efe2 Mon Sep 17 00:00:00 2001 From: franzap <_@franzap.com> Date: Fri, 23 Jan 2026 17:22:44 -0300 Subject: [PATCH 2/4] Add copy npub icon to user_screen --- lib/screens/user_screen.dart | 67 ++++++++++++++++++++++-------------- 1 file changed, 42 insertions(+), 25 deletions(-) diff --git a/lib/screens/user_screen.dart b/lib/screens/user_screen.dart index abab0a8..ebd15d8 100644 --- a/lib/screens/user_screen.dart +++ b/lib/screens/user_screen.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; @@ -382,34 +383,50 @@ class _NpubRow extends StatelessWidget { final abbreviatedNpub = '${npub.substring(0, 12)}...${npub.substring(npub.length - 8)}'; - return GestureDetector( - onTap: () => launchUrl( - Uri.parse('https://npub.world/$npub'), - mode: LaunchMode.externalApplication, - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - Icons.key, - size: 16, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - const SizedBox(width: 4), - Text( - abbreviatedNpub, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Theme.of(context).colorScheme.onSurface, - ), - ), - const SizedBox(width: 4), - Icon( - Icons.open_in_new, + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + GestureDetector( + onTap: () { + Clipboard.setData(ClipboardData(text: npub)); + }, + child: Icon( + Icons.copy, size: 12, color: Theme.of(context).colorScheme.onSurfaceVariant, ), - ], - ), + ), + const SizedBox(width: 4), + GestureDetector( + onTap: () => launchUrl( + Uri.parse('https://npub.world/$npub'), + mode: LaunchMode.externalApplication, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.key, + size: 16, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + const SizedBox(width: 4), + Text( + abbreviatedNpub, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onSurface, + ), + ), + const SizedBox(width: 4), + Icon( + Icons.open_in_new, + size: 12, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ], + ), + ), + ], ); } } From cdc3bfdc3586c50b5bf0de715c0a464ef02b428c Mon Sep 17 00:00:00 2001 From: Henrique Velloso Date: Fri, 23 Jan 2026 19:30:54 -0300 Subject: [PATCH 3/4] Fix saved apps section stuck in loading or disappearing - #283 - Show "No saved apps yet" empty state instead of infinite loading - Simplify - Centralize empty state logic in _SavedAppsList - Fix SizedBox.shrink() causing section to disappear when apps not in local cache --- lib/screens/profile_screen.dart | 219 ++++++++++++++------------------ 1 file changed, 95 insertions(+), 124 deletions(-) diff --git a/lib/screens/profile_screen.dart b/lib/screens/profile_screen.dart index 5fcdef5..0a04536 100644 --- a/lib/screens/profile_screen.dart +++ b/lib/screens/profile_screen.dart @@ -1480,80 +1480,111 @@ class _SavedAppsHeading extends ConsumerWidget { return const SizedBox.shrink(); } - final savedAppsAsync = ref.watch(bookmarksProvider); - - return savedAppsAsync.when( - loading: () => const SizedBox.shrink(), - error: (_, __) => const SizedBox.shrink(), - data: (addressableIds) { - if (addressableIds.isEmpty) { - return const SizedBox.shrink(); - } - - final identifiers = addressableIds - .map((id) { - final parts = id.split(':'); - return parts.length >= 3 ? parts[2] : null; - }) - .whereType() - .toSet(); - - if (identifiers.isEmpty) { - return const SizedBox.shrink(); - } - - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 16.0), - child: Text('Saved Apps', style: context.textTheme.headlineSmall), - ); - }, + // Always show heading when signed in + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: Text('Saved Apps', style: context.textTheme.headlineSmall), ); } } -class _SavedAppsSection extends HookConsumerWidget { +class _SavedAppsSection extends ConsumerWidget { const _SavedAppsSection(); @override Widget build(BuildContext context, WidgetRef ref) { final signedInPubkey = ref.watch(Signer.activePubkeyProvider); - final hasLoadedOnce = useState(false); - - if (signedInPubkey == null) { - return const SizedBox.shrink(); - } + if (signedInPubkey == null) return const SizedBox.shrink(); final savedAppsAsync = ref.watch(bookmarksProvider); - return savedAppsAsync.when( - loading: () => _savedAppsLoadingCard(context), - error: (_, __) => const SizedBox.shrink(), - data: (addressableIds) { - if (addressableIds.isEmpty) { - return const SizedBox.shrink(); - } + // Keep previous value during refresh, if available. + final addressableIds = savedAppsAsync.valueOrNull; - final identifiers = addressableIds - .map((id) { - final parts = id.split(':'); - return parts.length >= 3 ? parts[2] : null; - }) - .whereType() - .toSet(); + // Show loading only on first load (when no value exists yet). + if (addressableIds == null) { + return _savedAppsLoadingCard(context); + } - if (identifiers.isEmpty) { - return const SizedBox.shrink(); - } + final identifiers = _toIdentifiers(addressableIds); + return _SavedAppsList(identifiers: identifiers); + } - return _SavedAppsList( - identifiers: identifiers, - hasLoadedOnce: hasLoadedOnce, - ); - }, + Set _toIdentifiers(Set addressableIds) { + return addressableIds + .map((id) => id.split(':')) + .where((parts) => parts.length >= 3) + .map((parts) => parts[2]) + .toSet(); + } + + Widget _savedAppsLoadingCard(BuildContext context) => Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Center( + child: CircularProgressIndicator( + color: Theme.of(context).colorScheme.primary, + ), + ), + ), + ); +} + +class _SavedAppsList extends ConsumerWidget { + const _SavedAppsList({required this.identifiers}); + + final Set identifiers; + + @override + Widget build(BuildContext context, WidgetRef ref) { + // No bookmarks saved - show empty state without querying + if (identifiers.isEmpty) { + return _emptyState(context); + } + + final savedAppsState = ref.watch( + query( + tags: {'#d': identifiers}, + and: (app) => {app.latestRelease.query()}, + source: const LocalSource(), + subscriptionPrefix: 'profile-saved-apps', + ), + ); + + final isLoading = savedAppsState is StorageLoading; + + final savedApps = savedAppsState.models.toList() + ..sort( + (a, b) => (a.name ?? a.identifier).toLowerCase().compareTo( + (b.name ?? b.identifier).toLowerCase(), + ), + ); + + // Show spinner only when we truly have nothing to render yet + // If we're refreshing but still have models, keep showing the list + if (isLoading && savedApps.isEmpty) { + return _loadingCard(context); + } + + if (savedApps.isEmpty) { + return _emptyState(context); + } + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (final app in savedApps) + AppCard(app: app, showUpdateArrow: false, showDescription: false), + ], + ), + ), ); } - Widget _savedAppsLoadingCard(BuildContext context) { + Widget _loadingCard(BuildContext context) { return Card( child: Padding( padding: const EdgeInsets.all(16), @@ -1565,75 +1596,15 @@ class _SavedAppsSection extends HookConsumerWidget { ), ); } -} -class _SavedAppsList extends ConsumerWidget { - const _SavedAppsList({ - required this.identifiers, - required this.hasLoadedOnce, - }); - - final Set identifiers; - final ValueNotifier hasLoadedOnce; - - @override - Widget build(BuildContext context, WidgetRef ref) { - final savedAppsState = ref.watch( - query( - tags: {'#d': identifiers}, - and: (app) => {app.latestRelease.query()}, - source: const LocalSource(), - subscriptionPrefix: 'profile-saved-apps', - ), - ); - - final savedApps = savedAppsState.models.toList() - ..sort( - (a, b) => (a.name ?? a.identifier).toLowerCase().compareTo( - (b.name ?? b.identifier).toLowerCase(), - ), - ); - - // Check if we have loaded at least once - defer state update to after build - if (savedApps.isNotEmpty && !hasLoadedOnce.value) { - WidgetsBinding.instance.addPostFrameCallback((_) { - hasLoadedOnce.value = true; - }); - } - - // Show loading state only if we haven't loaded any apps yet - if (savedApps.isEmpty && !hasLoadedOnce.value) { - return Card( - child: Padding( - padding: const EdgeInsets.all(16), - child: Center( - child: CircularProgressIndicator( - color: Theme.of(context).colorScheme.primary, - ), - ), - ), - ); - } - - // Hide if no apps after loading - if (savedApps.isEmpty) { - return const SizedBox.shrink(); - } - - return Card( - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: savedApps - .map( - (app) => AppCard( - app: app, - showUpdateArrow: false, - showDescription: false, - ), - ) - .toList(), + Widget _emptyState(BuildContext context) { + final theme = Theme.of(context); + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Text( + 'No saved apps yet', + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurface.withValues(alpha: 0.5), ), ), ); From f17100a6a6bf410c00ea22993576ff4baa25fa3d Mon Sep 17 00:00:00 2001 From: franzap <_@franzap.com> Date: Sat, 24 Jan 2026 13:07:29 -0300 Subject: [PATCH 4/4] Ignore retarded analyzer warning --- analysis_options.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/analysis_options.yaml b/analysis_options.yaml index 5da7251..2fb84c4 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -10,6 +10,7 @@ analyzer: errors: dangling_library_doc_comments: ignore + deprecated_member_use: ignore include: package:flutter_lints/flutter.yaml linter: