diff --git a/lib/constants/app_constants.dart b/lib/constants/app_constants.dart index 8de0ffa..902b622 100644 --- a/lib/constants/app_constants.dart +++ b/lib/constants/app_constants.dart @@ -22,30 +22,41 @@ const kZapstoreCommunityPubkey = /// Identifier for storing user saved apps const kAppBookmarksIdentifier = 'zapstore-bookmarks'; -/// Event filter for app stacks - excludes saved apps and stacks with zero App references +/// Event filter for app stacks - must run as schemaFilter so rejected events +/// are never stored in local SQLite. +/// +/// Rejects: +/// - Private/encrypted stacks (non-empty content, kind 30267 only) +/// - The user's own saved-apps bookmark stack +/// - Stacks with no public App (32267) references bool appStackEventFilter(Map event) { + // Guard: only apply to AppStack events (kind 30267). + // Other kinds (e.g. App kind 32267) also use content and must not be rejected. + final kind = event['kind'] as int?; + if (kind != null && kind != 30267) return true; + + // Private stacks store encrypted app IDs in content — reject before storage + final content = event['content'] as String?; + if (content != null && content.isNotEmpty) return false; + final tags = event['tags'] as List?; if (tags == null) return false; - // Check for saved apps identifier in 'd' tag for (final tag in tags) { - if (tag is List && tag.isNotEmpty && tag[0] == 'd') { - if (tag.length > 1 && tag[1] == kAppBookmarksIdentifier) { - return false; - } + if (tag is! List || tag.isEmpty) continue; + if (tag[0] == 'd' && tag.length > 1 && tag[1] == kAppBookmarksIdentifier) { + return false; } } - // Check for at least one 'a' tag starting with '32267:' (App kind) for (final tag in tags) { - if (tag is List && tag.isNotEmpty && tag[0] == 'a') { - if (tag.length > 1 && tag[1] is String && tag[1].startsWith('32267:')) { - return true; - } + if (tag is! List || tag.isEmpty) continue; + if (tag[0] == 'a' && tag.length > 1 && + tag[1] is String && (tag[1] as String).startsWith('32267:')) { + return true; } } - // No valid App references found return false; } diff --git a/lib/router.dart b/lib/router.dart index 588a3f1..1e00fc2 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/stacks_screen.dart'; +import 'package:zapstore/screens/app_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'; @@ -91,7 +91,7 @@ GoRoute _allStacksRoute() { return GoRoute( path: 'stacks', pageBuilder: (context, state) { - return _noTransitionPage(state: state, child: const StacksScreen()); + return _noTransitionPage(state: state, child: const AppStacksScreen()); }, ); } diff --git a/lib/screens/app_stack_screen.dart b/lib/screens/app_stack_screen.dart index 8b03037..0c0f8a0 100644 --- a/lib/screens/app_stack_screen.dart +++ b/lib/screens/app_stack_screen.dart @@ -21,7 +21,6 @@ class AppStackScreen extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - // Query stack with apps and author relationships final stackState = ref.watch( query( authors: authorPubkey != null ? {authorPubkey!} : null, @@ -29,51 +28,52 @@ class AppStackScreen extends HookConsumerWidget { '#d': {stackId}, }, limit: 1, - source: LocalAndRemoteSource(stream: true, relays: 'social'), - subscriptionPrefix: 'app-stack-$stackId', + source: const LocalAndRemoteSource( + relays: 'AppCatalog', + stream: false, + ), + subscriptionPrefix: 'app-stack-detail-$stackId', ), ); - return switch (stackState) { - StorageError(:final exception) => _ErrorScaffold( - message: exception.toString(), - ), - StorageData(:final models) => _AppStackContentWithApps( - stack: models.firstOrNull, - ), - StorageLoading() => Scaffold( + if (stackState case StorageError(:final exception)) { + return _ErrorScaffold(message: exception.toString()); + } + + final stack = stackState.models.firstOrNull; + + if (stack == null) { + return Scaffold( body: SingleChildScrollView( padding: const EdgeInsets.all(16), child: _AppStackSkeleton(), ), - ), - }; + ); + } + + return _AppStackContentWithApps(stack: stack); } } /// Intermediate widget that loads apps with their release relationships class _AppStackContentWithApps extends HookConsumerWidget { - final AppStack? stack; + final AppStack stack; const _AppStackContentWithApps({required this.stack}); @override Widget build(BuildContext context, WidgetRef ref) { - if (stack == null) { - return const _ErrorScaffold(message: 'Stack not found'); - } - // Derive identifiers directly from the raw event tags — stable and complete // regardless of what's been cached locally. The 'a' tag format is // '32267:pubkey:d-tag'; we extract just the d-tag (identifier) part. - final appIdentifiers = stack!.event + final appIdentifiers = stack.event .getTagSetValues('a') .where((id) => id.startsWith('32267:')) .map((id) => id.split(':').skip(2).join(':')) .toSet(); if (appIdentifiers.isEmpty) { - return _AppStackContent(stack: stack!, apps: const []); + return _AppStackContent(stack: stack, apps: const []); } // Query apps with release and metadata relationships (same pattern as search/user screens) @@ -92,7 +92,7 @@ class _AppStackContentWithApps extends HookConsumerWidget { ), }, source: const LocalAndRemoteSource(relays: 'AppCatalog', stream: false), - subscriptionPrefix: 'app-stack-apps-${stack!.identifier}', + subscriptionPrefix: 'app-stack-apps-${stack.identifier}', ), ); @@ -103,7 +103,7 @@ class _AppStackContentWithApps extends HookConsumerWidget { .whereType() .toList(); - return _AppStackContent(stack: stack!, apps: orderedApps); + return _AppStackContent(stack: stack, apps: orderedApps); } } diff --git a/lib/screens/app_stacks_screen.dart b/lib/screens/app_stacks_screen.dart new file mode 100644 index 0000000..beeea3e --- /dev/null +++ b/lib/screens/app_stacks_screen.dart @@ -0,0 +1,532 @@ +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/notification_service.dart'; +import 'package:zapstore/services/package_manager/package_manager.dart'; +import 'package:zapstore/theme.dart'; +import 'package:zapstore/utils/extensions.dart'; +import 'package:zapstore/utils/paged_subscription_notifier.dart'; +import 'package:zapstore/widgets/app_stack_container.dart'; + +const int _kPageSize = 20; + +// --------------------------------------------------------------------------- +// Notifier & provider +// --------------------------------------------------------------------------- + +class StacksNotifier extends PagedSubscriptionNotifier { + StacksNotifier(super.ref, {required this.platform}); + + final String platform; + ProviderSubscription>? _sub; + + Map> get _tags => { + '#f': {platform}, + '#h': {kZapstoreCommunityPubkey}, + }; + + @override + int get pageSize => _kPageSize; + + @override + void startSubscription() { + _sub?.close(); + _sub = ref.listen( + query( + limit: pageSize, + tags: _tags, + source: const LocalAndRemoteSource(relays: 'AppCatalog', stream: true), + subscriptionPrefix: 'app-stacks', + schemaFilter: appStackEventFilter, + ), + (_, next) => updateFirstPage(next), + fireImmediately: true, + ); + } + + @override + Future<({List items, int count})> fetchOlderPage( + DateTime until, + ) async { + final storage = ref.read(storageNotifierProvider.notifier); + final items = await storage.query( + RequestFilter( + tags: _tags, + until: until, + limit: pageSize, + ).toRequest(), + source: const LocalAndRemoteSource(relays: 'AppCatalog', stream: false), + subscriptionPrefix: 'app-stacks-older', + ); + return (items: items, count: items.length); + } + + @override + String getId(AppStack item) => item.id; + + @override + DateTime getCreatedAt(AppStack item) => item.event.createdAt; + + @override + void dispose() { + _sub?.close(); + super.dispose(); + } +} + +final stacksProvider = + StateNotifierProvider>((ref) { + final platform = ref.read(packageManagerProvider.notifier).platform; + return StacksNotifier(ref, platform: platform); + }); + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/// Returns true if the stack is public (no encrypted content) and is missing +/// required tags: h (community) or f (platform). +bool stackNeedsMigration(AppStack stack, String platform) { + // Skip private/encrypted stacks + if (stack.content.isNotEmpty) return false; + + // Skip the user's saved-apps bookmark stack + if (stack.identifier == kAppBookmarksIdentifier) return false; + + final hTags = stack.event.getTagSetValues('h'); + final fTags = stack.event.getTagSetValues('f'); + + final hasH = hTags.contains(kZapstoreCommunityPubkey); + final hasF = fTags.contains(platform); + + return !hasH || !hasF; +} + +// --------------------------------------------------------------------------- +// Screen +// --------------------------------------------------------------------------- + +class AppStacksScreen extends HookConsumerWidget { + const AppStacksScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final scrollController = useScrollController(); + final platform = ref.read(packageManagerProvider.notifier).platform; + + final state = ref.watch(stacksProvider); + final items = state.combined; + + // Query signed-in user's stacks that may need migration + final signedInPubkey = ref.watch(Signer.activePubkeyProvider); + final userStacksState = signedInPubkey != null + ? ref.watch( + query( + authors: {signedInPubkey}, + where: (s) => stackNeedsMigration(s, platform), + source: LocalAndRemoteSource( + relays: {'social', 'AppCatalog'}, + stream: false, + ), + subscriptionPrefix: 'app-user-stacks-migration', + ), + ) + : null; + + final unmigrated = userStacksState?.models.toList() ?? []; + + final authorPubkeys = items.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; + + final allPreviewIdentifiers = {}; + final stackPreviewIds = >{}; + for (final stack in items) { + 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, + }; + + useEffect(() { + void onScroll() { + final s = ref.read(stacksProvider); + if (s.isLoadingMore || !s.hasMore) return; + if (scrollController.position.pixels >= + scrollController.position.maxScrollExtent - 300) { + ref.read(stacksProvider.notifier).loadMore(); + } + } + + scrollController.addListener(onScroll); + return () => scrollController.removeListener(onScroll); + }, [scrollController]); + + final isInitialLoading = state.firstPage is StorageLoading && items.isEmpty; + + 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 (unmigrated.isNotEmpty) + SliverToBoxAdapter( + child: _MigrationBanner(stacks: unmigrated, platform: platform), + ), + if (isInitialLoading) + 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: 10, + ), + ), + ) + else if (items.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 = items[index]; + final author = authorsMap[stack.event.pubkey]; + return StackCard( + stack: stack, + author: author, + isAuthorLoading: isAuthorsLoading && author == null, + previewIdentifiers: stackPreviewIds[stack.id] ?? [], + appsMap: appsMap, + showAuthor: true, + ); + }, childCount: items.length), + ), + ), + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 16), + child: state.isLoadingMore + ? Center( + child: SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator( + strokeWidth: 3, + color: Theme.of(context).colorScheme.primary, + ), + ), + ) + : !state.hasMore && items.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)), + ], + ), + ); + } +} + +// --------------------------------------------------------------------------- +// Migration Banner +// --------------------------------------------------------------------------- + +class _MigrationBanner extends HookConsumerWidget { + const _MigrationBanner({required this.stacks, required this.platform}); + + final List stacks; + final String platform; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final isLoading = useState(false); + final progressCount = useState(0); + + // Query authors for the pending stacks + final authorPubkeys = stacks.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-migration-stacks-authors', + ), + ) + : null; + final authorsMap = { + for (final profile in authorsState?.models ?? []) + profile.pubkey: profile, + }; + + final allPreviewIdentifiers = {}; + final stackPreviewIds = >{}; + for (final stack in stacks) { + final ids = getPreviewIdentifiers(stack); + stackPreviewIds[stack.id] = ids; + allPreviewIdentifiers.addAll(ids); + } + + // Query preview apps + final previewAppsState = allPreviewIdentifiers.isNotEmpty + ? ref.watch( + query( + tags: {'#d': allPreviewIdentifiers}, + source: const LocalAndRemoteSource( + relays: 'AppCatalog', + stream: false, + ), + subscriptionPrefix: 'app-migration-stacks-preview-apps', + ), + ) + : null; + final appsMap = { + for (final app in previewAppsState?.models ?? []) + app.identifier: app, + }; + + return Container( + margin: const EdgeInsets.fromLTRB(12, 0, 12, 12), + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Theme.of( + context, + ).colorScheme.primaryContainer.withValues(alpha: 0.4), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: Theme.of(context).colorScheme.primary.withValues(alpha: 0.3), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.info_outline, + size: 20, + color: Theme.of(context).colorScheme.primary, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + 'You have ${stacks.length} stack${stacks.length == 1 ? '' : 's'} that need${stacks.length == 1 ? 's' : ''} updating', + style: context.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + const SizedBox(height: 8), + Text( + 'Update your stacks so they appear in the community feed. ' + 'Coming soon: ability to delete them.', + style: context.textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 16), + GridView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 2, + crossAxisSpacing: 10, + mainAxisSpacing: 10, + childAspectRatio: 1.15, + ), + itemCount: stacks.length, + itemBuilder: (context, index) { + final stack = stacks[index]; + final author = authorsMap[stack.event.pubkey]; + return StackCard( + stack: stack, + author: author, + isAuthorLoading: + authorsState is StorageLoading && author == null, + previewIdentifiers: stackPreviewIds[stack.id] ?? [], + appsMap: appsMap, + showAuthor: false, + ); + }, + ), + const SizedBox(height: 16), + SizedBox( + width: double.infinity, + child: FilledButton.icon( + onPressed: isLoading.value + ? null + : () => _migrateStacks( + context, + ref, + isLoading, + progressCount, + stacks, + ), + icon: isLoading.value + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.upgrade, size: 18), + label: Text( + isLoading.value + ? 'Updating ${progressCount.value}/${stacks.length}...' + : 'Update stacks', + ), + ), + ), + ], + ), + ); + } + + Future _migrateStacks( + BuildContext context, + WidgetRef ref, + ValueNotifier isLoading, + ValueNotifier progressCount, + List pendingStacks, + ) async { + isLoading.value = true; + progressCount.value = 0; + + try { + final signer = ref.read(Signer.activeSignerProvider); + if (signer == null) { + if (context.mounted) { + context.showError('Sign in required'); + } + return; + } + + for (final stack in pendingStacks) { + final appIds = stack.event.getTagSetValues('a').toList(); + + final partialStack = PartialAppStack( + name: stack.name ?? stack.identifier, + identifier: stack.identifier, + platform: platform, + ); + partialStack.addCommunityKey(kZapstoreCommunityPubkey); + + for (final appId in appIds) { + partialStack.addApp(appId); + } + + // Add one second so relays accept the replacement + partialStack.event.createdAt = stack.event.createdAt.add(const Duration(seconds: 1)); + + final signedStack = await partialStack.signWith(signer); + await ref.storage.save({signedStack}); + ref.storage.publish({ + signedStack, + }, source: RemoteSource(relays: {'social', 'AppCatalog'})); + + progressCount.value++; + } + + if (context.mounted) { + context.showInfo( + 'Updated ${progressCount.value} stack${progressCount.value == 1 ? '' : 's'}', + ); + } + } catch (e) { + if (context.mounted) { + context.showError('Failed to update stacks', technicalDetails: '$e'); + } + } finally { + isLoading.value = false; + } + } +} diff --git a/lib/screens/profile_screen.dart b/lib/screens/profile_screen.dart index 8235c60..5bcd2f0 100644 --- a/lib/screens/profile_screen.dart +++ b/lib/screens/profile_screen.dart @@ -24,6 +24,7 @@ import 'package:zapstore/services/notification_service.dart'; import 'package:zapstore/widgets/common/note_parser.dart'; import 'package:zapstore/widgets/nwc_widgets.dart'; import 'package:zapstore/widgets/relay_management_card.dart'; +import 'package:zapstore/screens/app_stacks_screen.dart'; /// Profile screen for authentication and app settings class ProfileScreen extends ConsumerWidget { @@ -201,6 +202,7 @@ class _AuthenticationSection extends ConsumerWidget { tapTargetSize: MaterialTapTargetSize.shrinkWrap, ), ), + _StackMigrationWarning(pubkey: pubkey), ], ); } @@ -257,6 +259,77 @@ class _AuthenticationSection extends ConsumerWidget { } } +class _StackMigrationWarning extends ConsumerWidget { + const _StackMigrationWarning({required this.pubkey}); + + final String pubkey; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final platform = ref.read(packageManagerProvider.notifier).platform; + final stacksState = ref.watch( + query( + authors: {pubkey}, + where: (s) => stackNeedsMigration(s, platform), + source: LocalAndRemoteSource( + relays: {'social', 'AppCatalog'}, + stream: false, + ), + subscriptionPrefix: 'app-user-stacks-migration-profile', + ), + ); + + final count = stacksState.models.length; + if (count == 0) return const SizedBox.shrink(); + + return Padding( + padding: const EdgeInsets.only(top: 12), + child: InkWell( + onTap: () { + final segments = GoRouterState.of(context).uri.pathSegments; + final first = segments.isNotEmpty ? segments.first : 'search'; + context.push('/$first/stacks'); + }, + borderRadius: BorderRadius.circular(8), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.errorContainer.withValues(alpha: 0.5), + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: Theme.of(context).colorScheme.error.withValues(alpha: 0.4), + ), + ), + child: Row( + children: [ + Icon( + Icons.warning_amber_rounded, + size: 16, + color: Theme.of(context).colorScheme.error, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + '$count stack${count == 1 ? '' : 's'} need${count == 1 ? 's' : ''} updating — tap to fix', + style: context.textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onErrorContainer, + fontWeight: FontWeight.w500, + ), + ), + ), + Icon( + Icons.arrow_forward, + size: 14, + color: Theme.of(context).colorScheme.error, + ), + ], + ), + ), + ), + ); + } +} + // Sign in button with Amber installation check class _SignInButtonWithAmberCheck extends ConsumerWidget { const _SignInButtonWithAmberCheck({required this.isAmberInstalled}); diff --git a/lib/screens/stacks_screen.dart b/lib/screens/stacks_screen.dart deleted file mode 100644 index 9caa762..0000000 --- a/lib/screens/stacks_screen.dart +++ /dev/null @@ -1,272 +0,0 @@ -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 = 6; - -class StacksScreen extends HookConsumerWidget { - const StacksScreen({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: 'AppCatalog'), - subscriptionPrefix: 'app-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 = appStacksState.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 && 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( - 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/screens/user_screen.dart b/lib/screens/user_screen.dart index 9d894ab..59d8c18 100644 --- a/lib/screens/user_screen.dart +++ b/lib/screens/user_screen.dart @@ -1,12 +1,8 @@ -import 'dart:convert'; - 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:zapstore/services/notification_service.dart'; -import 'package:zapstore/services/package_manager/package_manager.dart'; import 'package:zapstore/utils/extensions.dart'; import '../theme.dart'; import '../widgets/common/note_parser.dart'; @@ -141,10 +137,6 @@ class UserScreen extends HookConsumerWidget { return _StackLinkCard(stack: stack, pubkey: pubkey); }, childCount: stacks.length), ), - // Republish stacks button - only for signed-in user viewing their own profile - SliverToBoxAdapter( - child: _RepublishStacksButton(stacks: stacks, pubkey: pubkey), - ), ], // Bottom padding @@ -443,135 +435,3 @@ class _UserBio extends HookWidget { } } -class _RepublishStacksButton extends HookConsumerWidget { - const _RepublishStacksButton({ - required this.stacks, - required this.pubkey, - }); - - final List stacks; - final String pubkey; - - @override - Widget build(BuildContext context, WidgetRef ref) { - final signedInPubkey = ref.watch(Signer.activePubkeyProvider); - - // Only show if user is viewing their own profile - if (signedInPubkey == null || signedInPubkey != pubkey) { - return const SizedBox.shrink(); - } - - final isLoading = useState(false); - final progressCount = useState(0); - - return Padding( - padding: const EdgeInsets.fromLTRB(16, 16, 16, 0), - child: FilledButton.icon( - onPressed: isLoading.value - ? null - : () async { - isLoading.value = true; - progressCount.value = 0; - try { - final signer = ref.read(Signer.activeSignerProvider); - if (signer == null) { - if (context.mounted) { - context.showError('Sign in required'); - } - return; - } - - final platform = - ref.read(packageManagerProvider.notifier).platform; - - for (final stack in stacks) { - // Get existing app IDs from the stack - final appIds = stack.event.getTagSetValues('a').toList(); - - // Check if it's an encrypted stack (has content but no 'a' tags) - final isEncrypted = - stack.content.isNotEmpty && appIds.isEmpty; - - if (isEncrypted) { - // For encrypted stacks, decrypt, then re-encrypt with platform - try { - final decryptedContent = await signer.nip44Decrypt( - stack.content, - signedInPubkey, - ); - final existingAppIds = - (jsonDecode(decryptedContent) as List) - .cast(); - - final partialStack = PartialAppStack.withEncryptedApps( - name: stack.name ?? stack.identifier, - identifier: stack.identifier, - apps: existingAppIds, - platform: platform, - ); - - final signedStack = - await partialStack.signWith(signer); - await ref.storage.save({signedStack}); - ref.storage.publish({ - signedStack, - }, source: RemoteSource(relays: 'social')); - } catch (e) { - // Skip stacks that can't be decrypted - continue; - } - } else { - // For public stacks, rebuild with platform - final partialStack = PartialAppStack( - name: stack.name ?? stack.identifier, - identifier: stack.identifier, - platform: platform, - ); - partialStack.addCommunityKey(kZapstoreCommunityPubkey); - - for (final appId in appIds) { - partialStack.addApp(appId); - } - - final signedStack = await partialStack.signWith(signer); - await ref.storage.save({signedStack}); - ref.storage.publish({ - signedStack, - }, source: RemoteSource(relays: {'social', 'AppCatalog'})); - } - - progressCount.value++; - } - - if (context.mounted) { - context.showInfo( - 'Republished ${progressCount.value} stacks with platform tag', - ); - } - } catch (e) { - if (context.mounted) { - context.showError( - 'Failed to republish stacks', - technicalDetails: '$e', - ); - } - } finally { - isLoading.value = false; - } - }, - icon: isLoading.value - ? const SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : const Icon(Icons.publish, size: 18), - label: Text( - isLoading.value - ? 'Republishing ${progressCount.value}/${stacks.length}...' - : 'Republish stacks (${stacks.length})', - ), - ), - ); - } -} diff --git a/lib/utils/paged_subscription_notifier.dart b/lib/utils/paged_subscription_notifier.dart new file mode 100644 index 0000000..547162b --- /dev/null +++ b/lib/utils/paged_subscription_notifier.dart @@ -0,0 +1,135 @@ +import 'package:flutter/foundation.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:models/models.dart'; + +/// State for a chronologically-ordered, paged list backed by a live subscription. +/// +/// [firstPage] is the streaming first-page result — local cache is shown +/// immediately and updated as remote events arrive in the background. +/// [olderItems] accumulates items loaded via scroll-triggered [loadMore]. +class PagedState> { + final StorageState firstPage; + final List olderItems; + final bool isLoadingMore; + final bool hasMore; + + const PagedState({ + required this.firstPage, + required this.olderItems, + required this.isLoadingMore, + required this.hasMore, + }); + + factory PagedState.initial() => PagedState( + firstPage: StorageLoading(const []), + olderItems: const [], + isLoadingMore: false, + hasMore: true, + ); + + /// All currently loaded items: live first page + accumulated older pages. + List get combined => [...firstPage.models, ...olderItems]; + + PagedState copyWith({ + StorageState? firstPage, + List? olderItems, + bool? isLoadingMore, + bool? hasMore, + }) => PagedState( + firstPage: firstPage ?? this.firstPage, + olderItems: olderItems ?? this.olderItems, + isLoadingMore: isLoadingMore ?? this.isLoadingMore, + hasMore: hasMore ?? this.hasMore, + ); +} + +/// Base notifier for a descending-date list with live subscription + infinite scroll. +/// +/// ## Pattern +/// - **First page**: `stream: true` — serves local cache immediately, then merges +/// new relay events as they arrive in the background (local-first). +/// - **Older pages**: one-shot fetches (`stream: false`) with an `until` cursor +/// derived from the oldest loaded item. Never re-subscribed. +/// +/// ## Subclass contract +/// 1. Override [startSubscription] — call `ref.listen` with `stream: true` and +/// forward each update to [updateFirstPage]. +/// 2. Override [fetchOlderPage] — imperatively fetch one page before [until] +/// with `stream: false`. Return items and raw event count. +/// 3. Override [getId] and [getCreatedAt] for deduplication and cursor. +abstract class PagedSubscriptionNotifier> + extends StateNotifier> { + PagedSubscriptionNotifier(this.ref) : super(PagedState.initial()) { + startSubscription(); + } + + final Ref ref; + + /// Items per page. Must match the `limit` used in both queries. + int get pageSize => 5; + + /// Set up the first-page subscription with `stream: true`. + /// Must call [updateFirstPage] on every state update. + void startSubscription(); + + /// Fetch one page of items with creation time strictly before [until]. + /// Use `stream: false`. Returns items and raw event count for has-more detection. + Future<({List items, int count})> fetchOlderPage(DateTime until); + + /// Stable unique ID for deduplication across pages. + String getId(T item); + + /// Creation timestamp used as the pagination cursor. + DateTime getCreatedAt(T item); + + /// Called by [startSubscription] with each incoming first-page state. + /// Merges live data and removes stale duplicates from older pages. + @protected + void updateFirstPage(StorageState next) { + if (next is StorageData) { + final liveIds = next.models.map(getId).toSet(); + final filteredOlder = state.olderItems + .where((item) => !liveIds.contains(getId(item))) + .toList(); + state = state.copyWith(firstPage: next, olderItems: filteredOlder); + } else { + state = state.copyWith(firstPage: next); + } + if (next is StorageError) { + state = state.copyWith(isLoadingMore: false); + } + } + + /// Load the next page of older items. Safe to call from scroll listeners. + Future loadMore() async { + final combined = state.combined; + if (state.isLoadingMore || !state.hasMore || combined.isEmpty) return; + + final oldest = combined + .map(getCreatedAt) + .reduce((a, b) => a.isBefore(b) ? a : b) + .subtract(const Duration(milliseconds: 1)); + + state = state.copyWith(isLoadingMore: true); + + try { + final result = await fetchOlderPage(oldest); + if (result.items.isNotEmpty) { + final existingIds = combined.map(getId).toSet(); + final unique = result.items + .where((item) => !existingIds.contains(getId(item))) + .toList(); + state = state.copyWith( + olderItems: [...state.olderItems, ...unique], + isLoadingMore: false, + hasMore: result.count >= pageSize, + ); + } else { + state = state.copyWith(isLoadingMore: false, hasMore: false); + } + } catch (e) { + state = state.copyWith(isLoadingMore: false); + rethrow; + } + } +} diff --git a/lib/widgets/app_stack_container.dart b/lib/widgets/app_stack_container.dart index 8cd6059..fc4e477 100644 --- a/lib/widgets/app_stack_container.dart +++ b/lib/widgets/app_stack_container.dart @@ -14,7 +14,7 @@ import 'common/profile_avatar.dart'; import 'common/profile_name_widget.dart'; /// Number of stacks to show initially and load per batch -const int _kInitialStacks = 6; +const int _kInitialStacks = 8; const int _kBatchSize = 6; /// Get the raw `a` tag values from the stack's event (available immediately) @@ -35,46 +35,12 @@ List getPreviewIdentifiers(AppStack stack) { return rawTags.take(3).map(extractStackIdentifier).whereType().toList(); } -/// Sort app stacks: franzap/following first, then by recency -List _sortStacks( - List stacks, { - String? signedInPubkey, - Set? followingPubkeys, -}) { +/// Shuffle stacks with a daily seed for variety +List _shuffleStacks(List stacks, {String? signedInPubkey}) { final today = DateTime.now(); final dateSeed = today.year * 10000 + today.month * 100 + today.day; final userSeed = signedInPubkey?.hashCode ?? 0; - final random = Random(dateSeed ^ userSeed); - - 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.shuffle(random); - 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.shuffle(random); - otherStacks.sort((a, b) => b.event.createdAt.compareTo(a.event.createdAt)); - return [...franzapStacks, ...otherStacks]; - } + return stacks.toList()..shuffle(Random(dateSeed ^ userSeed)); } /// App Stack Container - horizontally scrollable 2-row grid of stack cards @@ -95,13 +61,13 @@ class AppStackContainer extends HookConsumerWidget { final signedInPubkey = ref.watch(Signer.activePubkeyProvider); - // Get platform from package manager for filtering final platform = ref.read(packageManagerProvider.notifier).platform; - // Query 30 stacks WITHOUT loading apps (fast, from local storage) + // Only show stacks curated by the zapstore community final appStacksState = ref.watch( query( - limit: 30, + authors: {kZapstoreCommunityPubkey}, + limit: 20, tags: { '#f': {platform}, }, @@ -111,62 +77,19 @@ class AppStackContainer extends HookConsumerWidget { ), ); - // Get contact list for sorting - final contactListState = signedInPubkey != null - ? ref.watch( - query( - authors: {signedInPubkey}, - limit: 1, - source: const LocalAndRemoteSource( - relays: 'social', - stream: false, - cachedFor: Duration(hours: 1), - ), - subscriptionPrefix: 'app-stack-contacts', - ), - ) - : null; - - final followingPubkeys = - contactListState?.models.firstOrNull?.followingPubkeys; - final allStacks = appStacksState.models.toList(); if (allStacks.isEmpty) { return _buildSkeleton(context); } - // Sort stacks - final sortedStacks = _sortStacks( + final sortedStacks = _shuffleStacks( allStacks, signedInPubkey: signedInPubkey, - followingPubkeys: followingPubkeys, ); - // Only show up to visibleCount final displayedStacks = sortedStacks.take(visibleCount.value).toList(); - // Batch load author profiles for displayed stacks - final authorPubkeys = displayedStacks.map((s) => s.event.pubkey).toSet(); - final authorsState = ref.watch( - query( - authors: authorPubkeys, - source: const LocalAndRemoteSource( - relays: {'social', 'vertex'}, - cachedFor: Duration(hours: 2), - ), - subscriptionPrefix: 'app-stack-authors', - ), - ); - final authorsMap = { - for (final profile in authorsState.models) profile.pubkey: profile, - }; - final isAuthorsLoading = authorsState is StorageLoading; - - // Helper to check if a specific author is loading (loading AND not in cache) - bool isAuthorLoading(String pubkey) => - isAuthorsLoading && authorsMap[pubkey] == null; - // Batch load preview apps for displayed stacks (3 per stack) final allPreviewIdentifiers = {}; final stackPreviewIds = >{}; @@ -213,10 +136,8 @@ class AppStackContainer extends HookConsumerWidget { return () => scrollController.removeListener(onScroll); }, [scrollController, sortedStacks.length]); - // 2-row horizontal scroll layout - // Add a "See more" card after the last stack if there are more stacks + // 2-row horizontal scroll layout with "See more" card 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; @@ -240,8 +161,6 @@ class AppStackContainer extends HookConsumerWidget { context, col * 2, displayedStacks, - authorsMap, - isAuthorLoading, stackPreviewIds, appsMap, showSeeMore, @@ -253,8 +172,6 @@ class AppStackContainer extends HookConsumerWidget { context, col * 2 + 1, displayedStacks, - authorsMap, - isAuthorLoading, stackPreviewIds, appsMap, showSeeMore, @@ -274,8 +191,6 @@ class AppStackContainer extends HookConsumerWidget { BuildContext context, int index, List displayedStacks, - Map authorsMap, - bool Function(String) isAuthorLoading, Map> stackPreviewIds, Map appsMap, bool showSeeMore, @@ -289,8 +204,7 @@ class AppStackContainer extends HookConsumerWidget { final stack = displayedStacks[index]; return StackCard( stack: stack, - author: authorsMap[stack.event.pubkey], - isAuthorLoading: isAuthorLoading(stack.event.pubkey), + showAuthor: false, previewIdentifiers: stackPreviewIds[stack.id] ?? [], appsMap: appsMap, ); @@ -415,10 +329,11 @@ class StackCard extends StatelessWidget { const StackCard({ super.key, required this.stack, - required this.author, required this.previewIdentifiers, required this.appsMap, + this.author, this.isAuthorLoading = false, + this.showAuthor = true, }); final AppStack stack; @@ -426,12 +341,12 @@ class StackCard extends StatelessWidget { final List previewIdentifiers; final Map appsMap; final bool isAuthorLoading; + final bool showAuthor; @override Widget build(BuildContext context) { final totalApps = getRawAppTagValues(stack).length; - // Resolve preview apps from the pre-loaded map final previewApps = previewIdentifiers .map((id) => appsMap[id]) .whereType() @@ -476,22 +391,24 @@ class StackCard extends StatelessWidget { fontSize: (context.textTheme.titleMedium?.fontSize ?? 16) * 0.9, ), ), - const SizedBox(height: 6), - Row( - children: [ - ProfileAvatar(profile: author, radius: 9), - const SizedBox(width: 5), - Expanded( - child: ProfileNameWidget( - pubkey: stack.event.pubkey, - profile: author, - isLoading: isAuthorLoading, - style: profileStyle, - skeletonWidth: 80, + if (showAuthor) ...[ + const SizedBox(height: 6), + Row( + children: [ + ProfileAvatar(profile: author, radius: 9), + const SizedBox(width: 5), + Expanded( + child: ProfileNameWidget( + pubkey: stack.event.pubkey, + profile: author, + isLoading: isAuthorLoading, + style: profileStyle, + skeletonWidth: 80, + ), ), - ), - ], - ), + ], + ), + ], const SizedBox(height: 8), _AppIconsRow(apps: previewApps, totalApps: totalApps), ], diff --git a/spec/guidelines/ARCHITECTURE.md b/spec/guidelines/ARCHITECTURE.md index 8b5cd4f..74793e0 100644 --- a/spec/guidelines/ARCHITECTURE.md +++ b/spec/guidelines/ARCHITECTURE.md @@ -100,6 +100,95 @@ onPressed: () async { } ``` +### Paged lists with live subscription (infinite scroll) + +Use `PagedSubscriptionNotifier` (`lib/utils/paged_subscription_notifier.dart`) +for any list that is sorted descending by date, supports infinite scroll, and +should reflect new items without user action. + +**Rules:** + +- The **first page only** uses `stream: true`. It serves local cache immediately + and merges relay events in the background (local-first). +- **All older pages** use `stream: false` with an `until` cursor pointing 1 ms + before the oldest loaded item. Never subscribe to older pages. +- Scroll listeners call `notifier.loadMore()` — the base class handles + deduplication, the `isLoadingMore` guard, and `hasMore` detection. + +**How to implement:** + +```dart +class MyNotifier extends PagedSubscriptionNotifier { + MyNotifier(super.ref); + + ProviderSubscription>? _sub; + + @override int get pageSize => 10; + + @override + void startSubscription() { + _sub?.close(); + _sub = ref.listen( + query( + limit: pageSize, + source: const LocalAndRemoteSource(relays: 'AppCatalog', stream: true), + subscriptionPrefix: 'app-my-list', + ), + (_, next) => updateFirstPage(next), + fireImmediately: true, + ); + } + + @override + Future<({List items, int count})> fetchOlderPage(DateTime until) async { + final items = await ref.storage.query( + RequestFilter(until: until, limit: pageSize).toRequest(), + source: const LocalAndRemoteSource(stream: false), + subscriptionPrefix: 'app-my-list-older', + ); + return (items: items, count: items.length); + } + + @override String getId(MyModel item) => item.id; + @override DateTime getCreatedAt(MyModel item) => item.event.createdAt; + + @override void dispose() { _sub?.close(); super.dispose(); } +} + +final myListProvider = StateNotifierProvider>( + (ref) => MyNotifier(ref), +); +``` + +**In the widget:** + +```dart +// Infinite scroll trigger +useEffect(() { + void onScroll() { + final s = ref.read(myListProvider); + if (s.isLoadingMore || !s.hasMore) return; + if (scrollController.position.pixels >= + scrollController.position.maxScrollExtent - 300) { + ref.read(myListProvider.notifier).loadMore(); + } + } + scrollController.addListener(onScroll); + return () => scrollController.removeListener(onScroll); +}, [scrollController]); + +// Consume state +final state = ref.watch(myListProvider); +final items = state.combined; // first page + all older pages + +// Loading / error / content +if (state.firstPage is StorageLoading && items.isEmpty) { /* skeleton */ } +else if (state.firstPage is StorageError) { /* error */ } +else { /* list */ } +``` + +**Examples:** `LatestReleasesNotifier` (apps via assets), `StacksNotifier` (stacks directly). + ### Subscription prefix naming All queries using the `AppCatalog` relay group MUST prefix their `subscriptionPrefix` with `app-`. This is used in the backend. diff --git a/spec/guidelines/INVARIANTS.md b/spec/guidelines/INVARIANTS.md index 3b7c0d9..cb0119d 100644 --- a/spec/guidelines/INVARIANTS.md +++ b/spec/guidelines/INVARIANTS.md @@ -41,8 +41,9 @@ If any invariant is violated, the implementation is incorrect. ## App Stack (kind 30267) Tag Rules -- Encrypted stacks (private collections, e.g. "Saved Apps") MUST NOT include a community `p` tag or `h` tag. +- Encrypted stacks (private collections, e.g. "Saved Apps") MUST NOT include a community `p` tag. - Only public stacks may carry a community `p` tag to associate them with a community. +- The `h` tag is used for relay grouping/routing and may appear on both public and private stacks. ## Lifecycle Safety