From 30db991dc4802c877e426acf5b5206ad265d3bd6 Mon Sep 17 00:00:00 2001 From: franzap <_@franzap.com> Date: Sat, 13 Dec 2025 22:23:39 -0300 Subject: [PATCH] Data loading improvements, remove unused code --- lib/main.dart | 24 +- lib/screens/developer_screen.dart | 112 +++-- lib/screens/search_screen.dart | 4 +- lib/services/bookmarks_service.dart | 2 +- lib/services/profile_provider.dart | 167 -------- lib/services/profile_service.dart | 203 +++++---- lib/services/updates_service.dart | 40 +- lib/widgets/app_curation_pickers.dart | 62 --- lib/widgets/app_pack_container.dart | 9 + lib/widgets/comments_section.dart | 16 +- lib/widgets/common/note_parser.dart | 467 ++------------------- lib/widgets/date_pill_widget.dart | 32 -- lib/widgets/install_alert_dialog.dart | 5 +- lib/widgets/latest_releases_container.dart | 46 +- 14 files changed, 241 insertions(+), 948 deletions(-) delete mode 100644 lib/services/profile_provider.dart delete mode 100644 lib/widgets/app_curation_pickers.dart delete mode 100644 lib/widgets/date_pill_widget.dart diff --git a/lib/main.dart b/lib/main.dart index e6fa92c..8272d38 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -58,11 +58,12 @@ void main() { /// Global error handler that reports errors via NIP-44 encrypted DMs void _errorHandler(Object exception, StackTrace? stack) { // Report error asynchronously (fire and forget) - unawaited( - _providerContainer - .read(errorReportingServiceProvider) - .reportError(exception, stack), - ); + // TODO: Disabled until careful review + // unawaited( + // _providerContainer + // .read(errorReportingServiceProvider) + // .reportError(exception, stack), + // ); } class ZapstoreApp extends HookConsumerWidget { @@ -218,6 +219,7 @@ final appInitializationProvider = FutureProvider((ref) async { initializationProvider( StorageConfiguration( databasePath: dbPath, + defaultQuerySource: LocalAndRemoteSource(relays: 'AppCatalog'), defaultRelays: { 'bootstrap': {'wss://purplepag.es', 'wss://relay.zapstore.dev'}, 'AppCatalog': {'wss://relay.zapstore.dev'}, @@ -282,12 +284,12 @@ class _AppLifecycleObserver with WidgetsBindingObserver { @override void didChangeAppLifecycleState(AppLifecycleState state) { final downloadService = _ref.read(downloadServiceProvider.notifier); - + if (state == AppLifecycleState.resumed) { // Notify download service that app is in foreground // This will process any pending installations unawaited(downloadService.setAppForeground(true)); - + // App regained focus - re-sync installed packages from Android system // This will detect any apps installed/uninstalled outside of Zapstore unawaited( @@ -302,10 +304,10 @@ class _AppLifecycleObserver with WidgetsBindingObserver { _ref.read(storageNotifierProvider.notifier) as PurplebaseStorageNotifier; notifier.ensureConnected(); - } else if (state == AppLifecycleState.paused || - state == AppLifecycleState.inactive || - state == AppLifecycleState.detached || - state == AppLifecycleState.hidden) { + } else if (state == AppLifecycleState.paused || + state == AppLifecycleState.inactive || + state == AppLifecycleState.detached || + state == AppLifecycleState.hidden) { // Notify download service that app is in background unawaited(downloadService.setAppForeground(false)); } diff --git a/lib/screens/developer_screen.dart b/lib/screens/developer_screen.dart index d2b34d0..e49383c 100644 --- a/lib/screens/developer_screen.dart +++ b/lib/screens/developer_screen.dart @@ -29,6 +29,31 @@ class DeveloperScreen extends HookConsumerWidget { final profileAsync = ref.watch(profileProvider(pubkey)); final profile = profileAsync.value; + // Shared developer apps query (AppCatalog) + final developerAppsState = ref.watch( + query( + authors: {pubkey}, + tags: { + '#f': {'android-arm64-v8a'}, + }, + limit: 20, + and: (app) => { + app.latestRelease, + app.latestRelease.value?.latestMetadata, + }, + source: const LocalAndRemoteSource( + relays: 'AppCatalog', + stream: true, + background: true, + ), + andSource: const LocalAndRemoteSource( + relays: 'AppCatalog', + stream: false, + ), + subscriptionPrefix: 'developer-apps', + ), + ); + return Scaffold( body: SafeArea( child: Column( @@ -41,7 +66,7 @@ class DeveloperScreen extends HookConsumerWidget { ), // Zaps list for all developer's apps - _DeveloperZapsList(pubkey: pubkey), + _DeveloperZapsList(pubkey: pubkey, appsState: developerAppsState), // Tabs for apps and info Expanded( @@ -61,6 +86,7 @@ class DeveloperScreen extends HookConsumerWidget { _DeveloperAppsTab( pubkey: pubkey, scrollController: scrollController, + appsState: developerAppsState, ), _DeveloperAboutTab( profile: profile, @@ -107,26 +133,15 @@ class _DeveloperAppsTab extends HookConsumerWidget { const _DeveloperAppsTab({ required this.pubkey, required this.scrollController, + required this.appsState, }); final String pubkey; final ScrollController scrollController; + final StorageState appsState; @override Widget build(BuildContext context, WidgetRef ref) { - // Query apps by this specific developer - final appsState = ref.watch( - query( - authors: {pubkey}, // Filter by developer pubkey - limit: 100, - tags: { - '#f': {'android-arm64-v8a'}, - }, - and: (app) => {app.latestRelease}, - subscriptionPrefix: 'developer-apps', - ), - ); - // Query author profile from 'social' relay group final authorAsync = ref.watch(profileProvider(pubkey)); final author = authorAsync.value; @@ -142,11 +157,10 @@ class _DeveloperAppsTab extends HookConsumerWidget { itemCount: 5, itemBuilder: (context, index) => AppCard(isLoading: true), ), - StorageError() => _buildErrorState( + StorageError() => _buildErrorState(context, appsState.toString()), + StorageData(:final models) when models.isEmpty => _buildEmptyState( context, - appsState.toString(), ), - StorageData(:final models) when models.isEmpty => _buildEmptyState(context), StorageData(:final models) => _buildAppsList( context, models, @@ -202,11 +216,7 @@ class _DeveloperAppsTab extends HookConsumerWidget { ); } - Widget _buildAppsList( - BuildContext context, - List apps, - Profile? author, - ) { + Widget _buildAppsList(BuildContext context, List apps, Profile? author) { return ListView.builder( padding: const EdgeInsets.all(16), itemCount: apps.length, @@ -222,26 +232,13 @@ class _DeveloperAppsTab extends HookConsumerWidget { } class _DeveloperZapsList extends HookConsumerWidget { - const _DeveloperZapsList({required this.pubkey}); + const _DeveloperZapsList({required this.pubkey, required this.appsState}); final String pubkey; + final StorageState appsState; @override Widget build(BuildContext context, WidgetRef ref) { - // Query all apps by this developer with their latest metadata - final appsState = ref.watch( - query( - authors: {pubkey}, - tags: { - '#f': {'android-arm64-v8a'}, - }, - limit: 100, - and: (app) => {app.latestRelease}, - source: LocalAndRemoteSource(stream: false, background: true), - subscriptionPrefix: 'developer-all-apps', - ), - ); - // Get all apps final apps = appsState is StorageData ? appsState.models : []; @@ -269,36 +266,27 @@ class _DeveloperZapsList extends HookConsumerWidget { } } - // Query zaps on app addressable IDs - final zapsOnAppsState = ref.watch( + final zapTags = >{ + ...allAppTags, + if (metadataIds.isNotEmpty) '#e': metadataIds, + }; + + if (zapTags.isEmpty) { + return const SizedBox.shrink(); + } + + // Single zap query across app addressable IDs and metadata + final zapsState = ref.watch( query( - tags: allAppTags, - source: LocalAndRemoteSource(relays: 'social'), + tags: zapTags, + source: const LocalAndRemoteSource(relays: 'social'), and: (zap) => {zap.author, zap.zapRequest}, - andSource: LocalAndRemoteSource(relays: 'social', stream: false), - subscriptionPrefix: 'developer-zaps-apps', + andSource: const LocalAndRemoteSource(relays: 'social', stream: false), + subscriptionPrefix: 'developer-zaps', ), ); - // Query zaps on file metadata - final zapsOnMetadataState = metadataIds.isNotEmpty - ? ref.watch( - query( - tags: {'#e': metadataIds}, - source: LocalAndRemoteSource(relays: 'social'), - and: (zap) => {zap.author, zap.zapRequest}, - andSource: LocalAndRemoteSource(relays: 'social', stream: false), - subscriptionPrefix: 'developer-zaps-metadata', - ), - ) - : null; - - // Combine all zaps - final allZaps = { - if (zapsOnAppsState is StorageData) ...zapsOnAppsState.models, - if (zapsOnMetadataState != null && zapsOnMetadataState is StorageData) - ...zapsOnMetadataState.models, - }; + final allZaps = zapsState is StorageData ? zapsState.models : const []; if (allZaps.isEmpty) { return const SizedBox.shrink(); diff --git a/lib/screens/search_screen.dart b/lib/screens/search_screen.dart index 11bf8a1..70b7fa3 100644 --- a/lib/screens/search_screen.dart +++ b/lib/screens/search_screen.dart @@ -39,9 +39,7 @@ class SearchScreen extends HookConsumerWidget { }, and: (app) => { app.latestRelease, - // Load nested FileMetadata from latestRelease (same relay group) - if (app.latestRelease.value != null) - app.latestRelease.value!.latestMetadata, + app.latestRelease.value?.latestMetadata, }, // Force the search to hit the default relay group (relay.zapstore.dev) // so a connection appears in Debug Info when searching. diff --git a/lib/services/bookmarks_service.dart b/lib/services/bookmarks_service.dart index 4511f97..a7eead1 100644 --- a/lib/services/bookmarks_service.dart +++ b/lib/services/bookmarks_service.dart @@ -24,7 +24,7 @@ final bookmarksProvider = FutureProvider>((ref) async { source: const LocalAndRemoteSource( relays: 'social', stream: false, - background: true, // Don't block on relay response + background: false, // Single fetch; no live streaming ), subscriptionPrefix: 'user-bookmarks', ), diff --git a/lib/services/profile_provider.dart b/lib/services/profile_provider.dart deleted file mode 100644 index cc811c2..0000000 --- a/lib/services/profile_provider.dart +++ /dev/null @@ -1,167 +0,0 @@ -import 'package:flutter/foundation.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:models/models.dart'; - -/// Cache duration before fetching from remote relays -const kProfileCacheDuration = Duration(hours: 2); - -/// Tracks last fetch times for profiles by pubkey -final _profileFetchTimesProvider = StateProvider>( - (ref) => {}, -); - -/// Tracks profile update timestamps to trigger reactivity -final _profileUpdateTriggerProvider = StateProvider>( - (ref) => {}, -); - -final _profileRemoteFetchProvider = FutureProvider.autoDispose - .family((ref, pubkey) async { - final storage = ref.read(storageNotifierProvider.notifier); - final request = RequestFilter(authors: {pubkey}).toRequest(); - - try { - final profiles = await storage.query( - request, - source: const RemoteSource( - relays: 'social', - stream: false, - background: false, - ), - ); - - // Save to local storage - if (profiles.isNotEmpty) { - await storage.save(profiles.toSet()); - } - - ref.read(_profileFetchTimesProvider.notifier).update((state) { - return {...state, pubkey: DateTime.now()}; - }); - } catch (error, stackTrace) { - debugPrint('Failed to fetch profile $pubkey: $error\n$stackTrace'); - } - }); - -/// Service for managing profile fetches with cache awareness -class ProfileService { - ProfileService(this.ref); - - final Ref ref; - - /// Fetches profiles from remote, respecting cache duration. - /// Pass a Set of pubkeys - use Set of 1 for single profile. - Future fetchProfiles(Set pubkeys) async { - if (pubkeys.isEmpty) return; - - final fetchTimes = ref.read(_profileFetchTimesProvider); - final now = DateTime.now(); - - // Filter out recently fetched profiles - final staleProfiles = pubkeys.where((pubkey) { - final lastFetch = fetchTimes[pubkey]; - return lastFetch == null || - now.difference(lastFetch) > kProfileCacheDuration; - }).toSet(); - - if (staleProfiles.isEmpty) return; - - try { - final profiles = await ref.read(storageNotifierProvider.notifier).query( - RequestFilter(authors: staleProfiles).toRequest(), - source: const RemoteSource( - relays: 'social', - stream: false, - background: false, - ), - ); - - // Save to local storage so they're available for LocalSource queries - if (profiles.isNotEmpty) { - await ref.read(storageNotifierProvider.notifier).save(profiles.toSet()); - - // Trigger reactivity by updating timestamp for each profile - ref.read(_profileUpdateTriggerProvider.notifier).update((state) { - final updated = {...state}; - for (final profile in profiles) { - updated[profile.event.pubkey] = DateTime.now(); - } - return updated; - }); - } - - // Update fetch times for all profiles - ref.read(_profileFetchTimesProvider.notifier).update((state) { - final updated = {...state}; - for (final pubkey in staleProfiles) { - updated[pubkey] = now; - } - return updated; - }); - } catch (error, stackTrace) { - debugPrint('Failed to fetch profiles: $error\n$stackTrace'); - } - } - - /// Invalidate cache for a specific profile, forcing a remote fetch on next access. - void invalidateProfile(String pubkey) { - ref.read(_profileFetchTimesProvider.notifier).update((state) { - final updated = {...state}; - updated.remove(pubkey); - return updated; - }); - } - - /// Invalidate cache for all profiles, forcing remote fetches on next access. - /// Useful for pull-to-refresh scenarios. - void invalidateAllProfiles() { - ref.read(_profileFetchTimesProvider.notifier).state = {}; - } -} - -/// Provider for the profile service -final profileServiceProvider = Provider((ref) => ProfileService(ref)); - -/// Provider family for accessing profiles with smart caching -/// -/// Automatically determines whether to trigger a remote refresh: -/// - If profile was fetched from remote < 2 hours ago: keep cached data -/// - If profile was never fetched or > 2 hours ago: run a background remote fetch -/// - Reacts to profile updates via _profileUpdateTriggerProvider -final profileProvider = - AutoDisposeProvider.family, String>((ref, pubkey) { - final lastFetchTime = ref.watch( - _profileFetchTimesProvider.select((state) => state[pubkey]), - ); - - // Watch update trigger to react when profiles are saved - ref.watch( - _profileUpdateTriggerProvider.select((state) => state[pubkey]), - ); - - final now = DateTime.now(); - final shouldFetchRemote = - lastFetchTime == null || - now.difference(lastFetchTime) > kProfileCacheDuration; - - // Trigger background fetch if cache is stale - if (shouldFetchRemote) { - ref.watch(_profileRemoteFetchProvider(pubkey)); - } - - // Read from local storage (will re-run when update trigger changes) - final profileState = ref.watch( - query(authors: {pubkey}, source: const LocalSource()), - ); - - return switch (profileState) { - StorageLoading() => const AsyncValue.loading(), - StorageError(:final exception) => AsyncValue.error( - exception, - StackTrace.current, - ), - StorageData(:final models) => AsyncValue.data( - models.isNotEmpty ? models.first : null, - ), - }; - }); diff --git a/lib/services/profile_service.dart b/lib/services/profile_service.dart index cc811c2..2ccb4b8 100644 --- a/lib/services/profile_service.dart +++ b/lib/services/profile_service.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/foundation.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:models/models.dart'; @@ -5,6 +7,9 @@ import 'package:models/models.dart'; /// Cache duration before fetching from remote relays const kProfileCacheDuration = Duration(hours: 2); +/// Debounce duration for batching profile requests +const kProfileFetchDebounce = Duration(milliseconds: 500); + /// Tracks last fetch times for profiles by pubkey final _profileFetchTimesProvider = StateProvider>( (ref) => {}, @@ -15,71 +20,80 @@ final _profileUpdateTriggerProvider = StateProvider>( (ref) => {}, ); -final _profileRemoteFetchProvider = FutureProvider.autoDispose - .family((ref, pubkey) async { - final storage = ref.read(storageNotifierProvider.notifier); - final request = RequestFilter(authors: {pubkey}).toRequest(); - - try { - final profiles = await storage.query( - request, - source: const RemoteSource( - relays: 'social', - stream: false, - background: false, - ), - ); - - // Save to local storage - if (profiles.isNotEmpty) { - await storage.save(profiles.toSet()); - } - - ref.read(_profileFetchTimesProvider.notifier).update((state) { - return {...state, pubkey: DateTime.now()}; - }); - } catch (error, stackTrace) { - debugPrint('Failed to fetch profile $pubkey: $error\n$stackTrace'); - } - }); - -/// Service for managing profile fetches with cache awareness +/// Service for managing profile fetches with cache awareness and debounced batching class ProfileService { ProfileService(this.ref); - + final Ref ref; - - /// Fetches profiles from remote, respecting cache duration. - /// Pass a Set of pubkeys - use Set of 1 for single profile. - Future fetchProfiles(Set pubkeys) async { - if (pubkeys.isEmpty) return; - - final fetchTimes = ref.read(_profileFetchTimesProvider); - final now = DateTime.now(); - - // Filter out recently fetched profiles - final staleProfiles = pubkeys.where((pubkey) { - final lastFetch = fetchTimes[pubkey]; - return lastFetch == null || - now.difference(lastFetch) > kProfileCacheDuration; - }).toSet(); - - if (staleProfiles.isEmpty) return; - + + /// Pending pubkeys waiting to be fetched (collected during debounce window) + final Set _pendingPubkeys = {}; + + /// Pubkeys currently being fetched (prevents duplicate in-flight requests) + final Set _inFlightPubkeys = {}; + + /// Debounce timer for batching profile fetches + Timer? _debounceTimer; + + /// Request a profile fetch with debouncing. + /// Requests are collected and batched after the debounce window. + void requestProfile(String pubkey) { + // Skip if already pending or in-flight + if (_pendingPubkeys.contains(pubkey) || _inFlightPubkeys.contains(pubkey)) { + return; + } + + _pendingPubkeys.add(pubkey); + + // Reset/start debounce timer + _debounceTimer?.cancel(); + _debounceTimer = Timer(kProfileFetchDebounce, _executeBatchFetch); + } + + /// Executes batch fetch for all pending profiles + Future _executeBatchFetch() async { + if (_pendingPubkeys.isEmpty) return; + + // Move pending to in-flight + final pubkeysToFetch = Set.from(_pendingPubkeys); + _pendingPubkeys.clear(); + _inFlightPubkeys.addAll(pubkeysToFetch); + try { - final profiles = await ref.read(storageNotifierProvider.notifier).query( - RequestFilter(authors: staleProfiles).toRequest(), + await _fetchProfilesBatch(pubkeysToFetch); + } finally { + // Remove from in-flight when done + _inFlightPubkeys.removeAll(pubkeysToFetch); + } + } + + /// Internal batch fetch implementation + Future _fetchProfilesBatch(Set pubkeys) async { + if (pubkeys.isEmpty) return; + + final storage = ref.read(storageNotifierProvider.notifier); + final now = DateTime.now(); + + debugPrint('ProfileService: Batch fetching ${pubkeys.length} profiles'); + + try { + // Use RemoteSource with background: false to ensure we wait for relay data. + // This is fast because we're batching - one request for all profiles. + final profiles = await storage.query( + RequestFilter(authors: pubkeys).toRequest(), source: const RemoteSource( - relays: 'social', + relays: {'social', 'vertex'}, stream: false, - background: false, + background: false, // Wait for EOSE - fast since batched ), ); - + + debugPrint('ProfileService: Fetched ${profiles.length} profiles'); + // Save to local storage so they're available for LocalSource queries if (profiles.isNotEmpty) { - await ref.read(storageNotifierProvider.notifier).save(profiles.toSet()); - + await storage.save(profiles.toSet()); + // Trigger reactivity by updating timestamp for each profile ref.read(_profileUpdateTriggerProvider.notifier).update((state) { final updated = {...state}; @@ -89,17 +103,48 @@ class ProfileService { return updated; }); } - - // Update fetch times for all profiles + + // Update fetch times for all requested pubkeys (even if not found) ref.read(_profileFetchTimesProvider.notifier).update((state) { final updated = {...state}; - for (final pubkey in staleProfiles) { + for (final pubkey in pubkeys) { updated[pubkey] = now; } return updated; }); } catch (error, stackTrace) { - debugPrint('Failed to fetch profiles: $error\n$stackTrace'); + debugPrint( + 'ProfileService: Failed to batch fetch profiles: $error\n$stackTrace', + ); + } + } + + /// Fetches profiles from remote, respecting cache duration. + /// Pass a Set of pubkeys - use Set of 1 for single profile. + /// This method bypasses debouncing for immediate batch fetches. + Future fetchProfiles(Set pubkeys) async { + if (pubkeys.isEmpty) return; + + final fetchTimes = ref.read(_profileFetchTimesProvider); + final now = DateTime.now(); + + // Filter out recently fetched profiles and in-flight requests + final staleProfiles = pubkeys.where((pubkey) { + if (_inFlightPubkeys.contains(pubkey)) return false; + final lastFetch = fetchTimes[pubkey]; + return lastFetch == null || + now.difference(lastFetch) > kProfileCacheDuration; + }).toSet(); + + if (staleProfiles.isEmpty) return; + + // Mark as in-flight + _inFlightPubkeys.addAll(staleProfiles); + + try { + await _fetchProfilesBatch(staleProfiles); + } finally { + _inFlightPubkeys.removeAll(staleProfiles); } } @@ -122,36 +167,26 @@ class ProfileService { /// Provider for the profile service final profileServiceProvider = Provider((ref) => ProfileService(ref)); -/// Provider family for accessing profiles with smart caching +/// Provider family for accessing profiles with reactive updates /// -/// Automatically determines whether to trigger a remote refresh: -/// - If profile was fetched from remote < 2 hours ago: keep cached data -/// - If profile was never fetched or > 2 hours ago: run a background remote fetch -/// - Reacts to profile updates via _profileUpdateTriggerProvider +/// - Returns local data immediately if available +/// - Reacts to profile updates from batch fetches via trigger mechanism +/// - Streams updates when profile data changes final profileProvider = AutoDisposeProvider.family, String>((ref, pubkey) { - final lastFetchTime = ref.watch( - _profileFetchTimesProvider.select((state) => state[pubkey]), - ); + // Watch update trigger to react when profiles are saved by fetchProfiles() + ref.watch(_profileUpdateTriggerProvider.select((state) => state[pubkey])); - // Watch update trigger to react when profiles are saved - ref.watch( - _profileUpdateTriggerProvider.select((state) => state[pubkey]), - ); - - final now = DateTime.now(); - final shouldFetchRemote = - lastFetchTime == null || - now.difference(lastFetchTime) > kProfileCacheDuration; - - // Trigger background fetch if cache is stale - if (shouldFetchRemote) { - ref.watch(_profileRemoteFetchProvider(pubkey)); - } - - // Read from local storage (will re-run when update trigger changes) + // Use query with LocalAndRemoteSource for reactive updates final profileState = ref.watch( - query(authors: {pubkey}, source: const LocalSource()), + query( + authors: {pubkey}, + source: LocalAndRemoteSource( + relays: {'social', 'vertex'}, + stream: true, + background: true, // Don't block on remote + ), + ), ); return switch (profileState) { diff --git a/lib/services/updates_service.dart b/lib/services/updates_service.dart index 20c7675..ce2a66f 100644 --- a/lib/services/updates_service.dart +++ b/lib/services/updates_service.dart @@ -174,42 +174,8 @@ final categorizedAppsProvider = ); /// Provider that calculates the total number of apps with available updates -/// Uses LocalSource since categorizedAppsProvider already fetches from relays final updateCountProvider = Provider((ref) { - // Watch package manager for installed state changes - final packages = ref.watch(packageManagerProvider); - final installedIds = packages.map((p) => p.appId).toSet(); - final platform = ref.read(packageManagerProvider.notifier).platform; - - if (installedIds.isEmpty) { - return 0; - } - - // Query apps from local storage only - data is already fetched by categorizedAppsProvider - final state = ref.watch( - query( - tags: { - '#d': installedIds, - '#f': {platform}, - }, - and: (app) => { - app.latestRelease, - // Load nested FileMetadata - critical for hasUpdate to work - if (app.latestRelease.value != null) - app.latestRelease.value!.latestMetadata, - }, - source: const LocalSource(), - subscriptionPrefix: 'update-count', - ), - ); - - int updateCount = 0; - - for (final app in state.models) { - if (app.hasUpdate) { - updateCount++; - } - } - - return updateCount; + final categorized = ref.watch(categorizedAppsProvider); + if (categorized.isLoading) return 0; + return categorized.automaticUpdates.length + categorized.manualUpdates.length; }); diff --git a/lib/widgets/app_curation_pickers.dart b/lib/widgets/app_curation_pickers.dart deleted file mode 100644 index 95b5008..0000000 --- a/lib/widgets/app_curation_pickers.dart +++ /dev/null @@ -1,62 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:models/models.dart'; -import 'package:zapstore/widgets/profiles_rich_text.dart'; - -class AppPickersContainer extends StatelessWidget { - const AppPickersContainer({super.key, required this.app, this.author}); - - final App app; - final Profile? author; - - @override - Widget build(BuildContext context) { - final appPacks = app.appPacks.toList(); - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (appPacks.isNotEmpty) ...[ - _AppPackPickers(appPacks: appPacks), - const SizedBox(height: 16), - ], - ], - ); - } -} - -class _AppPackPickers extends StatelessWidget { - const _AppPackPickers({required this.appPacks}); - - final List appPacks; - - @override - Widget build(BuildContext context) { - final seenPubkeys = {}; - final curatorProfiles = appPacks - .map((pack) => pack.author.value) - .where((profile) => profile != null && seenPubkeys.add(profile.pubkey)) - .cast() - .toList(); - - if (curatorProfiles.isEmpty) { - return const SizedBox.shrink(); - } - - return Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surfaceContainer, - borderRadius: BorderRadius.circular(8), - ), - child: ProfilesRichText( - profiles: curatorProfiles, - trailingText: ' picked this app', - maxProfilesToDisplay: 3, - avatarRadius: 12, - textStyle: Theme.of( - context, - ).textTheme.bodySmall?.copyWith(color: Colors.grey[600]), - ), - ); - } -} diff --git a/lib/widgets/app_pack_container.dart b/lib/widgets/app_pack_container.dart index 8353961..86a0ddc 100644 --- a/lib/widgets/app_pack_container.dart +++ b/lib/widgets/app_pack_container.dart @@ -195,6 +195,15 @@ class AppPackContainer extends HookConsumerWidget { // Show newest packs first (also drives default selection) final sortedPacks = _sortPacksNewestFirst(nonEmptyPacks); + // Fetch author profiles for all app packs (batch fetch like latest_releases) + useEffect(() { + final authorPubkeys = sortedPacks.map((p) => p.event.pubkey).toSet(); + if (authorPubkeys.isNotEmpty) { + ref.read(profileServiceProvider).fetchProfiles(authorPubkeys); + } + return null; + }, [sortedPacks.map((p) => p.id).join(',')]); + // Default to the newest available pack final defaultSelection = sortedPacks.first.id; diff --git a/lib/widgets/comments_section.dart b/lib/widgets/comments_section.dart index a61ccd0..e3e42e5 100644 --- a/lib/widgets/comments_section.dart +++ b/lib/widgets/comments_section.dart @@ -8,10 +8,11 @@ import 'package:zapstore/widgets/common/profile_avatar.dart'; import 'package:intl/intl.dart'; import 'package:zapstore/widgets/sign_in_button.dart'; import 'package:zapstore/services/notification_service.dart'; +import 'package:zapstore/services/profile_service.dart'; import 'package:zapstore/widgets/pill_widget.dart'; import 'package:zapstore/theme.dart'; -class CommentsSection extends ConsumerWidget { +class CommentsSection extends HookConsumerWidget { const CommentsSection({super.key, this.fileMetadata}); final FileMetadata? fileMetadata; @@ -51,6 +52,15 @@ class CommentsSection extends ConsumerWidget { _ => null, }; + // Batch fetch author profiles for all comments + useEffect(() { + final authorPubkeys = comments.map((c) => c.event.pubkey).toSet(); + if (authorPubkeys.isNotEmpty) { + ref.read(profileServiceProvider).fetchProfiles(authorPubkeys); + } + return null; + }, [comments.map((c) => c.id).join(',')]); + return Padding( padding: const EdgeInsets.all(16), child: Column( @@ -155,7 +165,9 @@ class _CommentCard extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final author = comment.author.value; + // Use profileProvider for centralized caching and batched fetching + final authorAsync = ref.watch(profileProvider(comment.event.pubkey)); + final author = authorAsync.value ?? comment.author.value; // Extract version from v tag (per NIP-22 guidance) final version = comment.event.getFirstTagValue('v'); diff --git a/lib/widgets/common/note_parser.dart b/lib/widgets/common/note_parser.dart index e0b214f..484df55 100644 --- a/lib/widgets/common/note_parser.dart +++ b/lib/widgets/common/note_parser.dart @@ -1,12 +1,8 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:models/models.dart'; -import 'package:skeletonizer/skeletonizer.dart'; import 'package:zapstore/utils/extensions.dart'; -import '../../../theme.dart'; import 'package:zapstore/services/profile_service.dart'; -import 'package:zapstore/widgets/common/time_utils.dart'; -import 'package:zapstore/widgets/common/profile_avatar.dart'; import 'package:any_link_preview/any_link_preview.dart'; import 'package:url_launcher/url_launcher.dart'; import 'package:cached_network_image/cached_network_image.dart'; @@ -365,8 +361,6 @@ class NostrEntityWidget extends StatelessWidget { EventData() => EventEntityWidget( eventData: decoded, colorPair: colorPair, - onProfileTap: onProfileTap, - onHashtagTap: onHashtagTap, ), AddressData() => AddressEntityWidget( addressData: decoded, @@ -436,136 +430,21 @@ class ProfileEntityWidget extends ConsumerWidget { } } -class EventEntityWidget extends ConsumerWidget { +class EventEntityWidget extends StatelessWidget { final EventData eventData; final List colorPair; - final void Function(String pubkey)? onProfileTap; - final void Function(String hashtag)? onHashtagTap; const EventEntityWidget({ super.key, required this.eventData, required this.colorPair, - this.onProfileTap, - this.onHashtagTap, }); @override - Widget build(BuildContext context, WidgetRef ref) { - final noteState = ref.watch( - query( - ids: {eventData.eventId}, - limit: 1, - and: (note) => {note.author}, - // Profile relationships should come from social relays - andSource: const LocalAndRemoteSource(relays: 'social'), - subscriptionPrefix: 'entity-note', - ), - ); - - final note = noteState.models.firstOrNull; - - if (note == null) { - return Container( - margin: const EdgeInsets.symmetric(vertical: 8.0), - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surfaceContainerLowest, - borderRadius: BorderRadius.circular(12.0), - border: Border.all( - color: colorPair[0].withValues(alpha: 0.2), - width: 1.0, - ), - boxShadow: [ - BoxShadow( - color: colorPair[0].withValues(alpha: 0.05), - blurRadius: 4.0, - offset: const Offset(0, 2), - ), - ], - ), - child: Padding( - padding: const EdgeInsets.all(16.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Skeleton for author info - SkeletonizerConfig( - data: AppColors.getSkeletonizerConfig( - Theme.of(context).brightness, - ), - child: Skeletonizer( - enabled: true, - child: Row( - children: [ - Container( - width: 24, - height: 24, - decoration: const BoxDecoration( - color: Colors.white, - shape: BoxShape.circle, - ), - ), - const SizedBox(width: 8.0), - Expanded( - child: Container( - width: 100, - height: 13, - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(4), - ), - ), - ), - Container( - width: 40, - height: 11, - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(4), - ), - ), - ], - ), - ), - ), - const SizedBox(height: 8.0), - - // Skeleton for note content - SkeletonizerConfig( - data: AppColors.getSkeletonizerConfig( - Theme.of(context).brightness, - ), - child: Skeletonizer( - enabled: true, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Container( - width: double.infinity, - height: 14, - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(4), - ), - ), - const SizedBox(height: 4), - Container( - width: MediaQuery.of(context).size.width * 0.7, - height: 14, - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(4), - ), - ), - ], - ), - ), - ), - ], - ), - ), - ); - } + Widget build(BuildContext context) { + final shortId = eventData.eventId.length > 12 + ? '${eventData.eventId.substring(0, 8)}…' + : eventData.eventId; return Container( margin: const EdgeInsets.symmetric(vertical: 8.0), @@ -586,52 +465,34 @@ class EventEntityWidget extends ConsumerWidget { ), child: Padding( padding: const EdgeInsets.all(16.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + child: Row( children: [ - GestureDetector( - onTap: onProfileTap != null - ? () => onProfileTap!(note.event.pubkey) - : null, - child: Row( + Icon(Icons.event, color: colorPair[0]), + const SizedBox(width: 8.0), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - ProfileAvatar( - profile: note.author.value, - radius: 12, - borderColors: colorPair, + Text( + 'Event', + style: context.textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w600, + ), ), - const SizedBox(width: 8.0), - Expanded( - child: note.author.value?.nameOrNpub != null - ? Text( - note.author.value!.nameOrNpub, - style: context.textTheme.bodyMedium!.copyWith( - fontSize: 13, - fontWeight: FontWeight.w500, - ), - ) - : _AnimatedLoadingChip( - text: - 'npub1${note.event.pubkey.substring(0, 8)}...', - colorPair: colorPair, - ), - ), - TimeAgoText( - note.createdAt, - style: Theme.of( - context, - ).textTheme.bodySmall!.copyWith(fontSize: 11), + const SizedBox(height: 4), + Text( + shortId, + style: context.textTheme.bodySmall?.copyWith( + color: Theme.of(context) + .colorScheme + .onSurface + .withValues(alpha: 0.7), + fontFeatures: const [FontFeature.tabularFigures()], + ), ), ], ), ), - const SizedBox(height: 8.0), - ParsedContentWidget( - note: note, - colorPair: colorPair, - onProfileTap: onProfileTap, - onHashtagTap: onHashtagTap, - ), ], ), ), @@ -706,282 +567,6 @@ class GenericNip19Widget extends StatelessWidget { } } -class ParsedContentWidget extends ConsumerWidget { - final Note note; - final List colorPair; - final void Function(String pubkey)? onProfileTap; - final void Function(String hashtag)? onHashtagTap; - - const ParsedContentWidget({ - super.key, - required this.note, - required this.colorPair, - this.onProfileTap, - this.onHashtagTap, - }); - - @override - Widget build(BuildContext context, WidgetRef ref) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Reply context - show if this note is a reply - if (note.replyTo.value != null) ...[ - ReplyContextWidget( - replyingNote: note, - colorPair: colorPair, - onProfileTap: onProfileTap, - onHashtagTap: onHashtagTap, - ), - const SizedBox(height: 8), - ], - - // Original note content - _buildNoteContent(context), - ], - ); - } - - Widget _buildNoteContent(BuildContext context) { - if (note.content.trim().isEmpty) { - return Text( - 'No content', - style: context.textTheme.bodyMedium!.copyWith( - fontStyle: FontStyle.italic, - color: Colors.grey[500], - ), - ); - } - - return NoteParser.parse( - context, - note.content, - textStyle: context.textTheme.bodyMedium, - linkStyle: context.textTheme.bodyMedium!.copyWith( - color: colorPair[0], - decoration: TextDecoration.underline, - ), - onNostrEntity: (entity) => NostrEntityWidget( - entity: entity, - colorPair: colorPair, - onProfileTap: onProfileTap, - onHashtagTap: onHashtagTap, - ), - onHttpUrl: (url) => UrlChipWidget(url: url, colorPair: colorPair), - onMediaUrl: (url) => MediaWidget(url: url, colorPair: colorPair), - onHashtagTap: onHashtagTap, - onProfileTap: onProfileTap, - ); - } -} - -/// Internal widget for reply context - now part of the note parser -class ReplyContextWidget extends ConsumerWidget { - final Note replyingNote; - final List colorPair; - final void Function(String pubkey)? onProfileTap; - final void Function(String hashtag)? onHashtagTap; - - const ReplyContextWidget({ - super.key, - required this.replyingNote, - required this.colorPair, - this.onProfileTap, - this.onHashtagTap, - }); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final replyToNote = replyingNote.replyTo.value; - - if (replyToNote == null) { - return const SizedBox.shrink(); - } - - // Load the author for the replyTo note - final authorAsync = ref.watch(profileProvider(replyToNote.event.pubkey)); - final author = authorAsync.value; - final isAuthorLoading = authorAsync.isLoading || !authorAsync.hasValue; - - // Check if the reply-to note itself is still being loaded - final replyToNoteState = ref.watch( - query( - ids: {replyToNote.event.id}, - limit: 1, - subscriptionPrefix: 'reply-note', - ), - ); - - final isNoteLoading = replyToNoteState is StorageLoading; - - return Container( - padding: const EdgeInsets.all(12.0), - decoration: BoxDecoration( - color: Theme.of( - context, - ).colorScheme.surfaceContainer.withValues(alpha: 0.3), - borderRadius: BorderRadius.circular(8.0), - border: Border.all( - color: colorPair[0].withValues(alpha: 0.2), - width: 1.0, - ), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // "Replying to" indicator - Row( - children: [ - Icon( - Icons.reply, - size: 14, - color: colorPair[0].withValues(alpha: 0.7), - ), - const SizedBox(width: 4), - Text( - 'Replying to', - style: context.textTheme.bodySmall?.copyWith( - color: colorPair[0].withValues(alpha: 0.7), - fontWeight: FontWeight.w500, - ), - ), - ], - ), - const SizedBox(height: 8), - - // Parent note preview - Container( - padding: const EdgeInsets.all(12.0), - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surface, - borderRadius: BorderRadius.circular(6.0), - border: Border.all( - color: Theme.of( - context, - ).colorScheme.outline.withValues(alpha: 0.2), - ), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Parent note author - GestureDetector( - onTap: onProfileTap != null - ? () => onProfileTap!(replyToNote.event.pubkey) - : null, - child: Row( - children: [ - ProfileAvatar( - profile: author, - radius: 10, - borderColors: colorPair, - ), - const SizedBox(width: 6), - Expanded( - child: isAuthorLoading || isNoteLoading - ? _AnimatedLoadingChip( - text: - 'npub1${replyToNote.event.pubkey.substring(0, 8)}...', - colorPair: colorPair, - ) - : Text( - author?.nameOrNpub ?? - '${replyToNote.event.pubkey.substring(0, 8)}...', - style: context.textTheme.bodySmall?.copyWith( - fontWeight: FontWeight.w500, - fontSize: 12, - ), - ), - ), - TimeAgoText( - replyToNote.createdAt, - style: context.textTheme.bodySmall?.copyWith( - fontSize: 11, - color: Theme.of( - context, - ).colorScheme.onSurface.withValues(alpha: 0.6), - ), - ), - ], - ), - ), - const SizedBox(height: 6), - - // Parent note content with proper loading state handling - isNoteLoading || isAuthorLoading - ? Skeletonizer( - enabled: true, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Container( - width: double.infinity, - height: 12, - decoration: BoxDecoration( - color: Theme.of( - context, - ).colorScheme.surfaceContainerHighest, - borderRadius: BorderRadius.circular(4), - ), - ), - const SizedBox(height: 4), - Container( - width: MediaQuery.of(context).size.width * 0.6, - height: 12, - decoration: BoxDecoration( - color: Theme.of( - context, - ).colorScheme.surfaceContainerHighest, - borderRadius: BorderRadius.circular(4), - ), - ), - ], - ), - ) - : replyToNote.content.trim().isEmpty - ? Text( - 'No content', - style: context.textTheme.bodySmall?.copyWith( - fontStyle: FontStyle.italic, - color: Theme.of( - context, - ).colorScheme.onSurface.withValues(alpha: 0.5), - fontSize: 12, - ), - ) - : _buildFullContent(context, replyToNote.content), - ], - ), - ), - ], - ), - ); - } - - Widget _buildFullContent(BuildContext context, String content) { - return NoteParser.parse( - context, - content, - textStyle: context.textTheme.bodySmall?.copyWith(fontSize: 12), - linkStyle: context.textTheme.bodySmall?.copyWith( - color: colorPair[0], - decoration: TextDecoration.underline, - fontSize: 12, - ), - onNostrEntity: (entity) => NostrEntityWidget( - entity: entity, - colorPair: colorPair, - onProfileTap: onProfileTap, - onHashtagTap: onHashtagTap, - ), - onHttpUrl: (url) => UrlChipWidget(url: url, colorPair: colorPair), - onMediaUrl: (url) => MediaWidget(url: url, colorPair: colorPair), - onHashtagTap: onHashtagTap, - onProfileTap: onProfileTap, - ); - } -} - class UrlChipWidget extends StatelessWidget { final String url; final List colorPair; diff --git a/lib/widgets/date_pill_widget.dart b/lib/widgets/date_pill_widget.dart deleted file mode 100644 index 68a5646..0000000 --- a/lib/widgets/date_pill_widget.dart +++ /dev/null @@ -1,32 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:intl/intl.dart'; -import 'package:zapstore/utils/extensions.dart'; - -/// Date pill widget showing a formatted date in pill format -class DatePillWidget extends StatelessWidget { - final DateTime date; - - const DatePillWidget({super.key, required this.date}); - - @override - Widget build(BuildContext context) { - final formattedDate = DateFormat('MMM d, y').format(date); - - return Container( - padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 6), - decoration: BoxDecoration( - color: Colors.grey.shade300, - borderRadius: BorderRadius.circular(8), - ), - child: Text( - formattedDate, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: context.textTheme.labelMedium?.copyWith( - fontWeight: FontWeight.bold, - color: Colors.grey.shade700, - ), - ), - ); - } -} diff --git a/lib/widgets/install_alert_dialog.dart b/lib/widgets/install_alert_dialog.dart index 42d3aee..5f453db 100644 --- a/lib/widgets/install_alert_dialog.dart +++ b/lib/widgets/install_alert_dialog.dart @@ -3,6 +3,7 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:gap/gap.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:models/models.dart'; +import 'package:zapstore/services/profile_service.dart'; import 'package:zapstore/utils/extensions.dart'; import 'package:zapstore/widgets/author_container.dart'; import 'package:zapstore/widgets/common/base_dialog.dart'; @@ -17,7 +18,9 @@ class InstallAlertDialog extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final publisher = app.author.value; + // Use profileProvider with fallback to relationship + final publisherAsync = ref.watch(profileProvider(app.pubkey)); + final publisher = publisherAsync.value ?? app.author.value; if (publisher == null) { return const SizedBox.shrink(); } diff --git a/lib/widgets/latest_releases_container.dart b/lib/widgets/latest_releases_container.dart index 08608bd..aecf463 100644 --- a/lib/widgets/latest_releases_container.dart +++ b/lib/widgets/latest_releases_container.dart @@ -268,8 +268,7 @@ class LatestReleasesNotifier extends StateNotifier { and: (app) => { app.latestRelease, // Load nested FileMetadata from latestRelease (same relay group) - if (app.latestRelease.value != null) - app.latestRelease.value!.latestMetadata, + app.latestRelease.value?.latestMetadata, }, // NOTE: It must stream=true source: const LocalAndRemoteSource( @@ -291,14 +290,6 @@ class LatestReleasesNotifier extends StateNotifier { .where((a) => !liveIds.contains(a.id)) .toList(); state = state.copyWith(storage: next, olderApps: filteredOlder); - - final newApps = next.models; - - // For new apps, one-off fetch their relationships - // TODO: Not needed - should be dealt with by and/andSource - if (newApps.isNotEmpty) { - await _fetchRelationshipsForNewApps(newApps); - } } else { state = state.copyWith(storage: next); } @@ -311,41 +302,6 @@ class LatestReleasesNotifier extends StateNotifier { ); } - /// One-off fetch of Release and FileMetadata for newly received apps - Future _fetchRelationshipsForNewApps(List newApps) async { - if (newApps.isEmpty) return; - - // One-off fetch: Get releases for these apps from remote - final releases = await ref.storage.query( - Request( - newApps - .map((app) => app.latestRelease.req?.filters.firstOrNull) - .nonNulls - .toList(), - ), - source: const RemoteSource(stream: false), - ); - - // One-off fetch: Get file metadata for the releases from remote - if (releases.isNotEmpty) { - await ref.storage.query( - Request( - releases - .map((r) => r.latestMetadata.req?.filters.firstOrNull) - .nonNulls - .toList(), - ), - source: const RemoteSource(stream: false), - ); - } - - // One-off fetch: Get release author profiles from 'social' relay group - final authorPubkeys = releases.map((r) => r.event.pubkey).toSet(); - if (authorPubkeys.isNotEmpty) { - await ref.read(profileServiceProvider).fetchProfiles(authorPubkeys); - } - } - /// Fetch authors for a page of apps (used during pagination) Future _loadRelationshipsFor(List appsPage) async { final releases = appsPage