Minor fixes, app stacks screen

This commit is contained in:
franzap
2026-03-26 00:24:03 -03:00
parent 1ebfb12a84
commit 1449722297
6 changed files with 380 additions and 72 deletions
+17 -3
View File
@@ -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<GoRouter>((ref) {
state: state,
child: const SearchScreen(),
),
routes: [_appDetailRoute(), _stackDetailRoute(), _userRoute()],
routes: [_appDetailRoute(), _stackDetailRoute(), _allStacksRoute(), _userRoute()],
),
],
),
@@ -136,7 +150,7 @@ final routerProvider = Provider<GoRouter>((ref) {
state: state,
child: const UpdatesScreen(),
),
routes: [_appDetailRoute(), _stackDetailRoute(), _userRoute()],
routes: [_appDetailRoute(), _stackDetailRoute(), _allStacksRoute(), _userRoute()],
),
],
),
@@ -149,7 +163,7 @@ final routerProvider = Provider<GoRouter>((ref) {
state: state,
child: const ProfileScreen(),
),
routes: [_appDetailRoute(), _stackDetailRoute(), _userRoute()],
routes: [_appDetailRoute(), _stackDetailRoute(), _allStacksRoute(), _userRoute()],
),
],
),
+244
View File
@@ -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<AppStack>(
limit: 100,
tags: {
'#f': {platform},
},
source: const LocalAndRemoteSource(relays: 'social'),
subscriptionPrefix: 'app-all-stacks',
schemaFilter: appStackEventFilter,
),
);
final contactListState = signedInPubkey != null
? ref.watch(
query<ContactList>(
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(),
_ => <AppStack>[],
};
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<Profile>(
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>[])
profile.pubkey: profile,
};
final isAuthorsLoading = authorsState is StorageLoading;
// Batch load preview apps for displayed stacks
final allPreviewIdentifiers = <String>{};
final stackPreviewIds = <String, List<String>>{};
for (final stack in displayedStacks) {
final ids = getPreviewIdentifiers(stack);
stackPreviewIds[stack.id] = ids;
allPreviewIdentifiers.addAll(ids);
}
final previewAppsState = allPreviewIdentifiers.isNotEmpty
? ref.watch(
query<App>(
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>[])
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<AppStack> _sortStacks(
List<AppStack> stacks, {
String? signedInPubkey,
Set<String>? followingPubkeys,
}) {
if (signedInPubkey != null &&
followingPubkeys != null &&
followingPubkeys.isNotEmpty) {
final followed = <AppStack>[];
final others = <AppStack>[];
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];
}
}
+1 -1
View File
@@ -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
+114 -64
View File
@@ -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<String> _getRawAppTagValues(AppStack stack) {
Set<String> 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<String> _getPreviewIdentifiers(AppStack stack) {
final rawTags = _getRawAppTagValues(stack).toList()
List<String> getPreviewIdentifiers(AppStack stack) {
final rawTags = getRawAppTagValues(stack).toList()
..shuffle(Random(stack.id.hashCode));
return rawTags.take(3).map(_extractIdentifier).whereType<String>().toList();
return rawTags.take(3).map(extractStackIdentifier).whereType<String>().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 = <String>{};
final stackPreviewIds = <String, List<String>>{};
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
+1 -1
View File
@@ -294,7 +294,7 @@ class LatestReleasesNotifier extends StateNotifier<LatestReleasesState> {
}
final Ref ref;
static const int _pageSize = 10;
static const int _pageSize = 5;
ProviderSubscription<StorageState<App>>? _sub;
// We keep a fixed live head window from query(); older pages are appended
+3 -3
View File
@@ -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');
}