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
|
/// Identifier for storing user saved apps
|
||||||
const kAppBookmarksIdentifier = 'zapstore-bookmarks';
|
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) {
|
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>?;
|
final tags = event['tags'] as List<dynamic>?;
|
||||||
if (tags == null) return false;
|
if (tags == null) return false;
|
||||||
|
|
||||||
// Check for saved apps identifier in 'd' tag
|
|
||||||
for (final tag in tags) {
|
for (final tag in tags) {
|
||||||
if (tag is List && tag.isNotEmpty && tag[0] == 'd') {
|
if (tag is! List || tag.isEmpty) continue;
|
||||||
if (tag.length > 1 && tag[1] == kAppBookmarksIdentifier) {
|
if (tag[0] == 'd' && tag.length > 1 && tag[1] == kAppBookmarksIdentifier) {
|
||||||
return false;
|
return false;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for at least one 'a' tag starting with '32267:' (App kind)
|
|
||||||
for (final tag in tags) {
|
for (final tag in tags) {
|
||||||
if (tag is List && tag.isNotEmpty && tag[0] == 'a') {
|
if (tag is! List || tag.isEmpty) continue;
|
||||||
if (tag.length > 1 && tag[1] is String && tag[1].startsWith('32267:')) {
|
if (tag[0] == 'a' && tag.length > 1 &&
|
||||||
return true;
|
tag[1] is String && (tag[1] as String).startsWith('32267:')) {
|
||||||
}
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// No valid App references found
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -6,7 +6,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|||||||
import 'package:models/models.dart';
|
import 'package:models/models.dart';
|
||||||
import 'package:zapstore/screens/main_scaffold.dart';
|
import 'package:zapstore/screens/main_scaffold.dart';
|
||||||
import 'package:zapstore/screens/app_detail_screen.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/app_stack_screen.dart';
|
||||||
import 'package:zapstore/screens/user_screen.dart';
|
import 'package:zapstore/screens/user_screen.dart';
|
||||||
import 'package:zapstore/screens/search_screen.dart';
|
import 'package:zapstore/screens/search_screen.dart';
|
||||||
@@ -91,7 +91,7 @@ GoRoute _allStacksRoute() {
|
|||||||
return GoRoute(
|
return GoRoute(
|
||||||
path: 'stacks',
|
path: 'stacks',
|
||||||
pageBuilder: (context, state) {
|
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
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
// Query stack with apps and author relationships
|
|
||||||
final stackState = ref.watch(
|
final stackState = ref.watch(
|
||||||
query<AppStack>(
|
query<AppStack>(
|
||||||
authors: authorPubkey != null ? {authorPubkey!} : null,
|
authors: authorPubkey != null ? {authorPubkey!} : null,
|
||||||
@@ -29,51 +28,52 @@ class AppStackScreen extends HookConsumerWidget {
|
|||||||
'#d': {stackId},
|
'#d': {stackId},
|
||||||
},
|
},
|
||||||
limit: 1,
|
limit: 1,
|
||||||
source: LocalAndRemoteSource(stream: true, relays: 'social'),
|
source: const LocalAndRemoteSource(
|
||||||
subscriptionPrefix: 'app-stack-$stackId',
|
relays: 'AppCatalog',
|
||||||
|
stream: false,
|
||||||
|
),
|
||||||
|
subscriptionPrefix: 'app-stack-detail-$stackId',
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
return switch (stackState) {
|
if (stackState case StorageError(:final exception)) {
|
||||||
StorageError(:final exception) => _ErrorScaffold(
|
return _ErrorScaffold(message: exception.toString());
|
||||||
message: exception.toString(),
|
}
|
||||||
),
|
|
||||||
StorageData(:final models) => _AppStackContentWithApps(
|
final stack = stackState.models.firstOrNull;
|
||||||
stack: models.firstOrNull,
|
|
||||||
),
|
if (stack == null) {
|
||||||
StorageLoading() => Scaffold(
|
return Scaffold(
|
||||||
body: SingleChildScrollView(
|
body: SingleChildScrollView(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
child: _AppStackSkeleton(),
|
child: _AppStackSkeleton(),
|
||||||
),
|
),
|
||||||
),
|
);
|
||||||
};
|
}
|
||||||
|
|
||||||
|
return _AppStackContentWithApps(stack: stack);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Intermediate widget that loads apps with their release relationships
|
/// Intermediate widget that loads apps with their release relationships
|
||||||
class _AppStackContentWithApps extends HookConsumerWidget {
|
class _AppStackContentWithApps extends HookConsumerWidget {
|
||||||
final AppStack? stack;
|
final AppStack stack;
|
||||||
|
|
||||||
const _AppStackContentWithApps({required this.stack});
|
const _AppStackContentWithApps({required this.stack});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
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
|
// Derive identifiers directly from the raw event tags — stable and complete
|
||||||
// regardless of what's been cached locally. The 'a' tag format is
|
// regardless of what's been cached locally. The 'a' tag format is
|
||||||
// '32267:pubkey:d-tag'; we extract just the d-tag (identifier) part.
|
// '32267:pubkey:d-tag'; we extract just the d-tag (identifier) part.
|
||||||
final appIdentifiers = stack!.event
|
final appIdentifiers = stack.event
|
||||||
.getTagSetValues('a')
|
.getTagSetValues('a')
|
||||||
.where((id) => id.startsWith('32267:'))
|
.where((id) => id.startsWith('32267:'))
|
||||||
.map((id) => id.split(':').skip(2).join(':'))
|
.map((id) => id.split(':').skip(2).join(':'))
|
||||||
.toSet();
|
.toSet();
|
||||||
|
|
||||||
if (appIdentifiers.isEmpty) {
|
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)
|
// 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),
|
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>()
|
.whereType<App>()
|
||||||
.toList();
|
.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/common/note_parser.dart';
|
||||||
import 'package:zapstore/widgets/nwc_widgets.dart';
|
import 'package:zapstore/widgets/nwc_widgets.dart';
|
||||||
import 'package:zapstore/widgets/relay_management_card.dart';
|
import 'package:zapstore/widgets/relay_management_card.dart';
|
||||||
|
import 'package:zapstore/screens/app_stacks_screen.dart';
|
||||||
|
|
||||||
/// Profile screen for authentication and app settings
|
/// Profile screen for authentication and app settings
|
||||||
class ProfileScreen extends ConsumerWidget {
|
class ProfileScreen extends ConsumerWidget {
|
||||||
@@ -201,6 +202,7 @@ class _AuthenticationSection extends ConsumerWidget {
|
|||||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
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
|
// Sign in button with Amber installation check
|
||||||
class _SignInButtonWithAmberCheck extends ConsumerWidget {
|
class _SignInButtonWithAmberCheck extends ConsumerWidget {
|
||||||
const _SignInButtonWithAmberCheck({required this.isAmberInstalled});
|
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/material.dart';
|
||||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:models/models.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 'package:zapstore/utils/extensions.dart';
|
||||||
import '../theme.dart';
|
import '../theme.dart';
|
||||||
import '../widgets/common/note_parser.dart';
|
import '../widgets/common/note_parser.dart';
|
||||||
@@ -141,10 +137,6 @@ class UserScreen extends HookConsumerWidget {
|
|||||||
return _StackLinkCard(stack: stack, pubkey: pubkey);
|
return _StackLinkCard(stack: stack, pubkey: pubkey);
|
||||||
}, childCount: stacks.length),
|
}, childCount: stacks.length),
|
||||||
),
|
),
|
||||||
// Republish stacks button - only for signed-in user viewing their own profile
|
|
||||||
SliverToBoxAdapter(
|
|
||||||
child: _RepublishStacksButton(stacks: stacks, pubkey: pubkey),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
|
|
||||||
// Bottom padding
|
// 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';
|
import 'common/profile_name_widget.dart';
|
||||||
|
|
||||||
/// Number of stacks to show initially and load per batch
|
/// Number of stacks to show initially and load per batch
|
||||||
const int _kInitialStacks = 6;
|
const int _kInitialStacks = 8;
|
||||||
const int _kBatchSize = 6;
|
const int _kBatchSize = 6;
|
||||||
|
|
||||||
/// Get the raw `a` tag values from the stack's event (available immediately)
|
/// 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();
|
return rawTags.take(3).map(extractStackIdentifier).whereType<String>().toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Sort app stacks: franzap/following first, then by recency
|
/// Shuffle stacks with a daily seed for variety
|
||||||
List<AppStack> _sortStacks(
|
List<AppStack> _shuffleStacks(List<AppStack> stacks, {String? signedInPubkey}) {
|
||||||
List<AppStack> stacks, {
|
|
||||||
String? signedInPubkey,
|
|
||||||
Set<String>? followingPubkeys,
|
|
||||||
}) {
|
|
||||||
final today = DateTime.now();
|
final today = DateTime.now();
|
||||||
final dateSeed = today.year * 10000 + today.month * 100 + today.day;
|
final dateSeed = today.year * 10000 + today.month * 100 + today.day;
|
||||||
final userSeed = signedInPubkey?.hashCode ?? 0;
|
final userSeed = signedInPubkey?.hashCode ?? 0;
|
||||||
final random = Random(dateSeed ^ userSeed);
|
return stacks.toList()..shuffle(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];
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// App Stack Container - horizontally scrollable 2-row grid of stack cards
|
/// 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);
|
final signedInPubkey = ref.watch(Signer.activePubkeyProvider);
|
||||||
|
|
||||||
// Get platform from package manager for filtering
|
|
||||||
final platform = ref.read(packageManagerProvider.notifier).platform;
|
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(
|
final appStacksState = ref.watch(
|
||||||
query<AppStack>(
|
query<AppStack>(
|
||||||
limit: 30,
|
authors: {kZapstoreCommunityPubkey},
|
||||||
|
limit: 20,
|
||||||
tags: {
|
tags: {
|
||||||
'#f': {platform},
|
'#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();
|
final allStacks = appStacksState.models.toList();
|
||||||
|
|
||||||
if (allStacks.isEmpty) {
|
if (allStacks.isEmpty) {
|
||||||
return _buildSkeleton(context);
|
return _buildSkeleton(context);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sort stacks
|
final sortedStacks = _shuffleStacks(
|
||||||
final sortedStacks = _sortStacks(
|
|
||||||
allStacks,
|
allStacks,
|
||||||
signedInPubkey: signedInPubkey,
|
signedInPubkey: signedInPubkey,
|
||||||
followingPubkeys: followingPubkeys,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// Only show up to visibleCount
|
|
||||||
final displayedStacks = sortedStacks.take(visibleCount.value).toList();
|
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)
|
// Batch load preview apps for displayed stacks (3 per stack)
|
||||||
final allPreviewIdentifiers = <String>{};
|
final allPreviewIdentifiers = <String>{};
|
||||||
final stackPreviewIds = <String, List<String>>{};
|
final stackPreviewIds = <String, List<String>>{};
|
||||||
@@ -213,10 +136,8 @@ class AppStackContainer extends HookConsumerWidget {
|
|||||||
return () => scrollController.removeListener(onScroll);
|
return () => scrollController.removeListener(onScroll);
|
||||||
}, [scrollController, sortedStacks.length]);
|
}, [scrollController, sortedStacks.length]);
|
||||||
|
|
||||||
// 2-row horizontal scroll layout
|
// 2-row horizontal scroll layout with "See more" card
|
||||||
// Add a "See more" card after the last stack if there are more stacks
|
|
||||||
final showSeeMore = sortedStacks.length > _kInitialStacks;
|
final showSeeMore = sortedStacks.length > _kInitialStacks;
|
||||||
// Total items = displayed stacks + optional "See more" placeholder
|
|
||||||
final totalItems = displayedStacks.length + (showSeeMore ? 1 : 0);
|
final totalItems = displayedStacks.length + (showSeeMore ? 1 : 0);
|
||||||
final numColumns = (totalItems + 1) ~/ 2;
|
final numColumns = (totalItems + 1) ~/ 2;
|
||||||
|
|
||||||
@@ -240,8 +161,6 @@ class AppStackContainer extends HookConsumerWidget {
|
|||||||
context,
|
context,
|
||||||
col * 2,
|
col * 2,
|
||||||
displayedStacks,
|
displayedStacks,
|
||||||
authorsMap,
|
|
||||||
isAuthorLoading,
|
|
||||||
stackPreviewIds,
|
stackPreviewIds,
|
||||||
appsMap,
|
appsMap,
|
||||||
showSeeMore,
|
showSeeMore,
|
||||||
@@ -253,8 +172,6 @@ class AppStackContainer extends HookConsumerWidget {
|
|||||||
context,
|
context,
|
||||||
col * 2 + 1,
|
col * 2 + 1,
|
||||||
displayedStacks,
|
displayedStacks,
|
||||||
authorsMap,
|
|
||||||
isAuthorLoading,
|
|
||||||
stackPreviewIds,
|
stackPreviewIds,
|
||||||
appsMap,
|
appsMap,
|
||||||
showSeeMore,
|
showSeeMore,
|
||||||
@@ -274,8 +191,6 @@ class AppStackContainer extends HookConsumerWidget {
|
|||||||
BuildContext context,
|
BuildContext context,
|
||||||
int index,
|
int index,
|
||||||
List<AppStack> displayedStacks,
|
List<AppStack> displayedStacks,
|
||||||
Map<String, Profile> authorsMap,
|
|
||||||
bool Function(String) isAuthorLoading,
|
|
||||||
Map<String, List<String>> stackPreviewIds,
|
Map<String, List<String>> stackPreviewIds,
|
||||||
Map<String, App> appsMap,
|
Map<String, App> appsMap,
|
||||||
bool showSeeMore,
|
bool showSeeMore,
|
||||||
@@ -289,8 +204,7 @@ class AppStackContainer extends HookConsumerWidget {
|
|||||||
final stack = displayedStacks[index];
|
final stack = displayedStacks[index];
|
||||||
return StackCard(
|
return StackCard(
|
||||||
stack: stack,
|
stack: stack,
|
||||||
author: authorsMap[stack.event.pubkey],
|
showAuthor: false,
|
||||||
isAuthorLoading: isAuthorLoading(stack.event.pubkey),
|
|
||||||
previewIdentifiers: stackPreviewIds[stack.id] ?? [],
|
previewIdentifiers: stackPreviewIds[stack.id] ?? [],
|
||||||
appsMap: appsMap,
|
appsMap: appsMap,
|
||||||
);
|
);
|
||||||
@@ -415,10 +329,11 @@ class StackCard extends StatelessWidget {
|
|||||||
const StackCard({
|
const StackCard({
|
||||||
super.key,
|
super.key,
|
||||||
required this.stack,
|
required this.stack,
|
||||||
required this.author,
|
|
||||||
required this.previewIdentifiers,
|
required this.previewIdentifiers,
|
||||||
required this.appsMap,
|
required this.appsMap,
|
||||||
|
this.author,
|
||||||
this.isAuthorLoading = false,
|
this.isAuthorLoading = false,
|
||||||
|
this.showAuthor = true,
|
||||||
});
|
});
|
||||||
|
|
||||||
final AppStack stack;
|
final AppStack stack;
|
||||||
@@ -426,12 +341,12 @@ class StackCard extends StatelessWidget {
|
|||||||
final List<String> previewIdentifiers;
|
final List<String> previewIdentifiers;
|
||||||
final Map<String, App> appsMap;
|
final Map<String, App> appsMap;
|
||||||
final bool isAuthorLoading;
|
final bool isAuthorLoading;
|
||||||
|
final bool showAuthor;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final totalApps = getRawAppTagValues(stack).length;
|
final totalApps = getRawAppTagValues(stack).length;
|
||||||
|
|
||||||
// Resolve preview apps from the pre-loaded map
|
|
||||||
final previewApps = previewIdentifiers
|
final previewApps = previewIdentifiers
|
||||||
.map((id) => appsMap[id])
|
.map((id) => appsMap[id])
|
||||||
.whereType<App>()
|
.whereType<App>()
|
||||||
@@ -476,22 +391,24 @@ class StackCard extends StatelessWidget {
|
|||||||
fontSize: (context.textTheme.titleMedium?.fontSize ?? 16) * 0.9,
|
fontSize: (context.textTheme.titleMedium?.fontSize ?? 16) * 0.9,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 6),
|
if (showAuthor) ...[
|
||||||
Row(
|
const SizedBox(height: 6),
|
||||||
children: [
|
Row(
|
||||||
ProfileAvatar(profile: author, radius: 9),
|
children: [
|
||||||
const SizedBox(width: 5),
|
ProfileAvatar(profile: author, radius: 9),
|
||||||
Expanded(
|
const SizedBox(width: 5),
|
||||||
child: ProfileNameWidget(
|
Expanded(
|
||||||
pubkey: stack.event.pubkey,
|
child: ProfileNameWidget(
|
||||||
profile: author,
|
pubkey: stack.event.pubkey,
|
||||||
isLoading: isAuthorLoading,
|
profile: author,
|
||||||
style: profileStyle,
|
isLoading: isAuthorLoading,
|
||||||
skeletonWidth: 80,
|
style: profileStyle,
|
||||||
|
skeletonWidth: 80,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
],
|
||||||
],
|
),
|
||||||
),
|
],
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
_AppIconsRow(apps: previewApps, totalApps: totalApps),
|
_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
|
### Subscription prefix naming
|
||||||
|
|
||||||
All queries using the `AppCatalog` relay group MUST prefix their `subscriptionPrefix` with `app-`. This is used in the backend.
|
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
|
## 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.
|
- 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
|
## Lifecycle Safety
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user