From 1449722297c7b78ddd2cd5d81077b562c0244d29 Mon Sep 17 00:00:00 2001 From: franzap <_@franzap.com> Date: Thu, 26 Mar 2026 00:24:03 -0300 Subject: [PATCH] Minor fixes, app stacks screen --- lib/router.dart | 20 +- lib/screens/all_stacks_screen.dart | 244 +++++++++++++++++++++ lib/utils/extensions.dart | 2 +- lib/widgets/app_stack_container.dart | 178 +++++++++------ lib/widgets/latest_releases_container.dart | 2 +- lib/widgets/zap_widgets.dart | 6 +- 6 files changed, 380 insertions(+), 72 deletions(-) create mode 100644 lib/screens/all_stacks_screen.dart diff --git a/lib/router.dart b/lib/router.dart index 4e575d3..3af737a 100644 --- a/lib/router.dart +++ b/lib/router.dart @@ -6,6 +6,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:models/models.dart'; import 'package:zapstore/screens/main_scaffold.dart'; import 'package:zapstore/screens/app_detail_screen.dart'; +import 'package:zapstore/screens/all_stacks_screen.dart'; import 'package:zapstore/screens/app_stack_screen.dart'; import 'package:zapstore/screens/user_screen.dart'; import 'package:zapstore/screens/search_screen.dart'; @@ -84,6 +85,19 @@ GoRoute _stackDetailRoute() { ); } +/// Helper to build all-stacks route +GoRoute _allStacksRoute() { + return GoRoute( + path: 'stacks', + pageBuilder: (context, state) { + return _noTransitionPage( + state: state, + child: const AllStacksScreen(), + ); + }, + ); +} + /// Helper to build user route GoRoute _userRoute() { return GoRoute( @@ -123,7 +137,7 @@ final routerProvider = Provider((ref) { state: state, child: const SearchScreen(), ), - routes: [_appDetailRoute(), _stackDetailRoute(), _userRoute()], + routes: [_appDetailRoute(), _stackDetailRoute(), _allStacksRoute(), _userRoute()], ), ], ), @@ -136,7 +150,7 @@ final routerProvider = Provider((ref) { state: state, child: const UpdatesScreen(), ), - routes: [_appDetailRoute(), _stackDetailRoute(), _userRoute()], + routes: [_appDetailRoute(), _stackDetailRoute(), _allStacksRoute(), _userRoute()], ), ], ), @@ -149,7 +163,7 @@ final routerProvider = Provider((ref) { state: state, child: const ProfileScreen(), ), - routes: [_appDetailRoute(), _stackDetailRoute(), _userRoute()], + routes: [_appDetailRoute(), _stackDetailRoute(), _allStacksRoute(), _userRoute()], ), ], ), diff --git a/lib/screens/all_stacks_screen.dart b/lib/screens/all_stacks_screen.dart new file mode 100644 index 0000000..13df91a --- /dev/null +++ b/lib/screens/all_stacks_screen.dart @@ -0,0 +1,244 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:models/models.dart'; +import 'package:zapstore/services/package_manager/package_manager.dart'; +import 'package:zapstore/utils/extensions.dart'; +import 'package:zapstore/widgets/app_stack_container.dart'; + +const int _kPageSize = 10; + +class AllStacksScreen extends HookConsumerWidget { + const AllStacksScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final scrollController = useScrollController(); + final visibleCount = useState(_kPageSize); + + final signedInPubkey = ref.watch(Signer.activePubkeyProvider); + final platform = ref.read(packageManagerProvider.notifier).platform; + + final appStacksState = ref.watch( + query( + limit: 100, + tags: { + '#f': {platform}, + }, + source: const LocalAndRemoteSource(relays: 'social'), + subscriptionPrefix: 'app-all-stacks', + schemaFilter: appStackEventFilter, + ), + ); + + final contactListState = signedInPubkey != null + ? ref.watch( + query( + authors: {signedInPubkey}, + limit: 1, + source: const LocalAndRemoteSource( + relays: 'social', + stream: false, + cachedFor: Duration(hours: 1), + ), + subscriptionPrefix: 'app-all-stacks-contacts', + ), + ) + : null; + + final followingPubkeys = + contactListState?.models.firstOrNull?.followingPubkeys; + + final allStacks = switch (appStacksState) { + StorageData(:final models) => models.toList(), + _ => [], + }; + + final sortedStacks = _sortStacks( + allStacks, + signedInPubkey: signedInPubkey, + followingPubkeys: followingPubkeys, + ); + + final displayedStacks = sortedStacks.take(visibleCount.value).toList(); + final hasMore = visibleCount.value < sortedStacks.length; + + // Batch load author profiles for displayed stacks + final authorPubkeys = displayedStacks.map((s) => s.event.pubkey).toSet(); + final authorsState = authorPubkeys.isNotEmpty + ? ref.watch( + query( + authors: authorPubkeys, + source: const LocalAndRemoteSource( + relays: {'social', 'vertex'}, + cachedFor: Duration(hours: 2), + ), + subscriptionPrefix: 'app-all-stacks-authors', + ), + ) + : null; + final authorsMap = { + for (final profile in authorsState?.models ?? []) + profile.pubkey: profile, + }; + final isAuthorsLoading = authorsState is StorageLoading; + + // Batch load preview apps for displayed stacks + final allPreviewIdentifiers = {}; + final stackPreviewIds = >{}; + for (final stack in displayedStacks) { + final ids = getPreviewIdentifiers(stack); + stackPreviewIds[stack.id] = ids; + allPreviewIdentifiers.addAll(ids); + } + + final previewAppsState = allPreviewIdentifiers.isNotEmpty + ? ref.watch( + query( + tags: {'#d': allPreviewIdentifiers}, + source: const LocalAndRemoteSource( + relays: 'AppCatalog', + stream: false, + ), + subscriptionPrefix: 'app-all-stacks-preview-apps', + ), + ) + : null; + + final appsMap = { + for (final app in previewAppsState?.models ?? []) + app.identifier: app, + }; + + // Infinite scroll: load more when near bottom + useEffect(() { + void onScroll() { + if (!hasMore) return; + if (scrollController.position.pixels >= + scrollController.position.maxScrollExtent - 300) { + visibleCount.value = (visibleCount.value + _kPageSize).clamp( + 0, + sortedStacks.length, + ); + } + } + + scrollController.addListener(onScroll); + return () => scrollController.removeListener(onScroll); + }, [scrollController, sortedStacks.length, hasMore]); + + return Scaffold( + body: CustomScrollView( + controller: scrollController, + slivers: [ + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), + child: Text('App Stacks', style: context.textTheme.headlineMedium), + ), + ), + if (appStacksState is StorageLoading && allStacks.isEmpty) + const SliverFillRemaining( + child: Center(child: CircularProgressIndicator()), + ) + else if (sortedStacks.isEmpty) + SliverFillRemaining( + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.layers_outlined, size: 48, color: Colors.grey[400]), + const SizedBox(height: 16), + Text('No stacks found', style: context.textTheme.titleMedium), + ], + ), + ), + ) + else + SliverPadding( + padding: const EdgeInsets.symmetric(horizontal: 12), + sliver: SliverGrid( + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 2, + crossAxisSpacing: 10, + mainAxisSpacing: 10, + childAspectRatio: 1.15, + ), + delegate: SliverChildBuilderDelegate( + (context, index) { + final stack = displayedStacks[index]; + final author = authorsMap[stack.event.pubkey]; + return StackCard( + stack: stack, + author: author, + isAuthorLoading: isAuthorsLoading && author == null, + previewIdentifiers: stackPreviewIds[stack.id] ?? [], + appsMap: appsMap, + ); + }, + childCount: displayedStacks.length, + ), + ), + ), + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 16), + child: hasMore + ? const Center( + child: SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator(strokeWidth: 3), + ), + ) + : sortedStacks.isNotEmpty + ? Center( + child: Text( + 'No more stacks to load', + style: Theme.of(context).textTheme.bodyMedium + ?.copyWith(color: Colors.grey[600]), + ), + ) + : const SizedBox.shrink(), + ), + ), + const SliverToBoxAdapter(child: SizedBox(height: 16)), + ], + ), + ); + } +} + +List _sortStacks( + List stacks, { + String? signedInPubkey, + Set? followingPubkeys, +}) { + if (signedInPubkey != null && + followingPubkeys != null && + followingPubkeys.isNotEmpty) { + final followed = []; + final others = []; + + for (final stack in stacks) { + if (followingPubkeys.contains(stack.pubkey)) { + followed.add(stack); + } else { + others.add(stack); + } + } + + followed.sort((a, b) => b.event.createdAt.compareTo(a.event.createdAt)); + 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(); + + franzapStacks.sort((a, b) => b.event.createdAt.compareTo(a.event.createdAt)); + otherStacks.sort((a, b) => b.event.createdAt.compareTo(a.event.createdAt)); + return [...franzapStacks, ...otherStacks]; + } +} diff --git a/lib/utils/extensions.dart b/lib/utils/extensions.dart index 8dd6237..527c3e0 100644 --- a/lib/utils/extensions.dart +++ b/lib/utils/extensions.dart @@ -61,7 +61,7 @@ extension AppExt on App { } extension WidgetRefExt on WidgetRef { - Ref get ref => read(Provider((ref) => ref)); + Ref get asRef => read(Provider((ref) => ref)); } /// Extension to handle both old and new format certificate hashes diff --git a/lib/widgets/app_stack_container.dart b/lib/widgets/app_stack_container.dart index 546f36d..01d6057 100644 --- a/lib/widgets/app_stack_container.dart +++ b/lib/widgets/app_stack_container.dart @@ -1,9 +1,9 @@ import 'dart:math'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:models/models.dart'; -import 'package:go_router/go_router.dart'; import 'package:cached_network_image/cached_network_image.dart'; import 'package:skeletonizer/skeletonizer.dart'; import '../utils/extensions.dart'; @@ -18,21 +18,21 @@ const int _kInitialStacks = 6; const int _kBatchSize = 6; /// Get the raw `a` tag values from the stack's event (available immediately) -Set _getRawAppTagValues(AppStack stack) { +Set getRawAppTagValues(AppStack stack) { return stack.event.getTagSetValues('a'); } /// Extract just the d-tag (identifier) from a full addressable id -String? _extractIdentifier(String addressableId) { +String? extractStackIdentifier(String addressableId) { final parts = addressableId.split(':'); return parts.length >= 3 ? parts.sublist(2).join(':') : null; } /// Helper to compute preview app identifiers for a stack (3 apps max) -List _getPreviewIdentifiers(AppStack stack) { - final rawTags = _getRawAppTagValues(stack).toList() +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(extractStackIdentifier).whereType().toList(); } /// Sort app stacks: franzap/following first, then by recency @@ -90,7 +90,13 @@ class AppStackContainer extends HookConsumerWidget { final visibleCount = useState(_kInitialStacks); if (showSkeleton) { - return _buildSkeleton(context); + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + _StackSectionHeader(showSeeAll: false), + _buildSkeleton(context), + ], + ); } final signedInPubkey = ref.watch(Signer.activePubkeyProvider); @@ -136,7 +142,13 @@ class AppStackContainer extends HookConsumerWidget { }; if (allStacks.isEmpty) { - return _buildSkeleton(context); + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + _StackSectionHeader(showSeeAll: false), + _buildSkeleton(context), + ], + ); } // Sort stacks @@ -174,7 +186,7 @@ class AppStackContainer extends HookConsumerWidget { final allPreviewIdentifiers = {}; final stackPreviewIds = >{}; for (final stack in displayedStacks) { - final ids = _getPreviewIdentifiers(stack); + final ids = getPreviewIdentifiers(stack); stackPreviewIds[stack.id] = ids; allPreviewIdentifiers.addAll(ids); } @@ -219,59 +231,58 @@ class AppStackContainer extends HookConsumerWidget { // 2-row horizontal scroll layout final numColumns = (displayedStacks.length + 1) ~/ 2; - return SingleChildScrollView( - controller: scrollController, - scrollDirection: Axis.horizontal, - padding: const EdgeInsets.only(left: 12, right: 12), - clipBehavior: Clip.none, - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - for (int col = 0; col < numColumns; col++) - Padding( - padding: const EdgeInsets.only(right: 10), - child: SizedBox( - width: 160, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - // Top item - 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] ?? [], - appsMap: appsMap, - ), - // Bottom item - if (col * 2 + 1 < displayedStacks.length) ...[ - 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] ?? - [], - appsMap: appsMap, - ), - ], - ], + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + _StackSectionHeader(showSeeAll: sortedStacks.length > _kInitialStacks), + SingleChildScrollView( + controller: scrollController, + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.only(left: 12, right: 12), + clipBehavior: Clip.none, + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (int col = 0; col < numColumns; col++) + Padding( + padding: const EdgeInsets.only(right: 10), + child: SizedBox( + width: 160, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + 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] ?? [], + appsMap: appsMap, + ), + if (col * 2 + 1 < displayedStacks.length) ...[ + 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] ?? [], + appsMap: appsMap, + ), + ], + ], + ), + ), ), - ), - ), - ], - ), + ], + ), + ), + ], ); } @@ -308,6 +319,44 @@ class AppStackContainer extends HookConsumerWidget { } } +/// Section header with title and optional "See all" button +class _StackSectionHeader extends StatelessWidget { + const _StackSectionHeader({required this.showSeeAll}); + + final bool showSeeAll; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.fromLTRB(14, 0, 6, 8), + child: Row( + children: [ + Text('App Stacks', style: context.textTheme.titleLarge), + const Spacer(), + if (showSeeAll) + TextButton( + onPressed: () { + final segments = GoRouterState.of(context).uri.pathSegments; + final first = segments.isNotEmpty ? segments.first : 'search'; + context.push('/$first/stacks'); + }, + style: TextButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 8), + visualDensity: VisualDensity.compact, + ), + child: Text( + 'See all', + style: context.textTheme.bodyMedium?.copyWith( + color: Theme.of(context).colorScheme.primary, + ), + ), + ), + ], + ), + ); + } +} + /// Skeleton card for loading state class _SkeletonStackCard extends StatelessWidget { const _SkeletonStackCard(); @@ -357,8 +406,9 @@ class _SkeletonStackCard extends StatelessWidget { } /// Individual stack card with vertical layout -class _StackCard extends StatelessWidget { - const _StackCard({ +class StackCard extends StatelessWidget { + const StackCard({ + super.key, required this.stack, required this.author, required this.previewIdentifiers, @@ -374,7 +424,7 @@ class _StackCard extends StatelessWidget { @override Widget build(BuildContext context) { - final totalApps = _getRawAppTagValues(stack).length; + final totalApps = getRawAppTagValues(stack).length; // Resolve preview apps from the pre-loaded map final previewApps = previewIdentifiers diff --git a/lib/widgets/latest_releases_container.dart b/lib/widgets/latest_releases_container.dart index ffddc5d..44b92d1 100644 --- a/lib/widgets/latest_releases_container.dart +++ b/lib/widgets/latest_releases_container.dart @@ -294,7 +294,7 @@ class LatestReleasesNotifier extends StateNotifier { } final Ref ref; - static const int _pageSize = 10; + static const int _pageSize = 5; ProviderSubscription>? _sub; // We keep a fixed live head window from query(); older pages are appended diff --git a/lib/widgets/zap_widgets.dart b/lib/widgets/zap_widgets.dart index 8366b28..f51c406 100644 --- a/lib/widgets/zap_widgets.dart +++ b/lib/widgets/zap_widgets.dart @@ -416,7 +416,7 @@ class ZapAmountDialog extends HookConsumerWidget { if (signer == null) { signer = Bip340PrivateKeySigner( Utils.generateRandomHex64(), - ref.ref, + ref.asRef, ); await signer.signIn(registerSigner: false); } @@ -456,7 +456,7 @@ class ZapAmountDialog extends HookConsumerWidget { await _executeZapPayment( signedZapRequest, nwcString, - ref.ref, + ref.asRef, ); if (toastContext != null && toastContext.mounted) { toastContext.showInfo('⚡ Zap sent! $amount sats'); @@ -690,7 +690,7 @@ class NWCZapDialog extends HookConsumerWidget { try { await secureStorage.setNWCString(nwcString.trim()); ref.invalidate(hasNwcStringProvider); - await _executeZapPayment(signedZapRequest, nwcString, ref.ref); + await _executeZapPayment(signedZapRequest, nwcString, ref.asRef); if (toastContext != null && toastContext.mounted) { toastContext.showInfo('⚡ Zap sent! $amount sats'); }