diff --git a/lib/screens/search_screen.dart b/lib/screens/search_screen.dart index b0ccb16..4e8cf13 100644 --- a/lib/screens/search_screen.dart +++ b/lib/screens/search_screen.dart @@ -124,8 +124,8 @@ class SearchScreen extends HookConsumerWidget { // Latest Releases Container LatestReleasesContainer( - scrollController: scrollController, showSkeleton: !(initState.hasValue || initState.hasError), + scrollController: scrollController, ), const SizedBox(height: 16), // Bottom padding diff --git a/lib/utils/app_query.dart b/lib/utils/app_query.dart index f48c2fa..fd44b23 100644 --- a/lib/utils/app_query.dart +++ b/lib/utils/app_query.dart @@ -29,7 +29,7 @@ appAssetsQuery({ until: until, limit: limit, and: (asset) => { - asset.app.query(source: const LocalAndRemoteSource(stream: false)), + asset.app.query(source: const LocalAndRemoteSource(relays: 'AppCatalog', stream: false)), asset.author.query( source: const LocalAndRemoteSource( relays: {'vertex', 'social'}, @@ -43,46 +43,6 @@ appAssetsQuery({ ); } -/// Reactive query provider: fetches FileMetadata (1063) events and resolves -/// their parent App (32267) via the direct `#i` → `#d` relationship. -/// -/// Same shape as [appAssetsQuery] but for legacy 1063-only apps. -/// Delete this function when legacy 1063 support is fully removed. -AutoDisposeStateNotifierProvider< - RequestNotifier, - StorageState -> -legacyAppQuery({ - Set? authors, - Map>? tags, - String? search, - DateTime? since, - DateTime? until, - int? limit, - Source? source, - required String subscriptionPrefix, -}) { - return query( - authors: authors, - tags: tags, - search: search, - since: since, - until: until, - limit: limit, - and: (fm) => { - fm.app.query(source: const LocalAndRemoteSource(stream: false)), - fm.author.query( - source: const LocalAndRemoteSource( - cachedFor: Duration(hours: 2), - stream: false, - ), - ), - }, - source: source, - subscriptionPrefix: subscriptionPrefix, - ); -} - /// Result of a paginated asset-first fetch. class AssetFetchResult { final List apps; @@ -132,47 +92,6 @@ Future fetchAppsByAsset( return AssetFetchResult(apps, assets.length); } -/// Imperative one-shot: fetches a page of FileMetadata (1063) events, -/// resolves their parent Apps, and pre-loads author profiles. -/// Same shape as [fetchAppsByAsset] but for legacy 1063-only apps. -Future fetchLegacyAppsByMetadata( - StorageNotifier storage, { - Map>? tags, - DateTime? until, - int? limit, - Source? source, - required String subscriptionPrefix, -}) async { - final metadatas = await storage.query( - RequestFilter( - tags: tags, - until: until, - limit: limit, - ).toRequest(), - source: source, - subscriptionPrefix: subscriptionPrefix, - ); - - if (metadatas.isEmpty) return const AssetFetchResult([], 0); - - final appFilters = metadatas - .map((fm) => fm.app.req?.filters.firstOrNull) - .nonNulls - .toList(); - - if (appFilters.isEmpty) return AssetFetchResult(const [], metadatas.length); - - final apps = await storage.query( - Request(appFilters), - source: source, - subscriptionPrefix: '$subscriptionPrefix-apps', - ); - - await _loadAuthors(storage, apps, '$subscriptionPrefix-authors'); - - return AssetFetchResult(apps, metadatas.length); -} - Future _loadAuthors( StorageNotifier storage, List apps, diff --git a/lib/widgets/latest_releases_container.dart b/lib/widgets/latest_releases_container.dart index efc4c9e..ab02e1e 100644 --- a/lib/widgets/latest_releases_container.dart +++ b/lib/widgets/latest_releases_container.dart @@ -2,136 +2,122 @@ 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:purplebase/purplebase.dart'; import 'package:zapstore/services/updates_service.dart'; -import 'package:zapstore/utils/app_query.dart'; import 'package:zapstore/utils/extensions.dart'; import 'app_card.dart'; +const _pageSize = 5; + +final latestReleasesProvider = query( + limit: _pageSize, + tags: { + '#f': {'android-arm64-v8a'}, + }, + and: (asset) => {asset.app.query()}, + source: const LocalAndRemoteSource(relays: 'AppCatalog', stream: true), + subscriptionPrefix: 'app-latest', +); + class LatestReleasesContainer extends HookConsumerWidget { const LatestReleasesContainer({ super.key, - required this.scrollController, this.showSkeleton = false, + required this.scrollController, }); - final ScrollController scrollController; final bool showSkeleton; + final ScrollController scrollController; @override Widget build(BuildContext context, WidgetRef ref) { - if (showSkeleton) { - return _buildSkeletonState(context); - } - - // Infinite scroll: trigger loadMore when near bottom - useEffect(() { - void onScroll() { - final state = ref.read(latestReleasesProvider); - if (state.isLoadingMore || !state.hasMore) return; - - // Trigger load when 300px from bottom - if (scrollController.position.pixels >= - scrollController.position.maxScrollExtent - 300) { - ref.read(latestReleasesProvider.notifier).loadMore(); - } - } - - scrollController.addListener(onScroll); - return () => scrollController.removeListener(onScroll); - }, [scrollController]); - final state = ref.watch(latestReleasesProvider); - final storage = state.storage; - // Get pinned apps with zapstore updates to inject at top + final olderAssets = useState([]); + final isLoadingMore = useState(false); + final hasMore = useState(true); + final categorized = ref.watch(categorizedUpdatesProvider); - final pinnedAppsWithUpdates = [ + final pinnedApps = [ ...categorized.automaticUpdates, ...categorized.manualUpdates, ].where((a) => a.isZapstoreApp).toList(); - final pinnedIds = pinnedAppsWithUpdates.map((a) => a.id).toSet(); + final pinnedIds = pinnedApps.map((a) => a.id).toSet(); - // Combine live storage models (newest) with paged older apps, excluding pinned - final allApps = [ - ...storage.models, - ...state.olderApps, - ].where((a) => !pinnedIds.contains(a.id)).toList(); - final combinedApps = [...pinnedAppsWithUpdates, ...allApps]; + final seenIds = {}; + final firstPageApps = state.models + .map((asset) => asset.app.value) + .nonNulls + .where((app) => !pinnedIds.contains(app.id) && seenIds.add(app.id)) + .toList(); + + 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]; + + useEffect(() { + void onScroll() { + if (isLoadingMore.value || !hasMore.value) return; + if (scrollController.position.pixels >= + scrollController.position.maxScrollExtent - 300) { + _loadMore(ref, state, olderAssets, isLoadingMore, hasMore); + } + } + scrollController.addListener(onScroll); + return () => scrollController.removeListener(onScroll); + }, [scrollController, state]); return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16.0), - child: Row( - children: [ - Expanded( - child: Container( - height: 1, - color: Theme.of( - context, - ).colorScheme.onSurface.withValues(alpha: 0.2), - ), - ), - Padding( - padding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 6, - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - 'LATEST RELEASES', - style: context.textTheme.labelLarge?.copyWith( - color: Theme.of( - context, - ).colorScheme.onSurface.withValues(alpha: 0.85), - letterSpacing: 1.5, - fontWeight: FontWeight.bold, - ), - ), - const SizedBox(width: 8), - const _LatestReleasesStatusDot(), - ], - ), - ), - Expanded( - child: Container( - height: 1, - color: Theme.of( - context, - ).colorScheme.onSurface.withValues(alpha: 0.2), - ), - ), - ], - ), - ), - + _buildHeader(context), const SizedBox(height: 8), - - if (storage is StorageLoading && storage.models.isEmpty) - Column( - children: List.generate( - 3, - (index) => const AppCard(isLoading: true), - ), - ) - else if (storage is StorageError) - _buildErrorState(context, storage.exception.toString()) - else - _buildAppsList( - context, - combinedApps, - state.isLoadingMore, - state.hasMore, - ), + if (showSkeleton || (state is StorageLoading && combinedApps.isEmpty)) + Column(children: List.generate(3, (_) => const AppCard(isLoading: true))) + else if (state is StorageError) + _buildError(context, state.exception.toString()) + else ...[ + ...combinedApps.map((app) => AppCard(app: app, showUpdateArrow: app.hasUpdate)), + if (isLoadingMore.value) const AppCard(isLoading: true), + ], + const SizedBox(height: 24), ], ); } - Widget _buildErrorState(BuildContext context, String error) { + Widget _buildHeader(BuildContext context) { + final divider = Expanded( + child: Container( + height: 1, + color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.2), + ), + ); + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: Row( + children: [ + divider, + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), + child: Text( + 'LATEST RELEASES', + style: context.textTheme.labelLarge?.copyWith( + color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.85), + letterSpacing: 1.5, + fontWeight: FontWeight.bold, + ), + ), + ), + divider, + ], + ), + ); + } + + Widget _buildError(BuildContext context, String error) { return Padding( padding: const EdgeInsets.all(16.0), child: Center( @@ -141,319 +127,63 @@ 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), ], ), ), ); } - - Widget _buildAppsList( - BuildContext context, - List apps, - bool isLoadingMore, - bool hasMoreApps, - ) { - return Column( - children: [ - ...apps.map((app) { - return AppCard(app: app, showUpdateArrow: app.hasUpdate); - }), - - const SizedBox(height: 10), - - // Show loading indicator when fetching more - if (isLoadingMore) - Padding( - padding: const EdgeInsets.all(16.0), - child: Center( - child: SizedBox( - width: 24, - height: 24, - child: CircularProgressIndicator( - strokeWidth: 3, - color: Theme.of(context).colorScheme.primary, - ), - ), - ), - ) - else if (!hasMoreApps && apps.isNotEmpty) - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: Center( - child: Text( - 'No more apps to load', - style: Theme.of( - context, - ).textTheme.bodyMedium?.copyWith(color: Colors.grey[600]), - ), - ), - ), - - const SizedBox(height: 24), - ], - ); - } - - static Widget _buildSkeletonState(BuildContext context) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 12), - child: Row( - children: [ - Expanded( - child: Container( - height: 1, - color: Theme.of( - context, - ).colorScheme.onSurface.withValues(alpha: 0.2), - ), - ), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: Text( - 'LATEST RELEASES', - style: context.textTheme.labelLarge?.copyWith( - color: Theme.of( - context, - ).colorScheme.onSurface.withValues(alpha: 0.35), - letterSpacing: 1.5, - fontWeight: FontWeight.w300, - ), - ), - ), - Expanded( - child: Container( - height: 1, - color: Theme.of( - context, - ).colorScheme.onSurface.withValues(alpha: 0.2), - ), - ), - ], - ), - ), - const SizedBox(height: 8), - // Show 3 skeleton app cards - Column(children: List.generate(3, (index) => AppCard(isLoading: true))), - ], - ); - } } -// Provider and Notifier for latest releases pagination and relationship loading +Future _loadMore( + WidgetRef ref, + StorageState firstPageState, + ValueNotifier> olderAssets, + ValueNotifier isLoadingMore, + ValueNotifier hasMore, +) async { + isLoadingMore.value = true; -class LatestReleasesState { - final StorageState storage; - final List olderApps; - final bool isLoadingMore; - final bool hasMore; - - const LatestReleasesState({ - required this.storage, - required this.olderApps, - required this.isLoadingMore, - required this.hasMore, - }); - - factory LatestReleasesState.initial() => LatestReleasesState( - storage: StorageLoading(const []), - olderApps: const [], - isLoadingMore: false, - hasMore: true, - ); - - LatestReleasesState copyWith({ - StorageState? storage, - List? olderApps, - bool? isLoadingMore, - bool? hasMore, - }) { - return LatestReleasesState( - storage: storage ?? this.storage, - olderApps: olderApps ?? this.olderApps, - isLoadingMore: isLoadingMore ?? this.isLoadingMore, - hasMore: hasMore ?? this.hasMore, - ); - } -} - -final latestReleasesProvider = - StateNotifierProvider( - (ref) => LatestReleasesNotifier(ref), - ); - -class LatestReleasesNotifier extends StateNotifier { - LatestReleasesNotifier(this.ref) : super(LatestReleasesState.initial()) { - _startQuery(); + final allAssets = [...firstPageState.models, ...olderAssets.value]; + if (allAssets.isEmpty) { + isLoadingMore.value = false; + return; } - final Ref ref; - static const int _pageSize = 5; - ProviderSubscription>? _sub; + final oldest = allAssets + .map((a) => a.event.createdAt) + .reduce((a, b) => a.isBefore(b) ? a : b) + .subtract(const Duration(milliseconds: 1)); - void _startQuery() { - _sub?.close(); - - _sub = ref.listen>( - appAssetsQuery( - limit: _pageSize, - tags: { - '#f': {'android-arm64-v8a'}, - }, - source: const LocalAndRemoteSource(relays: 'AppCatalog', stream: true), - subscriptionPrefix: 'app-latest', - ), - (previous, next) { - final appState = _appsFromAssetState(next); - - if (appState is StorageData) { - final liveIds = appState.models.map((a) => a.id).toSet(); - final filteredOlder = state.olderApps - .where((a) => !liveIds.contains(a.id)) - .toList(); - state = state.copyWith(storage: appState, olderApps: filteredOlder); - } else { - state = state.copyWith(storage: appState); - } - - if (appState is StorageError) { - state = state.copyWith(isLoadingMore: false); - } - }, - fireImmediately: true, - ); - } - - /// Derive a `StorageState` from the SoftwareAsset query results. - StorageState _appsFromAssetState(StorageState assetState) { - return switch (assetState) { - StorageLoading() => StorageLoading( - _uniqueAppsFromAssets(assetState.models), - ), - StorageData(:final models) => StorageData( - _uniqueAppsFromAssets(models), - ), - StorageError(:final exception) => - StorageError(const [], exception: exception), - }; - } - - /// Extract unique Apps from assets, preserving order (newest first). - List _uniqueAppsFromAssets(List assets) { - final seen = {}; - final apps = []; - for (final asset in assets) { - final app = asset.app.value; - if (app != null && seen.add(app.identifier)) { - apps.add(app); - } - } - return apps; - } - - Future loadMore() async { - final live = state.storage.models; - final combined = [...live, ...state.olderApps]; - if (state.isLoadingMore || !state.hasMore || combined.isEmpty) return; - - final oldest = combined - .map((a) => a.event.createdAt) - .reduce((a, b) => a.isBefore(b) ? a : b) - .subtract(const Duration(milliseconds: 1)); - - state = state.copyWith(isLoadingMore: true); - - try { - final result = await fetchAppsByAsset( - ref.storage, - tags: { - '#f': {'android-arm64-v8a'}, - }, + try { + final storage = ref.read(storageNotifierProvider.notifier); + final items = await storage.query( + RequestFilter( + tags: {'#f': {'android-arm64-v8a'}}, until: oldest, limit: _pageSize, - source: const LocalAndRemoteSource(stream: false), - subscriptionPrefix: 'app-latest-older', - ); - - if (result.apps.isNotEmpty) { - final existingIds = combined.map((a) => a.id).toSet(); - final uniqueOlder = result.apps - .where((a) => !existingIds.contains(a.id)) - .toList(); - state = state.copyWith( - olderApps: [...state.olderApps, ...uniqueOlder], - isLoadingMore: false, - hasMore: result.assetCount >= _pageSize, - ); - } else { - state = state.copyWith(isLoadingMore: false, hasMore: false); - } - } catch (e) { - state = state.copyWith(isLoadingMore: false); - rethrow; - } - } - - @override - void dispose() { - _sub?.close(); - super.dispose(); - } -} - -/// Status dot showing connection state for 'latest' subscription -class _LatestReleasesStatusDot extends ConsumerWidget { - const _LatestReleasesStatusDot(); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final poolState = ref.watch(poolStateProvider); - final subscriptions = poolState?.subscriptions ?? {}; - - // Filter to only "app-latest" subscription - final latestSubs = subscriptions.entries - .where((e) => e.key.startsWith('app-latest')) - .map((e) => e.value); - - // Check relay status for latest subscription - bool hasActiveConnection = false; - - for (final sub in latestSubs) { - for (final relay in sub.relays.values) { - if (relay.phase == RelaySubPhase.streaming || - relay.phase == RelaySubPhase.loading) { - hasActiveConnection = true; - break; - } - } - if (hasActiveConnection) break; - } - - final statusColor = hasActiveConnection ? Colors.green : Colors.red; - - return Container( - width: 8, - height: 8, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: statusColor, - boxShadow: hasActiveConnection - ? [ - BoxShadow( - color: statusColor.withValues(alpha: 0.4), - blurRadius: 4, - spreadRadius: 1, - ), - ] - : null, - ), + ).toRequest(), + source: const LocalAndRemoteSource(relays: 'AppCatalog', stream: false), + subscriptionPrefix: 'app-latest-older', ); + + final identifiers = items.map((a) => a.appIdentifier).toSet(); + if (identifiers.isNotEmpty) { + await storage.query( + RequestFilter(tags: {'#d': identifiers}).toRequest(), + source: const LocalAndRemoteSource(relays: 'AppCatalog', stream: false), + subscriptionPrefix: 'app-latest-older-apps', + ); + } + + 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; }