Data loading improvements, remove unused code

This commit is contained in:
franzap
2025-12-13 22:23:39 -03:00
parent 7e77aae8c3
commit 30db991dc4
14 changed files with 241 additions and 948 deletions
+13 -11
View File
@@ -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<void>((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));
}
+50 -62
View File
@@ -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<App>(
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<App> appsState;
@override
Widget build(BuildContext context, WidgetRef ref) {
// Query apps by this specific developer
final appsState = ref.watch(
query<App>(
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<App> apps,
Profile? author,
) {
Widget _buildAppsList(BuildContext context, List<App> 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<App> appsState;
@override
Widget build(BuildContext context, WidgetRef ref) {
// Query all apps by this developer with their latest metadata
final appsState = ref.watch(
query<App>(
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 : <App>[];
@@ -269,36 +266,27 @@ class _DeveloperZapsList extends HookConsumerWidget {
}
}
// Query zaps on app addressable IDs
final zapsOnAppsState = ref.watch(
final zapTags = <String, Set<String>>{
...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<Zap>(
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<Zap>(
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 = <Zap>{
if (zapsOnAppsState is StorageData) ...zapsOnAppsState.models,
if (zapsOnMetadataState != null && zapsOnMetadataState is StorageData)
...zapsOnMetadataState.models,
};
final allZaps = zapsState is StorageData ? zapsState.models : const <Zap>[];
if (allZaps.isEmpty) {
return const SizedBox.shrink();
+1 -3
View File
@@ -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.
+1 -1
View File
@@ -24,7 +24,7 @@ final bookmarksProvider = FutureProvider<Set<String>>((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',
),
-167
View File
@@ -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<Map<String, DateTime>>(
(ref) => {},
);
/// Tracks profile update timestamps to trigger reactivity
final _profileUpdateTriggerProvider = StateProvider<Map<String, DateTime>>(
(ref) => {},
);
final _profileRemoteFetchProvider = FutureProvider.autoDispose
.family<void, String>((ref, pubkey) async {
final storage = ref.read(storageNotifierProvider.notifier);
final request = RequestFilter<Profile>(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<void> fetchProfiles(Set<String> 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<Profile>(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<AsyncValue<Profile?>, 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<Profile>(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,
),
};
});
+119 -84
View File
@@ -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<Map<String, DateTime>>(
(ref) => {},
@@ -15,71 +20,80 @@ final _profileUpdateTriggerProvider = StateProvider<Map<String, DateTime>>(
(ref) => {},
);
final _profileRemoteFetchProvider = FutureProvider.autoDispose
.family<void, String>((ref, pubkey) async {
final storage = ref.read(storageNotifierProvider.notifier);
final request = RequestFilter<Profile>(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<void> fetchProfiles(Set<String> 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<String> _pendingPubkeys = {};
/// Pubkeys currently being fetched (prevents duplicate in-flight requests)
final Set<String> _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<void> _executeBatchFetch() async {
if (_pendingPubkeys.isEmpty) return;
// Move pending to in-flight
final pubkeysToFetch = Set<String>.from(_pendingPubkeys);
_pendingPubkeys.clear();
_inFlightPubkeys.addAll(pubkeysToFetch);
try {
final profiles = await ref.read(storageNotifierProvider.notifier).query(
RequestFilter<Profile>(authors: staleProfiles).toRequest(),
await _fetchProfilesBatch(pubkeysToFetch);
} finally {
// Remove from in-flight when done
_inFlightPubkeys.removeAll(pubkeysToFetch);
}
}
/// Internal batch fetch implementation
Future<void> _fetchProfilesBatch(Set<String> 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<Profile>(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<void> fetchProfiles(Set<String> 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<AsyncValue<Profile?>, 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<Profile>(authors: {pubkey}, source: const LocalSource()),
query<Profile>(
authors: {pubkey},
source: LocalAndRemoteSource(
relays: {'social', 'vertex'},
stream: true,
background: true, // Don't block on remote
),
),
);
return switch (profileState) {
+3 -37
View File
@@ -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<int>((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<App>(
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;
});
-62
View File
@@ -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<AppPack> appPacks;
@override
Widget build(BuildContext context) {
final seenPubkeys = <String>{};
final curatorProfiles = appPacks
.map((pack) => pack.author.value)
.where((profile) => profile != null && seenPubkeys.add(profile.pubkey))
.cast<Profile>()
.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]),
),
);
}
}
+9
View File
@@ -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;
+14 -2
View File
@@ -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');
+26 -441
View File
@@ -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<Color> 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<Note>(
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<Color> 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<Color> 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<Note>(
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<Color> colorPair;
-32
View File
@@ -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,
),
),
);
}
}
+4 -1
View File
@@ -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();
}
+1 -45
View File
@@ -268,8 +268,7 @@ class LatestReleasesNotifier extends StateNotifier<LatestReleasesState> {
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<LatestReleasesState> {
.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<LatestReleasesState> {
);
}
/// One-off fetch of Release and FileMetadata for newly received apps
Future<void> _fetchRelationshipsForNewApps(List<App> newApps) async {
if (newApps.isEmpty) return;
// One-off fetch: Get releases for these apps from remote
final releases = await ref.storage.query(
Request<Release>(
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<FileMetadata>(
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<void> _loadRelationshipsFor(List<App> appsPage) async {
final releases = appsPage