From bf23420bbb3d154ba8c8abd17c1b07b5fa3b8ccc Mon Sep 17 00:00:00 2001 From: franzap <_@franzap.com> Date: Thu, 26 Mar 2026 09:41:20 -0300 Subject: [PATCH] Stacks fixes --- lib/constants/app_constants.dart | 4 + lib/router.dart | 31 ++- ..._stacks_screen.dart => stacks_screen.dart} | 108 +++++---- lib/screens/user_screen.dart | 3 +- lib/widgets/app_stack_container.dart | 208 +++++++++--------- lib/widgets/bookmark_widgets.dart | 6 +- spec/guidelines/INVARIANTS.md | 5 + 7 files changed, 213 insertions(+), 152 deletions(-) rename lib/screens/{all_stacks_screen.dart => stacks_screen.dart} (69%) diff --git a/lib/constants/app_constants.dart b/lib/constants/app_constants.dart index 282c5f8..8de0ffa 100644 --- a/lib/constants/app_constants.dart +++ b/lib/constants/app_constants.dart @@ -15,6 +15,10 @@ const kTrustedRelayPubkeys = { const kFranzapPubkey = '726a1e261cc6474674e8285e3951b3bb139be9a773d1acf49dc868db861a1c11'; +/// Zapstore community public key (npub14nl2afh9zsswsp5043zxe2w304afaa496gxe8z2w2rlw84ys92zqlnjx5u) +const kZapstoreCommunityPubkey = + 'acfeaea6e51420e8068fac446ca9d17d7a9ef6a5d20d93894e50fee3d4902a84'; + /// Identifier for storing user saved apps const kAppBookmarksIdentifier = 'zapstore-bookmarks'; diff --git a/lib/router.dart b/lib/router.dart index 3af737a..588a3f1 100644 --- a/lib/router.dart +++ b/lib/router.dart @@ -6,7 +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/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'; @@ -29,7 +29,8 @@ CustomTransitionPage _noTransitionPage({ return CustomTransitionPage( key: state.pageKey, child: child, - transitionsBuilder: (context, animation, secondaryAnimation, child) => child, + transitionsBuilder: (context, animation, secondaryAnimation, child) => + child, transitionDuration: Duration.zero, reverseTransitionDuration: Duration.zero, ); @@ -90,10 +91,7 @@ GoRoute _allStacksRoute() { return GoRoute( path: 'stacks', pageBuilder: (context, state) { - return _noTransitionPage( - state: state, - child: const AllStacksScreen(), - ); + return _noTransitionPage(state: state, child: const StacksScreen()); }, ); } @@ -137,7 +135,12 @@ final routerProvider = Provider((ref) { state: state, child: const SearchScreen(), ), - routes: [_appDetailRoute(), _stackDetailRoute(), _allStacksRoute(), _userRoute()], + routes: [ + _appDetailRoute(), + _stackDetailRoute(), + _allStacksRoute(), + _userRoute(), + ], ), ], ), @@ -150,7 +153,12 @@ final routerProvider = Provider((ref) { state: state, child: const UpdatesScreen(), ), - routes: [_appDetailRoute(), _stackDetailRoute(), _allStacksRoute(), _userRoute()], + routes: [ + _appDetailRoute(), + _stackDetailRoute(), + _allStacksRoute(), + _userRoute(), + ], ), ], ), @@ -163,7 +171,12 @@ final routerProvider = Provider((ref) { state: state, child: const ProfileScreen(), ), - routes: [_appDetailRoute(), _stackDetailRoute(), _allStacksRoute(), _userRoute()], + routes: [ + _appDetailRoute(), + _stackDetailRoute(), + _allStacksRoute(), + _userRoute(), + ], ), ], ), diff --git a/lib/screens/all_stacks_screen.dart b/lib/screens/stacks_screen.dart similarity index 69% rename from lib/screens/all_stacks_screen.dart rename to lib/screens/stacks_screen.dart index 13df91a..bcd4022 100644 --- a/lib/screens/all_stacks_screen.dart +++ b/lib/screens/stacks_screen.dart @@ -2,14 +2,16 @@ 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:skeletonizer/skeletonizer.dart'; import 'package:zapstore/services/package_manager/package_manager.dart'; +import 'package:zapstore/theme.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}); +class StacksScreen extends HookConsumerWidget { + const StacksScreen({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { @@ -25,7 +27,7 @@ class AllStacksScreen extends HookConsumerWidget { tags: { '#f': {platform}, }, - source: const LocalAndRemoteSource(relays: 'social'), + source: const LocalAndRemoteSource(relays: 'AppCatalog'), subscriptionPrefix: 'app-all-stacks', schemaFilter: appStackEventFilter, ), @@ -49,10 +51,7 @@ class AllStacksScreen extends HookConsumerWidget { final followingPubkeys = contactListState?.models.firstOrNull?.followingPubkeys; - final allStacks = switch (appStacksState) { - StorageData(:final models) => models.toList(), - _ => [], - }; + final allStacks = appStacksState.models.toList(); final sortedStacks = _sortStacks( allStacks, @@ -134,12 +133,32 @@ class AllStacksScreen extends HookConsumerWidget { SliverToBoxAdapter( child: Padding( padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), - child: Text('App Stacks', style: context.textTheme.headlineMedium), + child: Text( + 'App Stacks', + style: context.textTheme.headlineMedium, + ), ), ), - if (appStacksState is StorageLoading && allStacks.isEmpty) - const SliverFillRemaining( - child: Center(child: CircularProgressIndicator()), + if (appStacksState is StorageLoading && sortedStacks.isEmpty) + 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) => SkeletonizerConfig( + data: AppColors.getSkeletonizerConfig( + Theme.of(context).brightness, + ), + child: const Skeletonizer(child: StackCardSkeleton()), + ), + childCount: _kPageSize, + ), + ), ) else if (sortedStacks.isEmpty) SliverFillRemaining( @@ -147,9 +166,16 @@ class AllStacksScreen extends HookConsumerWidget { child: Column( mainAxisSize: MainAxisSize.min, children: [ - Icon(Icons.layers_outlined, size: 48, color: Colors.grey[400]), + Icon( + Icons.layers_outlined, + size: 48, + color: Colors.grey[400], + ), const SizedBox(height: 16), - Text('No stacks found', style: context.textTheme.titleMedium), + Text( + 'No stacks found', + style: context.textTheme.titleMedium, + ), ], ), ), @@ -164,20 +190,17 @@ class AllStacksScreen extends HookConsumerWidget { 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, - ), + 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( @@ -192,14 +215,15 @@ class AllStacksScreen extends HookConsumerWidget { ), ) : sortedStacks.isNotEmpty - ? Center( - child: Text( - 'No more stacks to load', - style: Theme.of(context).textTheme.bodyMedium - ?.copyWith(color: Colors.grey[600]), - ), - ) - : const SizedBox.shrink(), + ? 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)), @@ -232,12 +256,16 @@ 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.sort((a, b) => b.event.createdAt.compareTo(a.event.createdAt)); + 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/screens/user_screen.dart b/lib/screens/user_screen.dart index efe4f02..c137516 100644 --- a/lib/screens/user_screen.dart +++ b/lib/screens/user_screen.dart @@ -527,6 +527,7 @@ class _RepublishStacksButton extends HookConsumerWidget { identifier: stack.identifier, platform: platform, ); + partialStack.addCommunityKey(kZapstoreCommunityPubkey); for (final appId in appIds) { partialStack.addApp(appId); @@ -536,7 +537,7 @@ class _RepublishStacksButton extends HookConsumerWidget { await ref.storage.save({signedStack}); ref.storage.publish({ signedStack, - }, source: RemoteSource(relays: 'social')); + }, source: RemoteSource(relays: {'social', 'AppCatalog'})); } progressCount.value++; diff --git a/lib/widgets/app_stack_container.dart b/lib/widgets/app_stack_container.dart index 01d6057..8a60e68 100644 --- a/lib/widgets/app_stack_container.dart +++ b/lib/widgets/app_stack_container.dart @@ -90,13 +90,7 @@ class AppStackContainer extends HookConsumerWidget { final visibleCount = useState(_kInitialStacks); if (showSkeleton) { - return Column( - mainAxisSize: MainAxisSize.min, - children: [ - _StackSectionHeader(showSeeAll: false), - _buildSkeleton(context), - ], - ); + return _buildSkeleton(context); } final signedInPubkey = ref.watch(Signer.activePubkeyProvider); @@ -111,7 +105,7 @@ class AppStackContainer extends HookConsumerWidget { tags: { '#f': {platform}, }, - source: const LocalAndRemoteSource(relays: 'social'), + source: const LocalAndRemoteSource(relays: 'AppCatalog'), subscriptionPrefix: 'app-stack', schemaFilter: appStackEventFilter, ), @@ -142,13 +136,7 @@ class AppStackContainer extends HookConsumerWidget { }; if (allStacks.isEmpty) { - return Column( - mainAxisSize: MainAxisSize.min, - children: [ - _StackSectionHeader(showSeeAll: false), - _buildSkeleton(context), - ], - ); + return _buildSkeleton(context); } // Sort stacks @@ -229,60 +217,85 @@ class AppStackContainer extends HookConsumerWidget { }, [scrollController, sortedStacks.length]); // 2-row horizontal scroll layout - final numColumns = (displayedStacks.length + 1) ~/ 2; + // Add a "See more" card after the last stack if there are more stacks + final showSeeMore = sortedStacks.length > _kInitialStacks; + // Total items = displayed stacks + optional "See more" placeholder + final totalItems = displayedStacks.length + (showSeeMore ? 1 : 0); + final numColumns = (totalItems + 1) ~/ 2; - 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, - ), - ], - ], + 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: [ + _buildItemAtIndex( + context, + col * 2, + displayedStacks, + authorsMap, + isAuthorLoading, + stackPreviewIds, + appsMap, + showSeeMore, + totalItems, ), - ), + if (col * 2 + 1 < totalItems) ...[ + const SizedBox(height: 10), + _buildItemAtIndex( + context, + col * 2 + 1, + displayedStacks, + authorsMap, + isAuthorLoading, + stackPreviewIds, + appsMap, + showSeeMore, + totalItems, + ), + ], + ], ), - ], - ), - ), - ], + ), + ), + ], + ), + ); + } + + Widget _buildItemAtIndex( + BuildContext context, + int index, + List displayedStacks, + Map authorsMap, + bool Function(String) isAuthorLoading, + Map> stackPreviewIds, + Map appsMap, + bool showSeeMore, + int totalItems, + ) { + final isSeeMore = showSeeMore && index == totalItems - 1; + if (isSeeMore) { + return _SeeMoreCard(); + } + if (index >= displayedStacks.length) return const SizedBox.shrink(); + final stack = displayedStacks[index]; + return StackCard( + stack: stack, + author: authorsMap[stack.event.pubkey], + isAuthorLoading: isAuthorLoading(stack.event.pubkey), + previewIdentifiers: stackPreviewIds[stack.id] ?? [], + appsMap: appsMap, ); } @@ -304,9 +317,9 @@ class AppStackContainer extends HookConsumerWidget { child: const Column( mainAxisSize: MainAxisSize.min, children: [ - _SkeletonStackCard(), + StackCardSkeleton(), SizedBox(height: 10), - _SkeletonStackCard(), + StackCardSkeleton(), ], ), ), @@ -319,47 +332,42 @@ 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; - +/// "See more" card that navigates to AllStacksScreen +class _SeeMoreCard extends StatelessWidget { @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, - ), - ), + return GestureDetector( + onTap: () { + final segments = GoRouterState.of(context).uri.pathSegments; + final first = segments.isNotEmpty ? segments.first : 'search'; + context.push('/$first/stacks'); + }, + child: Container( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(14), + border: Border.all( + color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.2), + width: 1, + ), + ), + padding: const EdgeInsets.all(12), + child: Center( + child: Text( + 'See more', + style: context.textTheme.titleMedium?.copyWith( + color: Theme.of(context).colorScheme.primary, ), - ], + ), + ), ), ); } } /// Skeleton card for loading state -class _SkeletonStackCard extends StatelessWidget { - const _SkeletonStackCard(); +class StackCardSkeleton extends StatelessWidget { + const StackCardSkeleton({super.key}); @override Widget build(BuildContext context) { diff --git a/lib/widgets/bookmark_widgets.dart b/lib/widgets/bookmark_widgets.dart index 1db883e..425d066 100644 --- a/lib/widgets/bookmark_widgets.dart +++ b/lib/widgets/bookmark_widgets.dart @@ -638,6 +638,7 @@ class _AddToStackDialogSignedIn extends HookConsumerWidget { identifier: collectionId, platform: platform, ); + partialStack.addCommunityKey(kZapstoreCommunityPubkey); // Add existing apps in order for (final appId in existingAppIds) { @@ -651,7 +652,7 @@ class _AddToStackDialogSignedIn extends HookConsumerWidget { await ref.storage.save({signedStack}); await ref.storage.publish({ signedStack, - }, source: RemoteSource(relays: 'social')); + }, source: RemoteSource(relays: {'social', 'AppCatalog'})); } } @@ -666,6 +667,7 @@ class _AddToStackDialogSignedIn extends HookConsumerWidget { identifier: collectionId, platform: platform, ); + partialStack.addCommunityKey(kZapstoreCommunityPubkey); // Re-add all apps except the one we're removing, preserving order // Get raw app IDs from event tags as a list to preserve order @@ -682,7 +684,7 @@ class _AddToStackDialogSignedIn extends HookConsumerWidget { await ref.storage.save({signedStack}); await ref.storage.publish({ signedStack, - }, source: RemoteSource(relays: 'social')); + }, source: RemoteSource(relays: {'social', 'AppCatalog'})); } } diff --git a/spec/guidelines/INVARIANTS.md b/spec/guidelines/INVARIANTS.md index 85d9d47..3b7c0d9 100644 --- a/spec/guidelines/INVARIANTS.md +++ b/spec/guidelines/INVARIANTS.md @@ -39,6 +39,11 @@ If any invariant is violated, the implementation is incorrect. - Parsing unknown, missing, or future tags must not crash the app. - Partial or invalid data must degrade gracefully. +## App Stack (kind 30267) Tag Rules + +- Encrypted stacks (private collections, e.g. "Saved Apps") MUST NOT include a community `p` tag or `h` tag. +- Only public stacks may carry a community `p` tag to associate them with a community. + ## Lifecycle Safety - Subscriptions must always be cancellable.