Stacks fixes

This commit is contained in:
franzap
2026-03-31 15:19:23 -03:00
parent 73f6e12118
commit bf23420bbb
7 changed files with 213 additions and 152 deletions
+4
View File
@@ -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';
+22 -9
View File
@@ -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<void> _noTransitionPage({
return CustomTransitionPage<void>(
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<GoRouter>((ref) {
state: state,
child: const SearchScreen(),
),
routes: [_appDetailRoute(), _stackDetailRoute(), _allStacksRoute(), _userRoute()],
routes: [
_appDetailRoute(),
_stackDetailRoute(),
_allStacksRoute(),
_userRoute(),
],
),
],
),
@@ -150,7 +153,12 @@ final routerProvider = Provider<GoRouter>((ref) {
state: state,
child: const UpdatesScreen(),
),
routes: [_appDetailRoute(), _stackDetailRoute(), _allStacksRoute(), _userRoute()],
routes: [
_appDetailRoute(),
_stackDetailRoute(),
_allStacksRoute(),
_userRoute(),
],
),
],
),
@@ -163,7 +171,12 @@ final routerProvider = Provider<GoRouter>((ref) {
state: state,
child: const ProfileScreen(),
),
routes: [_appDetailRoute(), _stackDetailRoute(), _allStacksRoute(), _userRoute()],
routes: [
_appDetailRoute(),
_stackDetailRoute(),
_allStacksRoute(),
_userRoute(),
],
),
],
),
@@ -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(),
_ => <AppStack>[],
};
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<AppStack> _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];
}
+2 -1
View File
@@ -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++;
+108 -100
View File
@@ -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<AppStack> displayedStacks,
Map<String, Profile> authorsMap,
bool Function(String) isAuthorLoading,
Map<String, List<String>> stackPreviewIds,
Map<String, App> 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) {
+4 -2
View File
@@ -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'}));
}
}
+5
View File
@@ -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.