From 5b0d5c61fd6195fc01a72b0f87ac1866df41bfea Mon Sep 17 00:00:00 2001 From: franzap <_@franzap.com> Date: Sun, 5 Apr 2026 23:55:33 -0300 Subject: [PATCH] Fixed loading for releases/stacks --- lib/widgets/app_stack_container.dart | 42 ++- lib/widgets/latest_releases_container.dart | 346 +++++++++++++++------ 2 files changed, 273 insertions(+), 115 deletions(-) diff --git a/lib/widgets/app_stack_container.dart b/lib/widgets/app_stack_container.dart index fc4e477..784fa9f 100644 --- a/lib/widgets/app_stack_container.dart +++ b/lib/widgets/app_stack_container.dart @@ -35,12 +35,13 @@ List getPreviewIdentifiers(AppStack stack) { return rawTags.take(3).map(extractStackIdentifier).whereType().toList(); } -/// Shuffle stacks with a daily seed for variety +/// Seed generated once per app session for stable shuffle order +final int _sessionSeed = Random().nextInt(1 << 32); + +/// Shuffle stacks with a per-session seed for variety List _shuffleStacks(List stacks, {String? signedInPubkey}) { - final today = DateTime.now(); - final dateSeed = today.year * 10000 + today.month * 100 + today.day; final userSeed = signedInPubkey?.hashCode ?? 0; - return stacks.toList()..shuffle(Random(dateSeed ^ userSeed)); + return stacks.toList()..shuffle(Random(_sessionSeed ^ userSeed)); } /// App Stack Container - horizontally scrollable 2-row grid of stack cards @@ -384,13 +385,23 @@ class StackCard extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - _FadingText( - stack.name ?? stack.identifier, - style: context.textTheme.titleMedium?.copyWith( + Builder(builder: (context) { + final titleStyle = context.textTheme.titleMedium?.copyWith( fontFamily: 'Inter', - fontSize: (context.textTheme.titleMedium?.fontSize ?? 16) * 0.9, - ), - ), + fontSize: + (context.textTheme.titleMedium?.fontSize ?? 16) * 0.9, + ); + final lineHeight = (titleStyle?.fontSize ?? 14.4) * + (titleStyle?.height ?? 1.2); + return SizedBox( + height: lineHeight * 2, + child: _FadingText( + stack.name ?? stack.identifier, + style: titleStyle, + maxLines: 2, + ), + ); + }), if (showAuthor) ...[ const SizedBox(height: 6), Row( @@ -542,10 +553,11 @@ class _AppIconTile extends StatelessWidget { /// Text widget that applies fade-out effect when text overflows class _FadingText extends HookWidget { - const _FadingText(this.text, {required this.style}); + const _FadingText(this.text, {required this.style, this.maxLines = 1}); final String text; final TextStyle? style; + final int maxLines; @override Widget build(BuildContext context) { @@ -553,16 +565,16 @@ class _FadingText extends HookWidget { builder: (context, constraints) { final textPainter = TextPainter( text: TextSpan(text: text, style: style), - maxLines: 1, + maxLines: maxLines, textDirection: TextDirection.ltr, - )..layout(maxWidth: double.infinity); + )..layout(maxWidth: constraints.maxWidth); - final isOverflowing = textPainter.width > constraints.maxWidth; + final isOverflowing = textPainter.didExceedMaxLines; final textWidget = Text( text, style: style, - maxLines: 1, + maxLines: maxLines, overflow: TextOverflow.clip, ); diff --git a/lib/widgets/latest_releases_container.dart b/lib/widgets/latest_releases_container.dart index 89a9167..2592018 100644 --- a/lib/widgets/latest_releases_container.dart +++ b/lib/widgets/latest_releases_container.dart @@ -7,14 +7,214 @@ import 'package:zapstore/utils/app_query.dart'; import 'package:zapstore/utils/extensions.dart'; import 'app_card.dart'; -const _pageSize = 5; +const _kPageSize = 5; -final latestReleasesProvider = appAssetsQuery( - tags: {'#f': {'android-arm64-v8a'}}, - limit: _pageSize, - source: const LocalAndRemoteSource(relays: 'AppCatalog', stream: true), - subscriptionPrefix: 'app-latest', -); +// --------------------------------------------------------------------------- +// State +// --------------------------------------------------------------------------- + +class LatestReleasesState { + final List firstPage; + final List olderPages; + final Map appsByIdentifier; + final bool isLoadingMore; + final bool hasMore; + final Object? error; + + const LatestReleasesState({ + required this.firstPage, + required this.olderPages, + required this.appsByIdentifier, + required this.isLoadingMore, + required this.hasMore, + this.error, + }); + + factory LatestReleasesState.loading() => const LatestReleasesState( + firstPage: [], + olderPages: [], + appsByIdentifier: {}, + isLoadingMore: false, + hasMore: true, + ); + + bool get isLoading => + firstPage.isEmpty && olderPages.isEmpty && error == null; + + List get allReleases => [...firstPage, ...olderPages]; + + LatestReleasesState copyWith({ + List? firstPage, + List? olderPages, + Map? appsByIdentifier, + bool? isLoadingMore, + bool? hasMore, + Object? error, + }) => + LatestReleasesState( + firstPage: firstPage ?? this.firstPage, + olderPages: olderPages ?? this.olderPages, + appsByIdentifier: appsByIdentifier ?? this.appsByIdentifier, + isLoadingMore: isLoadingMore ?? this.isLoadingMore, + hasMore: hasMore ?? this.hasMore, + error: error, + ); +} + +// --------------------------------------------------------------------------- +// Notifier +// --------------------------------------------------------------------------- + +class LatestReleasesNotifier extends StateNotifier { + LatestReleasesNotifier(this.ref) : super(LatestReleasesState.loading()) { + _subscribe(); + } + + final Ref ref; + ProviderSubscription>? _sub; + + void _subscribe() { + _sub?.close(); + _sub = ref.listen( + query( + until: DateTime.now(), + limit: _kPageSize, + source: const LocalAndRemoteSource(relays: 'AppCatalog', stream: true), + subscriptionPrefix: 'app-latest-releases', + ), + (_, next) async { + if (next is StorageData) { + final liveIds = next.models.map((r) => r.id).toSet(); + final filteredOlder = + state.olderPages.where((r) => !liveIds.contains(r.id)).toList(); + final unresolved = next.models + .where((r) => + !state.appsByIdentifier.containsKey(r.appIdentifier)) + .toList(); + final apps = await _resolveRelated(unresolved); + if (mounted) { + state = state.copyWith( + firstPage: next.models, + olderPages: filteredOlder, + appsByIdentifier: {...state.appsByIdentifier, ...apps}, + error: null, + ); + } + } else if (next is StorageError) { + state = state.copyWith(error: next.exception); + } + }, + fireImmediately: true, + ); + } + + Future loadMore() async { + final all = state.allReleases; + if (state.isLoadingMore || !state.hasMore || all.isEmpty) return; + + final oldest = all + .map((r) => r.event.createdAt) + .reduce((a, b) => a.isBefore(b) ? a : b) + .subtract(const Duration(milliseconds: 1)); + + state = state.copyWith(isLoadingMore: true); + + try { + final storage = ref.read(storageNotifierProvider.notifier); + final releases = await storage.query( + RequestFilter(until: oldest, limit: _kPageSize).toRequest(), + source: const LocalAndRemoteSource(relays: 'AppCatalog', stream: false), + subscriptionPrefix: 'app-latest-releases-older', + ); + + if (releases.isEmpty) { + state = state.copyWith(isLoadingMore: false, hasMore: false); + return; + } + + final apps = await _resolveRelated(releases); + + final existingIds = all.map((r) => r.id).toSet(); + final unique = + releases.where((r) => !existingIds.contains(r.id)).toList(); + state = state.copyWith( + olderPages: [...state.olderPages, ...unique], + appsByIdentifier: {...state.appsByIdentifier, ...apps}, + isLoadingMore: false, + hasMore: releases.length >= _kPageSize, + ); + } catch (_) { + state = state.copyWith(isLoadingMore: false); + } + } + + /// Parallel fetch: assets by e-tag IDs + apps by i-tag identifiers. + /// Returns resolved apps keyed by identifier. + Future> _resolveRelated(List releases) async { + if (releases.isEmpty) return const {}; + final storage = ref.read(storageNotifierProvider.notifier); + + final assetIds = + releases.expand((r) => r.event.getTagSetValues('e')).toSet(); + final appIds = releases + .map((r) => r.appIdentifier) + .where((id) => id.isNotEmpty) + .toSet(); + + await Future.wait([ + if (assetIds.isNotEmpty) + storage.query( + RequestFilter( + ids: assetIds, + tags: {'#f': {'android-arm64-v8a'}}, + ).toRequest(), + source: const LocalAndRemoteSource( + relays: 'AppCatalog', + stream: false, + ), + subscriptionPrefix: 'app-latest-releases-assets', + ), + if (appIds.isNotEmpty) + storage.query( + RequestFilter( + tags: {'#d': appIds, '#f': {'android-arm64-v8a'}}, + ).toRequest(), + source: const LocalAndRemoteSource( + relays: 'AppCatalog', + stream: false, + ), + subscriptionPrefix: 'app-latest-releases-apps', + ), + ]); + + if (appIds.isEmpty) return const {}; + + final apps = appIds + .expand((id) => storage.querySync( + RequestFilter(tags: {'#d': {id}}, limit: 1).toRequest(), + )) + .cast() + .toList(); + await loadAuthors(storage, apps, 'app-latest-releases-authors'); + + return {for (final app in apps) app.identifier: app}; + } + + @override + void dispose() { + _sub?.close(); + super.dispose(); + } +} + +final latestReleasesProvider = + StateNotifierProvider((ref) { + return LatestReleasesNotifier(ref); +}); + +// --------------------------------------------------------------------------- +// Widget +// --------------------------------------------------------------------------- class LatestReleasesContainer extends HookConsumerWidget { const LatestReleasesContainer({ @@ -28,42 +228,36 @@ class LatestReleasesContainer extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - // Don't query storage until initialization completes final state = showSkeleton ? null : ref.watch(latestReleasesProvider); - - final olderAssets = useState([]); - final isLoadingMore = useState(false); - final hasMore = useState(true); + final releases = state?.allReleases ?? []; + final appsById = state?.appsByIdentifier ?? const {}; final categorized = ref.watch(categorizedUpdatesProvider); final pinnedApps = [ ...categorized.automaticUpdates, ...categorized.manualUpdates, ].where((a) => a.isZapstoreApp).toList(); - final pinnedIds = pinnedApps.map((a) => a.id).toSet(); + final pinnedIds = pinnedApps.map((a) => a.identifier).toSet(); - final seenIds = {}; - final firstPageApps = state?.models - .map((asset) => asset.app.value) - .nonNulls - .where((app) => !pinnedIds.contains(app.id) && seenIds.add(app.id)) - .toList() ?? []; + final seenAppIds = {...pinnedIds}; + final dedupedApps = []; + for (final release in releases) { + final app = appsById[release.appIdentifier]; + if (app != null && seenAppIds.add(app.identifier)) { + dedupedApps.add(app); + } + } - final olderApps = olderAssets.value - .map((asset) => asset.app.value) - .nonNulls - .where((app) => !pinnedIds.contains(app.id) && seenIds.add(app.id)) - .toList(); - - final combinedApps = [...pinnedApps, ...firstPageApps, ...olderApps]; + final combinedApps = [...pinnedApps, ...dedupedApps]; useEffect(() { if (state == null) return null; void onScroll() { - if (isLoadingMore.value || !hasMore.value) return; + final s = ref.read(latestReleasesProvider); + if (s.isLoadingMore || !s.hasMore) return; if (scrollController.position.pixels >= scrollController.position.maxScrollExtent - 300) { - _loadMore(ref, state, olderAssets, isLoadingMore, hasMore); + ref.read(latestReleasesProvider.notifier).loadMore(); } } scrollController.addListener(onScroll); @@ -75,20 +269,22 @@ class LatestReleasesContainer extends HookConsumerWidget { children: [ _buildHeader(context), const SizedBox(height: 8), - if (showSkeleton || state == null || - (state is StorageLoading && combinedApps.isEmpty) || - (state.models.isNotEmpty && combinedApps.isEmpty)) - Column(children: List.generate(3, (_) => const AppCard(isLoading: true))) - else if (state is StorageError) - _buildError(context, state.exception.toString()) + if (showSkeleton || + state == null || + (state.isLoading && combinedApps.isEmpty)) + Column( + children: List.generate(3, (_) => const AppCard(isLoading: true)), + ) + else if (state.error != null && combinedApps.isEmpty) + _buildError(context, state.error.toString()) else ...[ - ...combinedApps.map((app) => AppCard(app: app, showUpdateArrow: app.hasUpdate)), - if (isLoadingMore.value) - Padding( - padding: const EdgeInsets.all(16.0), - child: Center( - child: CircularProgressIndicator(), - ), + ...combinedApps.map( + (app) => AppCard(app: app, showUpdateArrow: app.hasUpdate), + ), + if (state.isLoadingMore) + const Padding( + padding: EdgeInsets.all(16.0), + child: Center(child: CircularProgressIndicator()), ), ], const SizedBox(height: 24), @@ -113,7 +309,10 @@ class LatestReleasesContainer extends HookConsumerWidget { child: Text( 'LATEST RELEASES', style: context.textTheme.labelLarge?.copyWith( - color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.85), + color: Theme.of(context) + .colorScheme + .onSurface + .withValues(alpha: 0.85), letterSpacing: 1.5, fontWeight: FontWeight.bold, ), @@ -135,67 +334,14 @@ class LatestReleasesContainer extends HookConsumerWidget { const SizedBox(height: 16), Text('Error loading apps', style: context.textTheme.titleMedium), const SizedBox(height: 8), - Text(error, style: context.textTheme.bodySmall, textAlign: TextAlign.center), + Text( + error, + style: context.textTheme.bodySmall, + textAlign: TextAlign.center, + ), ], ), ), ); } } - -Future _loadMore( - WidgetRef ref, - StorageState firstPageState, - ValueNotifier> olderAssets, - ValueNotifier isLoadingMore, - ValueNotifier hasMore, -) async { - isLoadingMore.value = true; - - final allAssets = [...firstPageState.models, ...olderAssets.value]; - if (allAssets.isEmpty) { - isLoadingMore.value = false; - return; - } - - final oldest = allAssets - .map((a) => a.event.createdAt) - .reduce((a, b) => a.isBefore(b) ? a : b) - .subtract(const Duration(milliseconds: 1)); - - try { - final storage = ref.read(storageNotifierProvider.notifier); - final items = await storage.query( - RequestFilter( - tags: {'#f': {'android-arm64-v8a'}}, - until: oldest, - limit: _pageSize, - ).toRequest(), - source: const LocalAndRemoteSource(relays: 'AppCatalog', stream: false), - subscriptionPrefix: 'app-latest-older', - ); - - final appFilters = items - .map((a) => a.app.req?.filters.firstOrNull) - .nonNulls - .toList(); - if (appFilters.isNotEmpty) { - final apps = await storage.query( - Request(appFilters), - source: const LocalAndRemoteSource(relays: 'AppCatalog', stream: false), - subscriptionPrefix: 'app-latest-older-apps', - ); - await loadAuthors(storage, apps, 'app-latest-older-authors'); - } - - final existingIds = allAssets.map((a) => a.id).toSet(); - final unique = items.where((a) => !existingIds.contains(a.id)).toList(); - - olderAssets.value = [...olderAssets.value, ...unique]; - hasMore.value = items.length >= _pageSize; - } catch (_) { - // Degrade gracefully — stop paging but don't crash - hasMore.value = false; - } - isLoadingMore.value = false; -}