mirror of
https://github.com/zapstore/zapstore.git
synced 2026-10-05 12:38:24 +00:00
New stacks screen + stack migration
This commit is contained in:
@@ -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<String, dynamic> 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<dynamic>?;
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
+2
-2
@@ -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());
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<AppStack>(
|
||||
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<App>()
|
||||
.toList();
|
||||
|
||||
return _AppStackContent(stack: stack!, apps: orderedApps);
|
||||
return _AppStackContent(stack: stack, apps: orderedApps);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<AppStack> {
|
||||
StacksNotifier(super.ref, {required this.platform});
|
||||
|
||||
final String platform;
|
||||
ProviderSubscription<StorageState<AppStack>>? _sub;
|
||||
|
||||
Map<String, Set<String>> get _tags => {
|
||||
'#f': {platform},
|
||||
'#h': {kZapstoreCommunityPubkey},
|
||||
};
|
||||
|
||||
@override
|
||||
int get pageSize => _kPageSize;
|
||||
|
||||
@override
|
||||
void startSubscription() {
|
||||
_sub?.close();
|
||||
_sub = ref.listen(
|
||||
query<AppStack>(
|
||||
limit: pageSize,
|
||||
tags: _tags,
|
||||
source: const LocalAndRemoteSource(relays: 'AppCatalog', stream: true),
|
||||
subscriptionPrefix: 'app-stacks',
|
||||
schemaFilter: appStackEventFilter,
|
||||
),
|
||||
(_, next) => updateFirstPage(next),
|
||||
fireImmediately: true,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<({List<AppStack> items, int count})> fetchOlderPage(
|
||||
DateTime until,
|
||||
) async {
|
||||
final storage = ref.read(storageNotifierProvider.notifier);
|
||||
final items = await storage.query(
|
||||
RequestFilter<AppStack>(
|
||||
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<StacksNotifier, PagedState<AppStack>>((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<AppStack>(
|
||||
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<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;
|
||||
|
||||
final allPreviewIdentifiers = <String>{};
|
||||
final stackPreviewIds = <String, List<String>>{};
|
||||
for (final stack in items) {
|
||||
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,
|
||||
};
|
||||
|
||||
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<AppStack> 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<Profile>(
|
||||
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>[])
|
||||
profile.pubkey: profile,
|
||||
};
|
||||
|
||||
final allPreviewIdentifiers = <String>{};
|
||||
final stackPreviewIds = <String, List<String>>{};
|
||||
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<App>(
|
||||
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>[])
|
||||
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<void> _migrateStacks(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
ValueNotifier<bool> isLoading,
|
||||
ValueNotifier<int> progressCount,
|
||||
List<AppStack> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<AppStack>(
|
||||
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});
|
||||
|
||||
@@ -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<AppStack>(
|
||||
limit: 100,
|
||||
tags: {
|
||||
'#f': {platform},
|
||||
},
|
||||
source: const LocalAndRemoteSource(relays: 'AppCatalog'),
|
||||
subscriptionPrefix: 'app-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 = 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<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 && 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<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,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<AppStack> 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<String>();
|
||||
|
||||
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})',
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<T extends Model<dynamic>> {
|
||||
final StorageState<T> firstPage;
|
||||
final List<T> 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<T>(const []),
|
||||
olderItems: const [],
|
||||
isLoadingMore: false,
|
||||
hasMore: true,
|
||||
);
|
||||
|
||||
/// All currently loaded items: live first page + accumulated older pages.
|
||||
List<T> get combined => [...firstPage.models, ...olderItems];
|
||||
|
||||
PagedState<T> copyWith({
|
||||
StorageState<T>? firstPage,
|
||||
List<T>? 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<T extends Model<dynamic>>
|
||||
extends StateNotifier<PagedState<T>> {
|
||||
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<T> 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<T> next) {
|
||||
if (next is StorageData<T>) {
|
||||
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<T>) {
|
||||
state = state.copyWith(isLoadingMore: false);
|
||||
}
|
||||
}
|
||||
|
||||
/// Load the next page of older items. Safe to call from scroll listeners.
|
||||
Future<void> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<String> getPreviewIdentifiers(AppStack stack) {
|
||||
return rawTags.take(3).map(extractStackIdentifier).whereType<String>().toList();
|
||||
}
|
||||
|
||||
/// Sort app stacks: franzap/following first, then by recency
|
||||
List<AppStack> _sortStacks(
|
||||
List<AppStack> stacks, {
|
||||
String? signedInPubkey,
|
||||
Set<String>? followingPubkeys,
|
||||
}) {
|
||||
/// Shuffle stacks with a daily seed for variety
|
||||
List<AppStack> _shuffleStacks(List<AppStack> 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 = <AppStack>[];
|
||||
final others = <AppStack>[];
|
||||
|
||||
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<AppStack>(
|
||||
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<ContactList>(
|
||||
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<Profile>(
|
||||
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 = <String>{};
|
||||
final stackPreviewIds = <String, List<String>>{};
|
||||
@@ -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<AppStack> displayedStacks,
|
||||
Map<String, Profile> authorsMap,
|
||||
bool Function(String) isAuthorLoading,
|
||||
Map<String, List<String>> stackPreviewIds,
|
||||
Map<String, App> 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<String> previewIdentifiers;
|
||||
final Map<String, App> 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<App>()
|
||||
@@ -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),
|
||||
],
|
||||
|
||||
@@ -100,6 +100,95 @@ onPressed: () async {
|
||||
}
|
||||
```
|
||||
|
||||
### Paged lists with live subscription (infinite scroll)
|
||||
|
||||
Use `PagedSubscriptionNotifier<T>` (`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<MyModel> {
|
||||
MyNotifier(super.ref);
|
||||
|
||||
ProviderSubscription<StorageState<MyModel>>? _sub;
|
||||
|
||||
@override int get pageSize => 10;
|
||||
|
||||
@override
|
||||
void startSubscription() {
|
||||
_sub?.close();
|
||||
_sub = ref.listen(
|
||||
query<MyModel>(
|
||||
limit: pageSize,
|
||||
source: const LocalAndRemoteSource(relays: 'AppCatalog', stream: true),
|
||||
subscriptionPrefix: 'app-my-list',
|
||||
),
|
||||
(_, next) => updateFirstPage(next),
|
||||
fireImmediately: true,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<({List<MyModel> items, int count})> fetchOlderPage(DateTime until) async {
|
||||
final items = await ref.storage.query(
|
||||
RequestFilter<MyModel>(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<MyNotifier, PagedState<MyModel>>(
|
||||
(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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user