diff --git a/lib/widgets/latest_releases_container.dart b/lib/widgets/latest_releases_container.dart index d5073c1..2403993 100644 --- a/lib/widgets/latest_releases_container.dart +++ b/lib/widgets/latest_releases_container.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:models/models.dart'; +import 'package:zapstore/services/package_manager/package_manager.dart'; import 'package:zapstore/services/updates_service.dart'; import 'package:zapstore/utils/extensions.dart'; import 'app_card.dart'; @@ -15,9 +16,9 @@ const _kPageSize = 5; // --------------------------------------------------------------------------- class LatestReleasesState { - final List firstPage; - final List olderPages; - final Map appsByIdentifier; + final List firstPage; + final List olderPages; + final Map appsByAssetId; final bool isLoadingMore; final bool hasMore; final Object? error; @@ -25,7 +26,7 @@ class LatestReleasesState { const LatestReleasesState({ required this.firstPage, required this.olderPages, - required this.appsByIdentifier, + required this.appsByAssetId, required this.isLoadingMore, required this.hasMore, this.error, @@ -34,7 +35,7 @@ class LatestReleasesState { factory LatestReleasesState.loading() => const LatestReleasesState( firstPage: [], olderPages: [], - appsByIdentifier: {}, + appsByAssetId: {}, isLoadingMore: false, hasMore: true, ); @@ -42,19 +43,19 @@ class LatestReleasesState { bool get isLoading => firstPage.isEmpty && olderPages.isEmpty && error == null; - List get allReleases => [...firstPage, ...olderPages]; + List get allAssets => [...firstPage, ...olderPages]; LatestReleasesState copyWith({ - List? firstPage, - List? olderPages, - Map? appsByIdentifier, + List? firstPage, + List? olderPages, + Map? appsByAssetId, bool? isLoadingMore, bool? hasMore, Object? error, }) => LatestReleasesState( firstPage: firstPage ?? this.firstPage, olderPages: olderPages ?? this.olderPages, - appsByIdentifier: appsByIdentifier ?? this.appsByIdentifier, + appsByAssetId: appsByAssetId ?? this.appsByAssetId, isLoadingMore: isLoadingMore ?? this.isLoadingMore, hasMore: hasMore ?? this.hasMore, error: error, @@ -68,14 +69,14 @@ class LatestReleasesState { /// Local-first, reactive notifier for the "Latest Releases" home section. /// /// Design: -/// - The outer `query(...)` subscription carries an `and:` chain that -/// loads `release.app` (+ nested `app.author`) and `release.softwareAssets` +/// - The outer `query(...)` subscription carries an `and:` chain +/// that loads `asset.app` /// in the background via `NestedQueryManager`. Relationship queries are /// fire-and-forget; they NEVER block the state update. -/// - The listener reacts to every emission — `StorageLoading(localReleases)` -/// during `awaitingRemote` and `StorageData` once EOSE lands. Local Releases +/// - The listener reacts to every emission — `StorageLoading(localAssets)` +/// during `awaitingRemote` and `StorageData` once EOSE lands. Local assets /// are displayed within one frame regardless of network state. -/// - `appsByIdentifier` is computed synchronously from local storage +/// - `appsByAssetId` is computed synchronously from local storage /// (`storage.querySync`) on every emission. As the `and:` relationship /// queries populate storage in the background, subsequent emissions pick /// up the newly-available Apps. @@ -85,36 +86,27 @@ class LatestReleasesState { /// - Remote failures degrade gracefully; rows without a resolved App are /// simply skipped by the consumer widget. class LatestReleasesNotifier extends StateNotifier { - LatestReleasesNotifier(this.ref) : super(LatestReleasesState.loading()) { + LatestReleasesNotifier(this.ref, {required this.platform}) + : super(LatestReleasesState.loading()) { _subscribe(); } final Ref ref; - ProviderSubscription>? _sub; + final String platform; + ProviderSubscription>? _sub; void _subscribe() { _sub?.close(); _sub = ref.listen( - query( + query( + tags: { + '#f': {platform}, + }, limit: _kPageSize, source: const LocalAndRemoteSource(relays: 'AppCatalog', stream: true), subscriptionPrefix: 'app-latest-releases', - and: (release) => { - release.app.query( - source: const LocalAndRemoteSource( - relays: 'AppCatalog', - stream: false, - ), - and: (app) => { - app.author.query( - source: const LocalAndRemoteSource( - relays: {'vertex', 'social'}, - cachedFor: Duration(hours: 2), - ), - ), - }, - ), - release.softwareAssets.query( + and: (asset) => { + asset.app.query( source: const LocalAndRemoteSource( relays: 'AppCatalog', stream: false, @@ -123,7 +115,7 @@ class LatestReleasesNotifier extends StateNotifier { }, ), (_, next) { - if (next is StorageError) { + if (next is StorageError) { state = state.copyWith(error: next.exception); return; } @@ -133,34 +125,31 @@ class LatestReleasesNotifier extends StateNotifier { ); } - void _applyFirstPage(List releases) { + void _applyFirstPage(List assets) { final storage = ref.read(storageNotifierProvider.notifier); - final liveIds = releases.map((r) => r.id).toSet(); + final liveIds = assets.map((asset) => asset.id).toSet(); final filteredOlder = state.olderPages - .where((r) => !liveIds.contains(r.id)) + .where((asset) => !liveIds.contains(asset.id)) .toList(); - final keepIds = { - ...releases.map((r) => r.appIdentifier), - ...filteredOlder.map((r) => r.appIdentifier), - }..removeWhere((id) => id.isEmpty); + final keepAssets = [...assets, ...filteredOlder]; - final appsByIdentifier = _resolveAppsFromLocal(storage, keepIds); + final appsByAssetId = _resolveAppsFromLocal(storage, keepAssets); state = state.copyWith( - firstPage: releases, + firstPage: assets, olderPages: filteredOlder, - appsByIdentifier: appsByIdentifier, + appsByAssetId: appsByAssetId, error: null, ); } Future loadMore() async { - final all = state.allReleases; + final all = state.allAssets; if (state.isLoadingMore || !state.hasMore || all.isEmpty) return; final oldest = all - .map((r) => r.event.createdAt) + .map((asset) => asset.event.createdAt) .reduce((a, b) => a.isBefore(b) ? a : b) .subtract(const Duration(milliseconds: 1)); @@ -168,16 +157,21 @@ class LatestReleasesNotifier extends StateNotifier { try { final storage = ref.read(storageNotifierProvider.notifier); - final req = - RequestFilter(until: oldest, limit: _kPageSize).toRequest(); + final req = RequestFilter( + tags: { + '#f': {platform}, + }, + until: oldest, + limit: _kPageSize, + ).toRequest(); // Local-first: read whatever is already cached, so offline scroll // shows older items immediately without waiting on the relay. - final localReleases = storage.querySync(req); + final localAssets = storage.querySync(req); - List releases; - if (localReleases.isNotEmpty) { - releases = localReleases; + List assets; + if (localAssets.isNotEmpty) { + assets = localAssets; // Background hydrate: bring in any older items the relay has that // aren't cached yet. Results land via the live subscription's // general storage-update path; next scroll picks them up. @@ -191,11 +185,11 @@ class LatestReleasesNotifier extends StateNotifier { ), subscriptionPrefix: 'app-latest-releases-older', ) - .catchError((_) => const []), + .catchError((_) => const []), ); } else { // Nothing local. Try remote with a short timeout; fall back to empty. - releases = await storage + assets = await storage .query( req, source: const LocalAndRemoteSource( @@ -206,85 +200,84 @@ class LatestReleasesNotifier extends StateNotifier { ) .timeout( const Duration(seconds: 5), - onTimeout: () => const [], + onTimeout: () => const [], ) - .catchError((_) => const []); + .catchError((_) => const []); } - if (releases.isEmpty) { + if (assets.isEmpty) { state = state.copyWith(isLoadingMore: false, hasMore: false); return; } - final existingIds = all.map((r) => r.id).toSet(); - final unique = releases - .where((r) => !existingIds.contains(r.id)) + final existingIds = all.map((asset) => asset.id).toSet(); + final unique = assets + .where((asset) => !existingIds.contains(asset.id)) .toList(); // Fire relationship queries for the older page in the background. // The first-page subscription's general-update path will refresh - // `appsByIdentifier` as apps/authors/assets land in storage. + // `appsByAssetId` as apps land in storage. _hydrateOlderPageRelationships(unique); - final keepIds = { - ...state.firstPage.map((r) => r.appIdentifier), - ...state.olderPages.map((r) => r.appIdentifier), - ...unique.map((r) => r.appIdentifier), - }..removeWhere((id) => id.isEmpty); + final keepAssets = [...state.firstPage, ...state.olderPages, ...unique]; state = state.copyWith( olderPages: [...state.olderPages, ...unique], - appsByIdentifier: _resolveAppsFromLocal(storage, keepIds), + appsByAssetId: _resolveAppsFromLocal(storage, keepAssets), isLoadingMore: false, - hasMore: releases.length >= _kPageSize, + hasMore: assets.length >= _kPageSize, ); } catch (_) { state = state.copyWith(isLoadingMore: false); } } - /// Synchronously map app identifiers → App using local storage only. + /// Synchronously map asset IDs -> parent App using local storage only. /// Never awaits network; returns an empty entry for ids not yet in cache. Map _resolveAppsFromLocal( StorageNotifier storage, - Set appIds, + List assets, ) { final result = {}; - for (final id in appIds) { + for (final asset in assets) { + if (asset.appIdentifier.isEmpty) continue; final matches = storage.querySync( RequestFilter( + authors: {asset.event.pubkey}, tags: { - '#d': {id}, + '#d': {asset.appIdentifier}, + '#f': {platform}, }, limit: 1, ).toRequest(), ); if (matches.isNotEmpty) { - result[id] = matches.first; + result[asset.id] = matches.first; } } return result; } - /// Fire-and-forget relationship hydration for older-page Releases. + /// Fire-and-forget relationship hydration for older-page SoftwareAssets. /// Results land in local storage and trigger a refresh via the outer /// subscription's `handleStorageUpdate`. - void _hydrateOlderPageRelationships(List releases) { - if (releases.isEmpty) return; + void _hydrateOlderPageRelationships(List assets) { + if (assets.isEmpty) return; final storage = ref.read(storageNotifierProvider.notifier); - final appIds = releases - .map((r) => r.appIdentifier) - .where((id) => id.isNotEmpty) - .toSet(); - final assetIds = releases - .expand((r) => r.event.getTagSetValues('e')) - .toSet(); + final appFilters = >[]; + for (final asset in assets) { + final filters = asset.app.req?.filters; + if (filters != null && filters.isNotEmpty) { + appFilters.add(filters.first); + } + } - if (appIds.isNotEmpty) { + if (appFilters.isNotEmpty) { unawaited( storage.query( - RequestFilter(tags: {'#d': appIds}).toRequest(), + Request(appFilters), source: const LocalAndRemoteSource( relays: 'AppCatalog', stream: false, @@ -293,18 +286,6 @@ class LatestReleasesNotifier extends StateNotifier { ), ); } - if (assetIds.isNotEmpty) { - unawaited( - storage.query( - RequestFilter(ids: assetIds).toRequest(), - source: const LocalAndRemoteSource( - relays: 'AppCatalog', - stream: false, - ), - subscriptionPrefix: 'app-latest-releases-older-assets', - ), - ); - } } @override @@ -316,7 +297,8 @@ class LatestReleasesNotifier extends StateNotifier { final latestReleasesProvider = StateNotifierProvider((ref) { - return LatestReleasesNotifier(ref); + final platform = ref.read(packageManagerProvider.notifier).platform; + return LatestReleasesNotifier(ref, platform: platform); }); // --------------------------------------------------------------------------- @@ -336,8 +318,8 @@ class LatestReleasesContainer extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final state = showSkeleton ? null : ref.watch(latestReleasesProvider); - final releases = state?.allReleases ?? []; - final appsById = state?.appsByIdentifier ?? const {}; + final assets = state?.allAssets ?? []; + final appsByAssetId = state?.appsByAssetId ?? const {}; final categorized = ref.watch(categorizedUpdatesProvider); final pinnedApps = [ @@ -348,8 +330,8 @@ class LatestReleasesContainer extends HookConsumerWidget { final seenAppIds = {...pinnedIds}; final dedupedApps = []; - for (final release in releases) { - final app = appsById[release.appIdentifier]; + for (final asset in assets) { + final app = appsByAssetId[asset.id]; if (app != null && seenAppIds.add(app.identifier)) { dedupedApps.add(app); } @@ -360,6 +342,7 @@ class LatestReleasesContainer extends HookConsumerWidget { useEffect(() { if (state == null) return null; void onScroll() { + if (!scrollController.hasClients) return; final s = ref.read(latestReleasesProvider); if (s.isLoadingMore || !s.hasMore) return; if (scrollController.position.pixels >= @@ -368,21 +351,27 @@ class LatestReleasesContainer extends HookConsumerWidget { } } - void checkInitialLoad() { + void checkContentExtent() { if (!scrollController.hasClients) return; final position = scrollController.position; final s = ref.read(latestReleasesProvider); if (s.isLoadingMore || !s.hasMore) return; - if (position.maxScrollExtent <= position.viewportDimension) { + if (position.maxScrollExtent <= 0 || + position.pixels >= position.maxScrollExtent - 300) { ref.read(latestReleasesProvider.notifier).loadMore(); } } - WidgetsBinding.instance.addPostFrameCallback((_) => checkInitialLoad()); + WidgetsBinding.instance.addPostFrameCallback((_) => checkContentExtent()); scrollController.addListener(onScroll); return () => scrollController.removeListener(onScroll); - }, [scrollController, state]); + }, [ + scrollController, + combinedApps.length, + state?.isLoadingMore, + state?.hasMore, + ]); return Column( crossAxisAlignment: CrossAxisAlignment.start, diff --git a/spec/work/WORK-012-latest-releases-asset-first.md b/spec/work/WORK-012-latest-releases-asset-first.md new file mode 100644 index 0000000..5d7d1b6 --- /dev/null +++ b/spec/work/WORK-012-latest-releases-asset-first.md @@ -0,0 +1,45 @@ +# WORK-012 - Latest Releases Asset-First Pagination + +**Feature:** FEAT-004 asset-first queries +**Status:** Implemented + +## Context + +The home screen "Latest Releases" section can miss newly published apps/releases +and pagination appears not to fire. + +Diagnosis: + +- `LatestReleasesNotifier` queries `Release` kind `30063`, but current app-card + freshness is driven by `SoftwareAsset` kind `3063`. +- The query has no platform filter, then resolves Apps after the fact. +- The UI dedupes by App after fetching only five raw Release rows, so duplicate + releases for the same app can hide other apps until pagination runs. +- The viewport-fill pagination check is tied to a one-frame scroll extent check + and should use the actual scroll extent after each render. + +## Tasks + +- [x] Switch Latest Releases first page to `SoftwareAsset` (`3063`) with platform filtering. +- [x] Keep local-first relationship resolution and avoid awaiting network on render. +- [x] Page older SoftwareAssets by asset `createdAt`. +- [x] Make initial/fill pagination trigger from actual scroll extent. +- [x] Attempt analyzer and focused tests where available. + +## Verification + +- [ ] `fvm flutter analyze` +- [x] Manual/code review: first page uses `SoftwareAsset`, `#f` platform filter, + and no awaited network call in the render/listener path. + +Analyzer could not run in this environment: + +- `fvm dart format lib/widgets/latest_releases_container.dart` failed with + `Operation not permitted` from the fvm shim. +- `dart format ...` failed trying to create/read files under the user pub/cache + paths. +- `flutter analyze` failed with `operation not permitted: flutter`. + +## Spec Issues + +_None_