mirror of
https://github.com/zapstore/zapstore.git
synced 2026-10-05 12:38:24 +00:00
Query fine tuning, load release in parallel
This commit is contained in:
@@ -44,12 +44,8 @@ class AppDetailScreen extends HookConsumerWidget {
|
||||
},
|
||||
limit: 1,
|
||||
and: (a) => {
|
||||
a.latestRelease.query(
|
||||
and: (release) => {
|
||||
release.latestMetadata.query(),
|
||||
release.latestAsset.query(),
|
||||
},
|
||||
),
|
||||
a.latestAsset.query(),
|
||||
a.latestRelease.query(),
|
||||
},
|
||||
source: const LocalAndRemoteSource(relays: 'AppCatalog', stream: false),
|
||||
subscriptionPrefix: 'app-detail-$appId',
|
||||
@@ -66,12 +62,8 @@ class AppDetailScreen extends HookConsumerWidget {
|
||||
},
|
||||
limit: 1,
|
||||
and: (a) => {
|
||||
a.latestRelease.query(
|
||||
and: (release) => {
|
||||
release.latestMetadata.query(),
|
||||
release.latestAsset.query(),
|
||||
},
|
||||
),
|
||||
a.latestAsset.query(),
|
||||
a.latestRelease.query(),
|
||||
},
|
||||
source: const LocalAndRemoteSource(relays: 'AppCatalog', stream: false),
|
||||
subscriptionPrefix: 'app-detail-$appId',
|
||||
@@ -95,16 +87,15 @@ class AppDetailScreen extends HookConsumerWidget {
|
||||
},
|
||||
);
|
||||
|
||||
// Handle loading/error states when app not yet available
|
||||
final isLoading = appState is StorageLoading;
|
||||
final app = appState.models.firstOrNull;
|
||||
|
||||
if (appState case StorageError(:final exception)) {
|
||||
return _ErrorScaffold(message: exception.toString());
|
||||
}
|
||||
|
||||
// Show skeleton only if loading with no models yet
|
||||
if (isLoading && app == null) {
|
||||
if (app == null) {
|
||||
// StorageLoading with no models yet: show skeleton
|
||||
// StorageData with no models: app not found (handled by ref.listen pop)
|
||||
return const Scaffold(
|
||||
body: SafeArea(
|
||||
child: SingleChildScrollView(
|
||||
@@ -115,11 +106,7 @@ class AppDetailScreen extends HookConsumerWidget {
|
||||
);
|
||||
}
|
||||
|
||||
return _AppDetailContent(
|
||||
app: app,
|
||||
appState: appState,
|
||||
isLoading: isLoading,
|
||||
);
|
||||
return _AppDetailContent(app: app);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -150,22 +137,12 @@ class _ErrorScaffold extends StatelessWidget {
|
||||
|
||||
/// Internal widget that displays app details
|
||||
class _AppDetailContent extends HookConsumerWidget {
|
||||
final App? app;
|
||||
final StorageState<App> appState;
|
||||
final bool isLoading;
|
||||
final App app;
|
||||
|
||||
const _AppDetailContent({
|
||||
required this.app,
|
||||
required this.appState,
|
||||
required this.isLoading,
|
||||
});
|
||||
const _AppDetailContent({required this.app});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final app = this.app;
|
||||
if (app == null) {
|
||||
return const _ErrorScaffold(message: 'App not found');
|
||||
}
|
||||
|
||||
final signedInPubkey = ref.watch(Signer.activePubkeyProvider);
|
||||
final isSignedIn = signedInPubkey != null;
|
||||
@@ -182,10 +159,7 @@ class _AppDetailContent extends HookConsumerWidget {
|
||||
subscriptionPrefix: 'app-detail-profile',
|
||||
),
|
||||
);
|
||||
final author = switch (authorState) {
|
||||
StorageData(:final models) => models.firstOrNull,
|
||||
_ => null,
|
||||
};
|
||||
final author = authorState.models.firstOrNull;
|
||||
|
||||
final latestRelease = app.latestRelease.value;
|
||||
final latestMetadata = app.installable;
|
||||
@@ -196,29 +170,6 @@ class _AppDetailContent extends HookConsumerWidget {
|
||||
);
|
||||
final isInstalled = installedPackage != null;
|
||||
|
||||
// Show skeleton while relationships are loading
|
||||
// Always show loading indicator here - relationships are still pending
|
||||
if (latestRelease == null || latestMetadata == null) {
|
||||
return Scaffold(
|
||||
body: SafeArea(
|
||||
child: Stack(
|
||||
children: [
|
||||
const SingleChildScrollView(
|
||||
padding: EdgeInsets.only(top: 16, bottom: 80),
|
||||
child: Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 16),
|
||||
child: AppDetailSkeleton(),
|
||||
),
|
||||
),
|
||||
InstallButton(app: app, release: latestRelease),
|
||||
_buildFloatingMenu(context, ref, app, isInstalled, isSignedIn),
|
||||
_buildLoadingIndicator(context),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
body: SafeArea(
|
||||
child: Stack(
|
||||
@@ -235,7 +186,7 @@ class _AppDetailContent extends HookConsumerWidget {
|
||||
),
|
||||
|
||||
// Published by / Released at section
|
||||
if (app.isRelaySigned)
|
||||
if (app.isRelaySigned && latestMetadata != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
left: 16,
|
||||
@@ -322,98 +273,103 @@ class _AppDetailContent extends HookConsumerWidget {
|
||||
),
|
||||
),
|
||||
|
||||
// Latest release section
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const SizedBox(height: 12),
|
||||
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 RELEASE',
|
||||
style: context.textTheme.labelLarge?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurface
|
||||
.withValues(alpha: 0.85),
|
||||
letterSpacing: 1.5,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Container(
|
||||
height: 1,
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurface.withValues(alpha: 0.2),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 8,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurface.withValues(alpha: 0.05),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
// Latest release section — shown as soon as version is known
|
||||
if (latestMetadata != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
'Version:',
|
||||
style: context.textTheme.bodyMedium,
|
||||
),
|
||||
Gap(4),
|
||||
Text(
|
||||
latestMetadata.version,
|
||||
style: context.textTheme.bodyMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
Expanded(
|
||||
child: Container(
|
||||
height: 1,
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurface.withValues(alpha: 0.2),
|
||||
),
|
||||
),
|
||||
Gap(4),
|
||||
Text(
|
||||
'(${formatDate(latestRelease.createdAt)})',
|
||||
style: context.textTheme.bodyMedium?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurface
|
||||
.withValues(alpha: 0.6),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
),
|
||||
child: Text(
|
||||
'LATEST RELEASE',
|
||||
style: context.textTheme.labelLarge?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurface
|
||||
.withValues(alpha: 0.85),
|
||||
letterSpacing: 1.5,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Container(
|
||||
height: 1,
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurface.withValues(alpha: 0.2),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 20),
|
||||
child: ReleaseNotes(release: latestRelease),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 16),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 8,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurface.withValues(alpha: 0.05),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
'Version:',
|
||||
style: context.textTheme.bodyMedium,
|
||||
),
|
||||
Gap(4),
|
||||
Text(
|
||||
latestMetadata.version,
|
||||
style: context.textTheme.bodyMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
Gap(4),
|
||||
Text(
|
||||
'(${formatDate(latestMetadata.createdAt)})',
|
||||
style: context.textTheme.bodyMedium?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurface
|
||||
.withValues(alpha: 0.6),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 20),
|
||||
child: latestRelease != null
|
||||
? ReleaseNotes(release: latestRelease)
|
||||
: const ReleaseNotesSkeleton(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: AppInfoTable(app: app, fileMetadata: latestMetadata),
|
||||
),
|
||||
if (latestMetadata != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: AppInfoTable(app: app, fileMetadata: latestMetadata),
|
||||
),
|
||||
|
||||
CommentsSection(app: app, fileMetadata: latestMetadata),
|
||||
if (latestMetadata != null)
|
||||
CommentsSection(app: app, fileMetadata: latestMetadata),
|
||||
|
||||
// Debug section
|
||||
if (showDebugSections)
|
||||
@@ -433,62 +389,12 @@ class _AppDetailContent extends HookConsumerWidget {
|
||||
// Floating three-dot menu
|
||||
_buildFloatingMenu(context, ref, app, isInstalled, isSignedIn),
|
||||
|
||||
// Loading indicator while fetching more data
|
||||
if (isLoading) _buildLoadingIndicator(context),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildLoadingIndicator(BuildContext context) {
|
||||
return Positioned(
|
||||
top: 8,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: Center(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.surface.withValues(alpha: 0.95),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.1),
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 14,
|
||||
height: 14,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'Refreshing...',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurface.withValues(alpha: 0.7),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildFloatingMenu(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
|
||||
+35
-10
@@ -7,8 +7,11 @@ import 'package:models/models.dart';
|
||||
/// This is the primary query path for app listings. Each SoftwareAsset carries
|
||||
/// version, versionCode, hash, urls, and platform — everything needed for
|
||||
/// app cards and update detection — and links directly to its App.
|
||||
AutoDisposeStateNotifierProvider<RequestNotifier<SoftwareAsset>,
|
||||
StorageState<SoftwareAsset>> appAssetsQuery({
|
||||
AutoDisposeStateNotifierProvider<
|
||||
RequestNotifier<SoftwareAsset>,
|
||||
StorageState<SoftwareAsset>
|
||||
>
|
||||
appAssetsQuery({
|
||||
Set<String>? authors,
|
||||
Map<String, Set<String>>? tags,
|
||||
String? search,
|
||||
@@ -25,7 +28,16 @@ AutoDisposeStateNotifierProvider<RequestNotifier<SoftwareAsset>,
|
||||
since: since,
|
||||
until: until,
|
||||
limit: limit,
|
||||
and: (asset) => {asset.app.query(), asset.author.query()},
|
||||
and: (asset) => {
|
||||
asset.app.query(source: const LocalAndRemoteSource(stream: false)),
|
||||
asset.author.query(
|
||||
source: const LocalAndRemoteSource(
|
||||
relays: {'vertex', 'social'},
|
||||
cachedFor: Duration(hours: 2),
|
||||
stream: false,
|
||||
),
|
||||
),
|
||||
},
|
||||
source: source,
|
||||
subscriptionPrefix: subscriptionPrefix,
|
||||
);
|
||||
@@ -36,8 +48,11 @@ AutoDisposeStateNotifierProvider<RequestNotifier<SoftwareAsset>,
|
||||
///
|
||||
/// Same shape as [appAssetsQuery] but for legacy 1063-only apps.
|
||||
/// Delete this function when legacy 1063 support is fully removed.
|
||||
AutoDisposeStateNotifierProvider<RequestNotifier<FileMetadata>,
|
||||
StorageState<FileMetadata>> legacyAppQuery({
|
||||
AutoDisposeStateNotifierProvider<
|
||||
RequestNotifier<FileMetadata>,
|
||||
StorageState<FileMetadata>
|
||||
>
|
||||
legacyAppQuery({
|
||||
Set<String>? authors,
|
||||
Map<String, Set<String>>? tags,
|
||||
String? search,
|
||||
@@ -54,7 +69,15 @@ AutoDisposeStateNotifierProvider<RequestNotifier<FileMetadata>,
|
||||
since: since,
|
||||
until: until,
|
||||
limit: limit,
|
||||
and: (fm) => {fm.app.query(), fm.author.query()},
|
||||
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,
|
||||
);
|
||||
@@ -104,7 +127,7 @@ Future<AssetFetchResult> fetchAppsByAsset(
|
||||
subscriptionPrefix: '$subscriptionPrefix-apps',
|
||||
);
|
||||
|
||||
await _loadAuthors(storage, apps, source, '$subscriptionPrefix-authors');
|
||||
await _loadAuthors(storage, apps, '$subscriptionPrefix-authors');
|
||||
|
||||
return AssetFetchResult(apps, assets.length);
|
||||
}
|
||||
@@ -145,7 +168,7 @@ Future<AssetFetchResult> fetchLegacyAppsByMetadata(
|
||||
subscriptionPrefix: '$subscriptionPrefix-apps',
|
||||
);
|
||||
|
||||
await _loadAuthors(storage, apps, source, '$subscriptionPrefix-authors');
|
||||
await _loadAuthors(storage, apps, '$subscriptionPrefix-authors');
|
||||
|
||||
return AssetFetchResult(apps, metadatas.length);
|
||||
}
|
||||
@@ -153,7 +176,6 @@ Future<AssetFetchResult> fetchLegacyAppsByMetadata(
|
||||
Future<void> _loadAuthors(
|
||||
StorageNotifier storage,
|
||||
List<App> apps,
|
||||
Source? source,
|
||||
String subscriptionPrefix,
|
||||
) async {
|
||||
if (apps.isEmpty) return;
|
||||
@@ -164,7 +186,10 @@ Future<void> _loadAuthors(
|
||||
if (authorFilters.isEmpty) return;
|
||||
await storage.query(
|
||||
Request<Profile>(authorFilters),
|
||||
source: source,
|
||||
source: const LocalAndRemoteSource(
|
||||
relays: {'vertex', 'social'},
|
||||
cachedFor: Duration(hours: 2),
|
||||
),
|
||||
subscriptionPrefix: subscriptionPrefix,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -34,12 +34,10 @@ extension AppExt on App {
|
||||
ref.read(packageManagerProvider.notifier).isInstalled(identifier);
|
||||
|
||||
/// Resolved installable for the current platform.
|
||||
/// Prefers direct SoftwareAsset (3063) via `latestAsset` relationship,
|
||||
/// falls back to Release chain (1063).
|
||||
/// Uses direct SoftwareAsset (3063) via `latestAsset`, or legacy
|
||||
/// FileMetadata (1063) via `latestRelease` for apps not yet on 3063.
|
||||
Installable? get installable =>
|
||||
latestAsset.value ??
|
||||
latestRelease.value?.latestAsset.value ??
|
||||
latestRelease.value?.latestMetadata.value;
|
||||
latestAsset.value ?? latestRelease.value?.latestMetadata.value;
|
||||
|
||||
/// Whether there is an update available for the installed app.
|
||||
/// Delegates to PackageManager (versionCode-only comparison).
|
||||
@@ -65,8 +63,12 @@ extension WidgetRefExt on WidgetRef {
|
||||
Ref get asRef => read(Provider((ref) => ref));
|
||||
}
|
||||
|
||||
/// Extension to get APK certificate hashes from either format.
|
||||
/// Extension adding event-level accessors to Installable implementations.
|
||||
extension InstallableExt on Installable {
|
||||
/// Creation timestamp from the underlying Nostr event.
|
||||
DateTime get createdAt => (this as Model<dynamic>).event.createdAt;
|
||||
|
||||
|
||||
/// Returns the primary APK certificate hash.
|
||||
/// SoftwareAsset: uses apkCertificateHashes. FileMetadata: uses apkSignatureHash.
|
||||
String? get certificateHash {
|
||||
|
||||
@@ -64,6 +64,32 @@ class ReleaseNotes extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// Multi-line skeleton shown while release notes are loading
|
||||
class ReleaseNotesSkeleton extends StatelessWidget {
|
||||
const ReleaseNotesSkeleton({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
for (int i = 0; i < 4; i++)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
child: SizedBox(
|
||||
height: 14,
|
||||
width: i == 3 ? 160 : double.infinity,
|
||||
child: buildGradientLoader(context),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Row of social action buttons (zap, bookmark, app stack, share)
|
||||
class SocialActionsRow extends HookConsumerWidget {
|
||||
const SocialActionsRow({super.key, required this.app, required this.author});
|
||||
|
||||
Reference in New Issue
Block a user