Asset-first queries

This commit is contained in:
franzap
2026-03-31 15:19:23 -03:00
parent 4582708789
commit 1e22d3c581
25 changed files with 450 additions and 147 deletions
+2 -2
View File
@@ -188,7 +188,7 @@ class _AppDetailContent extends HookConsumerWidget {
};
final latestRelease = app.latestRelease.value;
final latestMetadata = app.latestFileMetadata;
final latestMetadata = app.installable;
// Check if app is installed for menu options
final installedPackage = ref.watch(
@@ -391,7 +391,7 @@ class _AppDetailContent extends HookConsumerWidget {
),
Gap(4),
Text(
'(${formatDate(latestMetadata.createdAt)})',
'(${formatDate(latestRelease.createdAt)})',
style: context.textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurface
.withValues(alpha: 0.6),
+1 -1
View File
@@ -200,7 +200,7 @@ class _UserZapsList extends HookConsumerWidget {
for (final entry in appTags.entries) {
allAppTags[entry.key] = {...?allAppTags[entry.key], ...entry.value};
}
final metadata = app.latestFileMetadata;
final metadata = app.installable;
if (metadata != null) {
metadataIds.add(metadata.id);
}
@@ -451,7 +451,7 @@ final class AndroidPackageManager extends PackageManager {
/// Directly update installed package from target metadata.
/// This ensures we don't depend on Android's package DB timing.
void _updateInstalledPackage(String appId, FileMetadata target) {
void _updateInstalledPackage(String appId, Installable target) {
final existingPkg = state.installed[appId];
final newPkg = PackageInfo(
appId: appId,
@@ -482,7 +482,7 @@ final class AndroidPackageManager extends PackageManager {
String filePath, {
required String expectedHash,
required int expectedSize,
required FileMetadata target,
required Installable target,
}) async {
// Permission is already checked by _proceedToInstall in base class
// No need for redundant _ensureInstallPermission() call here
@@ -22,7 +22,7 @@ final class BackgroundPackageManager extends PackageManager {
String filePath, {
required String expectedHash,
required int expectedSize,
required FileMetadata target,
required Installable target,
}) {
throw UnsupportedError('Install not supported in background');
}
@@ -47,7 +47,7 @@ final class DummyPackageManager extends PackageManager {
String filePath, {
required String expectedHash,
required int expectedSize,
required FileMetadata target,
required Installable target,
}) async {
// Mock: simulate install delay then add to installed
await Future.delayed(const Duration(milliseconds: 500));
@@ -21,8 +21,7 @@ const watchdogCheckInterval = Duration(seconds: 30);
/// Represents an active install operation for an app.
/// When there's no operation, the app simply has no entry in the operations map.
sealed class InstallOperation {
/// The target file metadata being installed (works for both FileMetadata and SoftwareAsset)
final FileMetadata target;
final Installable target;
const InstallOperation({required this.target});
}
@@ -331,7 +331,7 @@ abstract class PackageManager extends StateNotifier<PackageManagerState> {
/// [displayName] is shown in system notification (defaults to appId if null)
Future<bool> startDownload(
String appId,
FileMetadata target, {
Installable target, {
String? displayName,
}) async {
await _ensureDownloaderReady();
@@ -376,7 +376,7 @@ abstract class PackageManager extends StateNotifier<PackageManagerState> {
/// Queue multiple downloads at once - staggered to prevent UI flood.
/// This is the primary method for "Update All" functionality.
Future<void> queueDownloads(
List<({String appId, FileMetadata target, String? displayName})> items,
List<({String appId, Installable target, String? displayName})> items,
) async {
await _ensureDownloaderReady();
@@ -671,7 +671,7 @@ abstract class PackageManager extends StateNotifier<PackageManagerState> {
/// Advances the specified app AND all other apps awaiting permission.
Future<void> onPermissionGranted(String appId) async {
// Collect all apps that need to advance (AwaitingPermission or permissionDenied failures)
final toAdvance = <String, (FileMetadata target, String filePath)>{};
final toAdvance = <String, (Installable target, String filePath)>{};
for (final entry in state.operations.entries) {
final id = entry.key;
@@ -708,7 +708,7 @@ abstract class PackageManager extends StateNotifier<PackageManagerState> {
Future<void> _startDownloadTask(
String appId,
FileMetadata target,
Installable target,
String downloadUrl, {
String? displayName,
bool isCdnRetry = false,
@@ -932,7 +932,7 @@ abstract class PackageManager extends StateNotifier<PackageManagerState> {
Future<void> _handleDownloadComplete(
String appId,
FileMetadata target,
Installable target,
DownloadTask task,
) async {
// Remove from active downloads
@@ -1050,7 +1050,7 @@ abstract class PackageManager extends StateNotifier<PackageManagerState> {
/// Check permission and proceed to install
Future<void> _proceedToInstall(
String appId,
FileMetadata target,
Installable target,
String filePath,
) async {
if (!await hasPermission()) {
@@ -1085,7 +1085,7 @@ abstract class PackageManager extends StateNotifier<PackageManagerState> {
}
/// Add app to install queue and trigger processing.
void _addToInstallQueue(String appId, FileMetadata target, String filePath) {
void _addToInstallQueue(String appId, Installable target, String filePath) {
if (!installQueue.contains(appId)) {
installQueue.add(appId);
}
@@ -1096,7 +1096,7 @@ abstract class PackageManager extends StateNotifier<PackageManagerState> {
/// Perform the actual installation
Future<void> _performInstall(
String appId,
FileMetadata target,
Installable target,
String filePath,
) async {
try {
@@ -1210,7 +1210,7 @@ abstract class PackageManager extends StateNotifier<PackageManagerState> {
String appId,
TaskRecord record,
DownloadTask task,
FileMetadata fileMetadata,
Installable fileMetadata,
) async {
switch (record.status) {
case TaskStatus.complete:
@@ -1319,13 +1319,23 @@ abstract class PackageManager extends StateNotifier<PackageManagerState> {
return (metaData.isNotEmpty ? metaData : null, null, false);
}
Future<FileMetadata?> _loadFileMetadata(
Future<Installable?> _loadFileMetadata(
String? metadataId,
String filename,
) async {
final storage = ref.read(storageNotifierProvider.notifier);
if (metadataId != null) {
// Try SoftwareAsset (3063) first
try {
final assets = await storage.query(
RequestFilter<SoftwareAsset>(ids: {metadataId}).toRequest(),
subscriptionPrefix: 'app-file-metadata-asset',
);
if (assets.isNotEmpty) return assets.first;
} catch (_) {}
// Fall back to FileMetadata (1063)
try {
final results = await storage.query(
RequestFilter<FileMetadata>(ids: {metadataId}).toRequest(),
@@ -1338,6 +1348,14 @@ abstract class PackageManager extends StateNotifier<PackageManagerState> {
final dotIndex = filename.lastIndexOf('.');
final hash = dotIndex > 0 ? filename.substring(0, dotIndex) : filename;
if (hash.isNotEmpty) {
try {
final assets = await storage.query(
RequestFilter<SoftwareAsset>(search: hash).toRequest(),
subscriptionPrefix: 'app-file-metadata-asset-search',
);
if (assets.isNotEmpty) return assets.first;
} catch (_) {}
try {
final results = await storage.query(
RequestFilter<FileMetadata>(search: hash).toRequest(),
@@ -1363,7 +1381,7 @@ abstract class PackageManager extends StateNotifier<PackageManagerState> {
String filePath, {
required String expectedHash,
required int expectedSize,
required FileMetadata target,
required Installable target,
});
Future<void> uninstall(String appId);
@@ -1386,7 +1404,7 @@ abstract class PackageManager extends StateNotifier<PackageManagerState> {
///
/// Comparison uses Android versionCode only. Returns false when either
/// versionCode is unavailable or the app is not installed.
bool hasUpdate(String appId, FileMetadata latest) {
bool hasUpdate(String appId, Installable latest) {
final installed = state.installed[appId];
if (installed == null) return false;
final installedCode = installed.versionCode;
@@ -1399,7 +1417,7 @@ abstract class PackageManager extends StateNotifier<PackageManagerState> {
///
/// Comparison uses Android versionCode only. Returns false when either
/// versionCode is unavailable or the app is not installed.
bool hasDowngrade(String appId, FileMetadata latest) {
bool hasDowngrade(String appId, Installable latest) {
final installed = state.installed[appId];
if (installed == null) return false;
final installedCode = installed.versionCode;
+1 -1
View File
@@ -382,7 +382,7 @@ class CategorizedUpdatesNotifier extends Notifier<CategorizedUpdates> {
for (final app in installedApps) {
final pkg = installedMap[app.identifier]!;
final latest = app.latestFileMetadata;
final latest = app.installable;
// Determine if update available using versionCode (via PackageManager)
final pm = ref.read(packageManagerProvider.notifier);
+170
View File
@@ -0,0 +1,170 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:models/models.dart';
/// Reactive query provider: fetches SoftwareAsset (3063) events and resolves
/// their parent App (32267) via the direct relationship.
///
/// 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({
Set<String>? authors,
Map<String, Set<String>>? tags,
String? search,
DateTime? since,
DateTime? until,
int? limit,
Source? source,
required String subscriptionPrefix,
}) {
return query<SoftwareAsset>(
authors: authors,
tags: tags,
search: search,
since: since,
until: until,
limit: limit,
and: (asset) => {asset.app.query(), asset.author.query()},
source: source,
subscriptionPrefix: subscriptionPrefix,
);
}
/// Reactive query provider: fetches FileMetadata (1063) events and resolves
/// their parent App (32267) via the direct `#i` → `#d` relationship.
///
/// Same shape as [appAssetsQuery] but for legacy 1063-only apps.
/// Delete this function when legacy 1063 support is fully removed.
AutoDisposeStateNotifierProvider<RequestNotifier<FileMetadata>,
StorageState<FileMetadata>> legacyAppQuery({
Set<String>? authors,
Map<String, Set<String>>? tags,
String? search,
DateTime? since,
DateTime? until,
int? limit,
Source? source,
required String subscriptionPrefix,
}) {
return query<FileMetadata>(
authors: authors,
tags: tags,
search: search,
since: since,
until: until,
limit: limit,
and: (fm) => {fm.app.query(), fm.author.query()},
source: source,
subscriptionPrefix: subscriptionPrefix,
);
}
/// Result of a paginated asset-first fetch.
class AssetFetchResult {
final List<App> apps;
final int assetCount;
const AssetFetchResult(this.apps, this.assetCount);
}
/// Imperative one-shot: fetches a page of SoftwareAsset (3063) events,
/// resolves their parent Apps, and pre-loads author profiles.
/// Returns the resolved Apps (deduplicated) and the raw asset count
/// (for accurate pagination — multiple assets may map to one app).
Future<AssetFetchResult> fetchAppsByAsset(
StorageNotifier storage, {
Map<String, Set<String>>? tags,
DateTime? until,
int? limit,
Source? source,
required String subscriptionPrefix,
}) async {
final assets = await storage.query(
RequestFilter<SoftwareAsset>(
tags: tags,
until: until,
limit: limit,
).toRequest(),
source: source,
subscriptionPrefix: subscriptionPrefix,
);
if (assets.isEmpty) return const AssetFetchResult([], 0);
final appFilters = assets
.map((a) => a.app.req?.filters.firstOrNull)
.nonNulls
.toList();
if (appFilters.isEmpty) return AssetFetchResult(const [], assets.length);
final apps = await storage.query(
Request<App>(appFilters),
source: source,
subscriptionPrefix: '$subscriptionPrefix-apps',
);
await _loadAuthors(storage, apps, source, '$subscriptionPrefix-authors');
return AssetFetchResult(apps, assets.length);
}
/// Imperative one-shot: fetches a page of FileMetadata (1063) events,
/// resolves their parent Apps, and pre-loads author profiles.
/// Same shape as [fetchAppsByAsset] but for legacy 1063-only apps.
Future<AssetFetchResult> fetchLegacyAppsByMetadata(
StorageNotifier storage, {
Map<String, Set<String>>? tags,
DateTime? until,
int? limit,
Source? source,
required String subscriptionPrefix,
}) async {
final metadatas = await storage.query(
RequestFilter<FileMetadata>(
tags: tags,
until: until,
limit: limit,
).toRequest(),
source: source,
subscriptionPrefix: subscriptionPrefix,
);
if (metadatas.isEmpty) return const AssetFetchResult([], 0);
final appFilters = metadatas
.map((fm) => fm.app.req?.filters.firstOrNull)
.nonNulls
.toList();
if (appFilters.isEmpty) return AssetFetchResult(const [], metadatas.length);
final apps = await storage.query(
Request<App>(appFilters),
source: source,
subscriptionPrefix: '$subscriptionPrefix-apps',
);
await _loadAuthors(storage, apps, source, '$subscriptionPrefix-authors');
return AssetFetchResult(apps, metadatas.length);
}
Future<void> _loadAuthors(
StorageNotifier storage,
List<App> apps,
Source? source,
String subscriptionPrefix,
) async {
if (apps.isEmpty) return;
final authorFilters = apps
.map((a) => a.author.req?.filters.firstOrNull)
.nonNulls
.toList();
if (authorFilters.isEmpty) return;
await storage.query(
Request<Profile>(authorFilters),
source: source,
subscriptionPrefix: subscriptionPrefix,
);
}
+12 -16
View File
@@ -33,17 +33,18 @@ extension AppExt on App {
bool get isInstalled =>
ref.read(packageManagerProvider.notifier).isInstalled(identifier);
/// Latest file metadata associated to the latest release
/// Prefers SoftwareAsset (new format) over FileMetadata (old format)
/// Note: assumes latest metadata has been loaded for the current platform
FileMetadata? get latestFileMetadata =>
/// Resolved installable for the current platform.
/// Prefers direct SoftwareAsset (3063) via `latestAsset` relationship,
/// falls back to Release chain (1063).
Installable? get installable =>
latestAsset.value ??
latestRelease.value?.latestAsset.value ??
latestRelease.value?.latestMetadata.value;
/// Whether there is an update available for the installed app.
/// Delegates to PackageManager (versionCode-only comparison).
bool get hasUpdate {
final latest = latestFileMetadata;
final latest = installable;
if (latest == null) return false;
return ref.read(packageManagerProvider.notifier).hasUpdate(identifier, latest);
}
@@ -51,7 +52,7 @@ extension AppExt on App {
/// Whether the relay version would be a downgrade from the installed version.
/// Delegates to PackageManager (versionCode-only comparison).
bool get hasDowngrade {
final latest = latestFileMetadata;
final latest = installable;
if (latest == null) return false;
return ref.read(packageManagerProvider.notifier).hasDowngrade(identifier, latest);
}
@@ -64,29 +65,24 @@ extension WidgetRefExt on WidgetRef {
Ref get asRef => read(Provider((ref) => ref));
}
/// Extension to handle both old and new format certificate hashes
extension FileMetadataExt on FileMetadata {
/// Returns the APK certificate hash, checking new format first then old
/// New format (SoftwareAsset): apkCertificateHashes (Set)
/// Old format (FileMetadata): apkSignatureHash (String?)
/// Extension to get APK certificate hashes from either format.
extension InstallableExt on Installable {
/// Returns the primary APK certificate hash.
/// SoftwareAsset: uses apkCertificateHashes. FileMetadata: uses apkSignatureHash.
String? get certificateHash {
// Try new format first (SoftwareAsset has apkCertificateHashes)
if (this is SoftwareAsset) {
final hashes = (this as SoftwareAsset).apkCertificateHashes;
if (hashes.isNotEmpty) return hashes.first;
}
// Fall back to old format
return apkSignatureHash;
}
/// Returns all APK certificate hashes
/// Returns all APK certificate hashes.
Set<String> get certificateHashes {
// Try new format first
if (this is SoftwareAsset) {
final hashes = (this as SoftwareAsset).apkCertificateHashes;
if (hashes.isNotEmpty) return hashes;
}
// Fall back to old format (single hash as set)
return apkSignatureHash != null ? {apkSignatureHash!} : {};
}
}
+2 -2
View File
@@ -277,7 +277,7 @@ class _ZappersListSection extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final metadataId = app.latestFileMetadata?.id;
final metadataId = app.installable?.id;
// Query zaps on app (via #a tag)
final zapsState = ref.watch(
@@ -925,7 +925,7 @@ class DebugVersionsSection extends HookConsumerWidget {
WidgetRef ref,
App app,
Release release,
FileMetadata metadata,
Installable metadata,
) async {
// Use PackageManager to start download
final pm = ref.read(packageManagerProvider.notifier);
+1 -1
View File
@@ -13,7 +13,7 @@ class AppInfoTable extends HookConsumerWidget {
const AppInfoTable({super.key, required this.app, this.fileMetadata});
final App app;
final FileMetadata? fileMetadata;
final Installable? fileMetadata;
@override
Widget build(BuildContext context, WidgetRef ref) {
+2 -2
View File
@@ -25,11 +25,11 @@ class UpdateAllRow extends ConsumerWidget {
onPressed: () async {
final pm = ref.read(packageManagerProvider.notifier);
final items = allUpdates
.where((app) => app.latestFileMetadata != null)
.where((app) => app.installable != null)
.map(
(app) => (
appId: app.identifier,
target: app.latestFileMetadata!,
target: app.installable!,
displayName: app.name,
),
)
+6 -6
View File
@@ -17,7 +17,7 @@ class CommentsSection extends HookConsumerWidget {
const CommentsSection({super.key, required this.app, this.fileMetadata});
final App app;
final FileMetadata? fileMetadata;
final Installable? fileMetadata;
@override
Widget build(BuildContext context, WidgetRef ref) {
@@ -112,7 +112,7 @@ class _CommentsSectionLayout extends StatelessWidget {
final Object? errorException;
final Widget addCommentButton;
final App? app;
final FileMetadata? fileMetadata;
final Installable? fileMetadata;
final AppStack? stack;
@override
@@ -247,7 +247,7 @@ class _ThreadedCommentCard extends HookConsumerWidget {
final Comment comment;
final int depth;
final App? app;
final FileMetadata? fileMetadata;
final Installable? fileMetadata;
final AppStack? stack;
// Thread line colors for different depths
@@ -478,7 +478,7 @@ class _ThreadedCommentCard extends HookConsumerWidget {
class _AddCommentButton extends ConsumerWidget {
const _AddCommentButton({required this.fileMetadata, required this.app});
final FileMetadata fileMetadata;
final Installable fileMetadata;
final App app;
@override
@@ -547,7 +547,7 @@ class _AddStackCommentButton extends ConsumerWidget {
class _CommentComposer extends HookConsumerWidget {
const _CommentComposer({required this.fileMetadata, required this.app});
final FileMetadata fileMetadata;
final Installable fileMetadata;
final App app;
@override
@@ -701,7 +701,7 @@ class _ReplyComposer extends HookConsumerWidget {
final Comment parentComment;
final App? app;
final FileMetadata? fileMetadata;
final Installable? fileMetadata;
final AppStack? stack;
@override
+1 -1
View File
@@ -61,7 +61,7 @@ class InstallAlertDialog extends HookConsumerWidget {
'${app.name ?? app.identifier} will be installed from its original release location:',
oneLine: false,
showFullUrl: true,
url: app.latestFileMetadata!.urls.first,
url: app.installable!.urls.first,
size: baseTextSize,
),
] else ...[
+4 -4
View File
@@ -40,7 +40,7 @@ class InstallButton extends ConsumerWidget {
final hasUpdate = app.hasUpdate;
final hasDowngrade = app.hasDowngrade;
final hasRelease = release != null;
final fileMetadata = app.latestFileMetadata;
final fileMetadata = app.installable;
// Listen for errors to show toasts
ref.listen(installOperationProvider(app.identifier), (prev, next) {
@@ -104,7 +104,7 @@ class InstallButton extends ConsumerWidget {
required bool hasUpdate,
required bool hasDowngrade,
required bool hasRelease,
required FileMetadata? fileMetadata,
required Installable? fileMetadata,
required double fontSize,
}) {
// Completed is a terminal "result" state and may linger for batch progress UX.
@@ -285,7 +285,7 @@ class InstallButton extends ConsumerWidget {
}
String? _formatTotalSizeMb() {
final sizeBytes = app.latestFileMetadata?.size;
final sizeBytes = app.installable?.size;
if (sizeBytes == null || sizeBytes <= 0) return null;
final mb = sizeBytes / (1024 * 1024);
return '${mb.toStringAsFixed(1)} MB';
@@ -561,7 +561,7 @@ class InstallButton extends ConsumerWidget {
Future<void> _startDownload(
BuildContext context,
WidgetRef ref,
FileMetadata fileMetadata,
Installable fileMetadata,
) async {
final pm = ref.read(packageManagerProvider.notifier);
await pm.startDownload(app.identifier, fileMetadata, displayName: app.name);
+47 -80
View File
@@ -4,6 +4,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:models/models.dart';
import 'package:purplebase/purplebase.dart';
import 'package:zapstore/services/updates_service.dart';
import 'package:zapstore/utils/app_query.dart';
import 'package:zapstore/utils/extensions.dart';
import 'app_card.dart';
@@ -295,47 +296,34 @@ class LatestReleasesNotifier extends StateNotifier<LatestReleasesState> {
final Ref ref;
static const int _pageSize = 5;
ProviderSubscription<StorageState<App>>? _sub;
// We keep a fixed live head window from query(); older pages are appended
ProviderSubscription<StorageState<SoftwareAsset>>? _sub;
void _startQuery() {
_sub?.close();
_sub = ref.listen<StorageState<App>>(
query<App>(
_sub = ref.listen<StorageState<SoftwareAsset>>(
appAssetsQuery(
limit: _pageSize,
tags: {
'#f': {'android-arm64-v8a'},
},
and: (app) => {
app.latestRelease.query(
source: const LocalAndRemoteSource(
relays: 'AppCatalog',
stream: false,
),
and: (release) => {
release.latestMetadata.query(),
release.latestAsset.query(),
},
),
},
// NOTE: It must stream=true
source: const LocalAndRemoteSource(relays: 'AppCatalog', stream: true),
subscriptionPrefix: 'app-latest',
),
(previous, next) async {
// Always mirror storage state and ensure olderApps don't duplicate the live head
if (next is StorageData<App>) {
final liveIds = next.models.map((a) => a.id).toSet();
(previous, next) {
final appState = _appsFromAssetState(next);
if (appState is StorageData<App>) {
final liveIds = appState.models.map((a) => a.id).toSet();
final filteredOlder = state.olderApps
.where((a) => !liveIds.contains(a.id))
.toList();
state = state.copyWith(storage: next, olderApps: filteredOlder);
state = state.copyWith(storage: appState, olderApps: filteredOlder);
} else {
state = state.copyWith(storage: next);
state = state.copyWith(storage: appState);
}
if (next is StorageError<App>) {
if (appState is StorageError<App>) {
state = state.copyWith(isLoadingMore: false);
}
},
@@ -343,10 +331,31 @@ class LatestReleasesNotifier extends StateNotifier<LatestReleasesState> {
);
}
/// Fetch authors for a page of apps (used during pagination)
/// Note: Profiles are now loaded reactively via `query<Profile>` in individual widgets
Future<void> _loadRelationshipsFor(List<App> appsPage) async {
// No-op: profiles are now loaded reactively via `query<Profile>` with caching
/// Derive a `StorageState<App>` from the SoftwareAsset query results.
StorageState<App> _appsFromAssetState(StorageState<SoftwareAsset> assetState) {
return switch (assetState) {
StorageLoading<SoftwareAsset>() => StorageLoading<App>(
_uniqueAppsFromAssets(assetState.models),
),
StorageData<SoftwareAsset>(:final models) => StorageData<App>(
_uniqueAppsFromAssets(models),
),
StorageError<SoftwareAsset>(:final exception) =>
StorageError<App>(const [], exception: exception),
};
}
/// Extract unique Apps from assets, preserving order (newest first).
List<App> _uniqueAppsFromAssets(List<SoftwareAsset> assets) {
final seen = <String>{};
final apps = <App>[];
for (final asset in assets) {
final app = asset.app.value;
if (app != null && seen.add(app.identifier)) {
apps.add(app);
}
}
return apps;
}
Future<void> loadMore() async {
@@ -362,68 +371,26 @@ class LatestReleasesNotifier extends StateNotifier<LatestReleasesState> {
state = state.copyWith(isLoadingMore: true);
try {
// Fetch older apps
final olderPage = await ref.storage.query(
RequestFilter<App>(
until: oldest,
limit: _pageSize,
tags: {
'#f': {'android-arm64-v8a'},
},
).toRequest(),
final result = await fetchAppsByAsset(
ref.storage,
tags: {
'#f': {'android-arm64-v8a'},
},
until: oldest,
limit: _pageSize,
source: const LocalAndRemoteSource(stream: false),
subscriptionPrefix: 'app-latest-older',
);
if (olderPage.isNotEmpty) {
// Load relationships: releases and their file metadata (same relay group)
final releases = await ref.storage.query(
Request<Release>(
olderPage
.map((app) => app.latestRelease.req?.filters.firstOrNull)
.nonNulls
.toList(),
),
source: const RemoteSource(stream: false),
subscriptionPrefix: 'app-latest-releases',
);
// Load file metadata for the releases (old format)
if (releases.isNotEmpty) {
await ref.storage.query(
Request<FileMetadata>(
releases
.map((r) => r.latestMetadata.req?.filters.firstOrNull)
.nonNulls
.toList(),
),
source: const RemoteSource(stream: false),
subscriptionPrefix: 'app-latest-metadata',
);
// Load software assets for the releases (new format)
await ref.storage.query(
Request<SoftwareAsset>(
releases
.map((r) => r.latestAsset.req?.filters.firstOrNull)
.nonNulls
.toList(),
),
source: const RemoteSource(stream: false),
subscriptionPrefix: 'app-latest-assets',
);
}
// Load release authors from different relay group (social)
await _loadRelationshipsFor(olderPage);
if (result.apps.isNotEmpty) {
final existingIds = combined.map((a) => a.id).toSet();
final uniqueOlder = olderPage
final uniqueOlder = result.apps
.where((a) => !existingIds.contains(a.id))
.toList();
state = state.copyWith(
olderApps: [...state.olderApps, ...uniqueOlder],
isLoadingMore: false,
hasMore: olderPage.length >= _pageSize,
hasMore: result.assetCount >= _pageSize,
);
} else {
state = state.copyWith(isLoadingMore: false, hasMore: false);
@@ -81,7 +81,7 @@ class RelevantWhoFollowContainer extends HookConsumerWidget {
);
// Also query zaps on the latest metadata if available
final latestMetadata = app.latestFileMetadata;
final latestMetadata = app.installable;
final metadataZapsState = latestMetadata != null
? ref.watch(
query<Zap>(
+9 -9
View File
@@ -28,22 +28,22 @@ class VersionPillWidget extends HookConsumerWidget {
packageManagerProvider.select((s) => s.installed[app.identifier]),
);
// Watch app with relationships to ensure Release and FileMetadata are loaded
// Watch app with relationships from local cache only — parent screens are
// responsible for loading data from remote.
final appState = ref.watch(
model<App>(
app,
and: (a) => {
a.latestAsset.query(source: const LocalSource()),
a.latestRelease.query(
source: const LocalAndRemoteSource(
relays: 'AppCatalog',
stream: false,
),
source: const LocalSource(),
and: (release) => {
release.latestMetadata.query(),
release.latestAsset.query(),
release.latestMetadata.query(source: const LocalSource()),
release.latestAsset.query(source: const LocalSource()),
},
),
},
source: const LocalSource(),
subscriptionPrefix: 'app-version-pill',
),
);
@@ -58,8 +58,8 @@ class VersionPillWidget extends HookConsumerWidget {
final installedPackage = loadedApp.installedPackage;
final installedVersion = installedPackage?.version;
final installedVersionCode = installedPackage?.versionCode;
final availableVersion = loadedApp.latestFileMetadata?.version;
final availableVersionCode = loadedApp.latestFileMetadata?.versionCode;
final availableVersion = loadedApp.installable?.version;
final availableVersionCode = loadedApp.installable?.versionCode;
final isInstalled = loadedApp.isInstalled;
final updateAvailable = loadedApp.hasUpdate;
final downgradeAvailable = loadedApp.hasDowngrade;
+2 -2
View File
@@ -427,7 +427,7 @@ class ZapAmountDialog extends HookConsumerWidget {
: await secureStorage.getNWCString();
// Build zap request
final latestMetadata = app.latestFileMetadata;
final latestMetadata = app.installable;
final author = app.author.value;
if (latestMetadata == null || author == null) {
@@ -445,7 +445,7 @@ class ZapAmountDialog extends HookConsumerWidget {
if (comment.isNotEmpty) zapRequest.comment = comment;
zapRequest.linkProfileByPubkey(author.pubkey);
zapRequest.linkModel(app);
zapRequest.linkModel(latestMetadata);
zapRequest.linkModelById(latestMetadata.id);
zapRequest.relays = socialRelays;
final signedZapRequest = await zapRequest.signWith(
@@ -6,6 +6,22 @@
#include "generated_plugin_registrant.h"
#include <flutter_secure_storage_linux/flutter_secure_storage_linux_plugin.h>
#include <gtk/gtk_plugin.h>
#include <sqlite3_flutter_libs/sqlite3_flutter_libs_plugin.h>
#include <url_launcher_linux/url_launcher_plugin.h>
void fl_register_plugins(FlPluginRegistry* registry) {
g_autoptr(FlPluginRegistrar) flutter_secure_storage_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterSecureStorageLinuxPlugin");
flutter_secure_storage_linux_plugin_register_with_registrar(flutter_secure_storage_linux_registrar);
g_autoptr(FlPluginRegistrar) gtk_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "GtkPlugin");
gtk_plugin_register_with_registrar(gtk_registrar);
g_autoptr(FlPluginRegistrar) sqlite3_flutter_libs_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "Sqlite3FlutterLibsPlugin");
sqlite3_flutter_libs_plugin_register_with_registrar(sqlite3_flutter_libs_registrar);
g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin");
url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar);
}
+4
View File
@@ -3,6 +3,10 @@
#
list(APPEND FLUTTER_PLUGIN_LIST
flutter_secure_storage_linux
gtk
sqlite3_flutter_libs
url_launcher_linux
)
list(APPEND FLUTTER_FFI_PLUGIN_LIST
@@ -5,6 +5,28 @@
import FlutterMacOS
import Foundation
import app_links
import connectivity_plus
import device_info_plus
import flutter_local_notifications
import flutter_secure_storage_macos
import path_provider_foundation
import share_plus
import shared_preferences_foundation
import sqflite_darwin
import sqlite3_flutter_libs
import url_launcher_macos
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
AppLinksMacosPlugin.register(with: registry.registrar(forPlugin: "AppLinksMacosPlugin"))
ConnectivityPlusPlugin.register(with: registry.registrar(forPlugin: "ConnectivityPlusPlugin"))
DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin"))
FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin"))
FlutterSecureStoragePlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStoragePlugin"))
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin"))
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin"))
Sqlite3FlutterLibsPlugin.register(with: registry.registrar(forPlugin: "Sqlite3FlutterLibsPlugin"))
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
}
@@ -0,0 +1,42 @@
# FEAT-004 — Asset-First Query Optimization
## Goal
Speed up app listing screens by querying SoftwareAsset (kind 3063) first,
resolving the App (32267) via its direct relationship, and skipping the
intermediate Release (30063) for card-level display.
## Non-Goals
- Removing Release from the data model or detail screen
- Changing the models package
- Migrating all screens at once (phased rollout starting with Latest Releases)
## User-Visible Behavior
- App cards on the home screen load faster (2 hops instead of 4)
- No change to what the user sees: same name, icon, version pill, description
- Legacy 1063-only apps still appear, loaded via a separate fallback query
- Offline: local-first behavior unchanged — cached assets and apps render immediately
## Edge Cases
- App has only legacy FileMetadata (1063), no SoftwareAsset (3063) → fallback query picks it up (when `includeLegacy: true`; Latest Releases skips these)
- App has both 3063 and 1063 → 3063 wins (already the existing preference)
- SoftwareAsset exists but its App relationship fails to resolve → card skipped, no crash
- Network failure → local cache used, graceful degradation unchanged
## Acceptance Criteria
- [ ] `latestFileMetadata` renamed to `latestAsset` across codebase, with 3063→1063 fallback
- [ ] Centralized query helper with `includeLegacy` flag, reusable across screens
- [ ] LatestReleasesNotifier uses centralized query (3063-only, no legacy fallback)
- [ ] VersionPillWidget reads locally instead of re-fetching the full chain from remote
- [ ] `flutter analyze` clean
- [ ] Legacy 1063-only apps still appear in listings
## Notes
- The detail screen continues using Release for release notes, commit info, etc.
Release is loaded via `latestRelease` relationship when needed, not as part of the card query.
- When legacy 1063 is fully removed, the fallback query function is deleted — single removal point.
+69
View File
@@ -0,0 +1,69 @@
# WORK-007 — Asset-First Query Optimization
**Feature:** FEAT-004-asset-first-queries.md
**Status:** In Progress
## Tasks
- [x] 1. Rename `latestFileMetadata` → `latestAsset` in AppExt
- Files: `lib/utils/extensions.dart`, all call sites
- Returns `Installable?` (shared interface for FileMetadata + SoftwareAsset)
- [x] 2. Make SoftwareAsset self-referential (`RegularModel<SoftwareAsset>`)
- Files: `purplebase/models/lib/src/models/asset.dart`
- No longer extends FileMetadata — duplicates shared getters
- Added `Installable` interface implemented by both FileMetadata and SoftwareAsset
- Updated entire install pipeline (InstallOperation, PackageManager, platform impls) to use `Installable`
- [x] 3. Create centralized app query helper function
- Files: `lib/utils/app_query.dart` (new)
- `appAssetsQuery()`: reactive `query<SoftwareAsset>` with `asset.app.query()`
- `legacyAppQuery()`: reactive `query<App>` with old chain — delete when 1063 removed
- `fetchAppsByAsset()`: imperative one-shot for pagination
- [x] 4. Rewrite LatestReleasesNotifier to use centralized helper
- Files: `lib/widgets/latest_releases_container.dart`
- 3063-only, no legacy fallback
- Live head: `appAssetsQuery()` with `stream: true`, derives Apps from assets
- Pagination: `fetchAppsByAsset()` imperative call
- [x] 5. Update VersionPillWidget to not re-fetch from remote
- Files: `lib/widgets/version_pill_widget.dart`
- Switched to `LocalSource` only — parent screens load data
- [x] 6. Self-review against INVARIANTS.md — all clean
## Decisions
### 2026-03-26 — Query direction: asset-first
**Context:** Current queries go App→Release→FileMetadata/SoftwareAsset (3-4 hops).
SoftwareAsset (3063) has a direct `BelongsTo<App>` relationship.
**Options:** (A) Keep App-first, optimize relay-side. (B) Query 3063 first, resolve App via relationship.
**Decision:** Option B.
**Rationale:** Eliminates Release from the card-display path. 2 hops instead of 4.
When 1063 is removed, the fallback function is simply deleted.
### 2026-03-26 — VersionPillWidget remote query
**Context:** VersionPillWidget independently re-fetches App→Release→Metadata from remote.
**Decision:** Switch to LocalSource or remove the query. Listing screens are responsible for loading data.
**Rationale:** Eliminates redundant network requests per card. Data is already loaded by the parent.
### 2026-03-26 — No legacy fallback in Latest Releases
**Context:** Latest Releases is the hot path on the home screen.
**Decision:** 3063-only, no 1063 fallback. Other screens can opt in via `includeLegacy: true`.
**Rationale:** Optimizes for fastest retrieval. Legacy apps appear elsewhere (search, updates, stacks).
## Spec Issues
_None_
## Progress Notes
**2026-03-26:** Completed rename of `latestFileMetadata` → `latestAsset` across all call sites.
**2026-03-26:** Made SoftwareAsset self-referential. Introduced `Installable` interface. Updated entire install pipeline.
**2026-03-26:** Created centralized query helpers. Rewrote LatestReleasesNotifier (3063-only). VersionPillWidget now local-only.
**2026-03-26:** `dart analyze lib/` — no issues found.