mirror of
https://github.com/zapstore/zapstore.git
synced 2026-10-05 12:38:24 +00:00
Asset-first queries
This commit is contained in:
@@ -188,7 +188,7 @@ class _AppDetailContent extends HookConsumerWidget {
|
|||||||
};
|
};
|
||||||
|
|
||||||
final latestRelease = app.latestRelease.value;
|
final latestRelease = app.latestRelease.value;
|
||||||
final latestMetadata = app.latestFileMetadata;
|
final latestMetadata = app.installable;
|
||||||
|
|
||||||
// Check if app is installed for menu options
|
// Check if app is installed for menu options
|
||||||
final installedPackage = ref.watch(
|
final installedPackage = ref.watch(
|
||||||
@@ -391,7 +391,7 @@ class _AppDetailContent extends HookConsumerWidget {
|
|||||||
),
|
),
|
||||||
Gap(4),
|
Gap(4),
|
||||||
Text(
|
Text(
|
||||||
'(${formatDate(latestMetadata.createdAt)})',
|
'(${formatDate(latestRelease.createdAt)})',
|
||||||
style: context.textTheme.bodyMedium?.copyWith(
|
style: context.textTheme.bodyMedium?.copyWith(
|
||||||
color: Theme.of(context).colorScheme.onSurface
|
color: Theme.of(context).colorScheme.onSurface
|
||||||
.withValues(alpha: 0.6),
|
.withValues(alpha: 0.6),
|
||||||
|
|||||||
@@ -200,7 +200,7 @@ class _UserZapsList extends HookConsumerWidget {
|
|||||||
for (final entry in appTags.entries) {
|
for (final entry in appTags.entries) {
|
||||||
allAppTags[entry.key] = {...?allAppTags[entry.key], ...entry.value};
|
allAppTags[entry.key] = {...?allAppTags[entry.key], ...entry.value};
|
||||||
}
|
}
|
||||||
final metadata = app.latestFileMetadata;
|
final metadata = app.installable;
|
||||||
if (metadata != null) {
|
if (metadata != null) {
|
||||||
metadataIds.add(metadata.id);
|
metadataIds.add(metadata.id);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -451,7 +451,7 @@ final class AndroidPackageManager extends PackageManager {
|
|||||||
|
|
||||||
/// Directly update installed package from target metadata.
|
/// Directly update installed package from target metadata.
|
||||||
/// This ensures we don't depend on Android's package DB timing.
|
/// 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 existingPkg = state.installed[appId];
|
||||||
final newPkg = PackageInfo(
|
final newPkg = PackageInfo(
|
||||||
appId: appId,
|
appId: appId,
|
||||||
@@ -482,7 +482,7 @@ final class AndroidPackageManager extends PackageManager {
|
|||||||
String filePath, {
|
String filePath, {
|
||||||
required String expectedHash,
|
required String expectedHash,
|
||||||
required int expectedSize,
|
required int expectedSize,
|
||||||
required FileMetadata target,
|
required Installable target,
|
||||||
}) async {
|
}) async {
|
||||||
// Permission is already checked by _proceedToInstall in base class
|
// Permission is already checked by _proceedToInstall in base class
|
||||||
// No need for redundant _ensureInstallPermission() call here
|
// No need for redundant _ensureInstallPermission() call here
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ final class BackgroundPackageManager extends PackageManager {
|
|||||||
String filePath, {
|
String filePath, {
|
||||||
required String expectedHash,
|
required String expectedHash,
|
||||||
required int expectedSize,
|
required int expectedSize,
|
||||||
required FileMetadata target,
|
required Installable target,
|
||||||
}) {
|
}) {
|
||||||
throw UnsupportedError('Install not supported in background');
|
throw UnsupportedError('Install not supported in background');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ final class DummyPackageManager extends PackageManager {
|
|||||||
String filePath, {
|
String filePath, {
|
||||||
required String expectedHash,
|
required String expectedHash,
|
||||||
required int expectedSize,
|
required int expectedSize,
|
||||||
required FileMetadata target,
|
required Installable target,
|
||||||
}) async {
|
}) async {
|
||||||
// Mock: simulate install delay then add to installed
|
// Mock: simulate install delay then add to installed
|
||||||
await Future.delayed(const Duration(milliseconds: 500));
|
await Future.delayed(const Duration(milliseconds: 500));
|
||||||
|
|||||||
@@ -21,8 +21,7 @@ const watchdogCheckInterval = Duration(seconds: 30);
|
|||||||
/// Represents an active install operation for an app.
|
/// Represents an active install operation for an app.
|
||||||
/// When there's no operation, the app simply has no entry in the operations map.
|
/// When there's no operation, the app simply has no entry in the operations map.
|
||||||
sealed class InstallOperation {
|
sealed class InstallOperation {
|
||||||
/// The target file metadata being installed (works for both FileMetadata and SoftwareAsset)
|
final Installable target;
|
||||||
final FileMetadata target;
|
|
||||||
|
|
||||||
const InstallOperation({required this.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)
|
/// [displayName] is shown in system notification (defaults to appId if null)
|
||||||
Future<bool> startDownload(
|
Future<bool> startDownload(
|
||||||
String appId,
|
String appId,
|
||||||
FileMetadata target, {
|
Installable target, {
|
||||||
String? displayName,
|
String? displayName,
|
||||||
}) async {
|
}) async {
|
||||||
await _ensureDownloaderReady();
|
await _ensureDownloaderReady();
|
||||||
@@ -376,7 +376,7 @@ abstract class PackageManager extends StateNotifier<PackageManagerState> {
|
|||||||
/// Queue multiple downloads at once - staggered to prevent UI flood.
|
/// Queue multiple downloads at once - staggered to prevent UI flood.
|
||||||
/// This is the primary method for "Update All" functionality.
|
/// This is the primary method for "Update All" functionality.
|
||||||
Future<void> queueDownloads(
|
Future<void> queueDownloads(
|
||||||
List<({String appId, FileMetadata target, String? displayName})> items,
|
List<({String appId, Installable target, String? displayName})> items,
|
||||||
) async {
|
) async {
|
||||||
await _ensureDownloaderReady();
|
await _ensureDownloaderReady();
|
||||||
|
|
||||||
@@ -671,7 +671,7 @@ abstract class PackageManager extends StateNotifier<PackageManagerState> {
|
|||||||
/// Advances the specified app AND all other apps awaiting permission.
|
/// Advances the specified app AND all other apps awaiting permission.
|
||||||
Future<void> onPermissionGranted(String appId) async {
|
Future<void> onPermissionGranted(String appId) async {
|
||||||
// Collect all apps that need to advance (AwaitingPermission or permissionDenied failures)
|
// 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) {
|
for (final entry in state.operations.entries) {
|
||||||
final id = entry.key;
|
final id = entry.key;
|
||||||
@@ -708,7 +708,7 @@ abstract class PackageManager extends StateNotifier<PackageManagerState> {
|
|||||||
|
|
||||||
Future<void> _startDownloadTask(
|
Future<void> _startDownloadTask(
|
||||||
String appId,
|
String appId,
|
||||||
FileMetadata target,
|
Installable target,
|
||||||
String downloadUrl, {
|
String downloadUrl, {
|
||||||
String? displayName,
|
String? displayName,
|
||||||
bool isCdnRetry = false,
|
bool isCdnRetry = false,
|
||||||
@@ -932,7 +932,7 @@ abstract class PackageManager extends StateNotifier<PackageManagerState> {
|
|||||||
|
|
||||||
Future<void> _handleDownloadComplete(
|
Future<void> _handleDownloadComplete(
|
||||||
String appId,
|
String appId,
|
||||||
FileMetadata target,
|
Installable target,
|
||||||
DownloadTask task,
|
DownloadTask task,
|
||||||
) async {
|
) async {
|
||||||
// Remove from active downloads
|
// Remove from active downloads
|
||||||
@@ -1050,7 +1050,7 @@ abstract class PackageManager extends StateNotifier<PackageManagerState> {
|
|||||||
/// Check permission and proceed to install
|
/// Check permission and proceed to install
|
||||||
Future<void> _proceedToInstall(
|
Future<void> _proceedToInstall(
|
||||||
String appId,
|
String appId,
|
||||||
FileMetadata target,
|
Installable target,
|
||||||
String filePath,
|
String filePath,
|
||||||
) async {
|
) async {
|
||||||
if (!await hasPermission()) {
|
if (!await hasPermission()) {
|
||||||
@@ -1085,7 +1085,7 @@ abstract class PackageManager extends StateNotifier<PackageManagerState> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Add app to install queue and trigger processing.
|
/// 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)) {
|
if (!installQueue.contains(appId)) {
|
||||||
installQueue.add(appId);
|
installQueue.add(appId);
|
||||||
}
|
}
|
||||||
@@ -1096,7 +1096,7 @@ abstract class PackageManager extends StateNotifier<PackageManagerState> {
|
|||||||
/// Perform the actual installation
|
/// Perform the actual installation
|
||||||
Future<void> _performInstall(
|
Future<void> _performInstall(
|
||||||
String appId,
|
String appId,
|
||||||
FileMetadata target,
|
Installable target,
|
||||||
String filePath,
|
String filePath,
|
||||||
) async {
|
) async {
|
||||||
try {
|
try {
|
||||||
@@ -1210,7 +1210,7 @@ abstract class PackageManager extends StateNotifier<PackageManagerState> {
|
|||||||
String appId,
|
String appId,
|
||||||
TaskRecord record,
|
TaskRecord record,
|
||||||
DownloadTask task,
|
DownloadTask task,
|
||||||
FileMetadata fileMetadata,
|
Installable fileMetadata,
|
||||||
) async {
|
) async {
|
||||||
switch (record.status) {
|
switch (record.status) {
|
||||||
case TaskStatus.complete:
|
case TaskStatus.complete:
|
||||||
@@ -1319,13 +1319,23 @@ abstract class PackageManager extends StateNotifier<PackageManagerState> {
|
|||||||
return (metaData.isNotEmpty ? metaData : null, null, false);
|
return (metaData.isNotEmpty ? metaData : null, null, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<FileMetadata?> _loadFileMetadata(
|
Future<Installable?> _loadFileMetadata(
|
||||||
String? metadataId,
|
String? metadataId,
|
||||||
String filename,
|
String filename,
|
||||||
) async {
|
) async {
|
||||||
final storage = ref.read(storageNotifierProvider.notifier);
|
final storage = ref.read(storageNotifierProvider.notifier);
|
||||||
|
|
||||||
if (metadataId != null) {
|
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 {
|
try {
|
||||||
final results = await storage.query(
|
final results = await storage.query(
|
||||||
RequestFilter<FileMetadata>(ids: {metadataId}).toRequest(),
|
RequestFilter<FileMetadata>(ids: {metadataId}).toRequest(),
|
||||||
@@ -1338,6 +1348,14 @@ abstract class PackageManager extends StateNotifier<PackageManagerState> {
|
|||||||
final dotIndex = filename.lastIndexOf('.');
|
final dotIndex = filename.lastIndexOf('.');
|
||||||
final hash = dotIndex > 0 ? filename.substring(0, dotIndex) : filename;
|
final hash = dotIndex > 0 ? filename.substring(0, dotIndex) : filename;
|
||||||
if (hash.isNotEmpty) {
|
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 {
|
try {
|
||||||
final results = await storage.query(
|
final results = await storage.query(
|
||||||
RequestFilter<FileMetadata>(search: hash).toRequest(),
|
RequestFilter<FileMetadata>(search: hash).toRequest(),
|
||||||
@@ -1363,7 +1381,7 @@ abstract class PackageManager extends StateNotifier<PackageManagerState> {
|
|||||||
String filePath, {
|
String filePath, {
|
||||||
required String expectedHash,
|
required String expectedHash,
|
||||||
required int expectedSize,
|
required int expectedSize,
|
||||||
required FileMetadata target,
|
required Installable target,
|
||||||
});
|
});
|
||||||
|
|
||||||
Future<void> uninstall(String appId);
|
Future<void> uninstall(String appId);
|
||||||
@@ -1386,7 +1404,7 @@ abstract class PackageManager extends StateNotifier<PackageManagerState> {
|
|||||||
///
|
///
|
||||||
/// Comparison uses Android versionCode only. Returns false when either
|
/// Comparison uses Android versionCode only. Returns false when either
|
||||||
/// versionCode is unavailable or the app is not installed.
|
/// 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];
|
final installed = state.installed[appId];
|
||||||
if (installed == null) return false;
|
if (installed == null) return false;
|
||||||
final installedCode = installed.versionCode;
|
final installedCode = installed.versionCode;
|
||||||
@@ -1399,7 +1417,7 @@ abstract class PackageManager extends StateNotifier<PackageManagerState> {
|
|||||||
///
|
///
|
||||||
/// Comparison uses Android versionCode only. Returns false when either
|
/// Comparison uses Android versionCode only. Returns false when either
|
||||||
/// versionCode is unavailable or the app is not installed.
|
/// 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];
|
final installed = state.installed[appId];
|
||||||
if (installed == null) return false;
|
if (installed == null) return false;
|
||||||
final installedCode = installed.versionCode;
|
final installedCode = installed.versionCode;
|
||||||
|
|||||||
@@ -382,7 +382,7 @@ class CategorizedUpdatesNotifier extends Notifier<CategorizedUpdates> {
|
|||||||
|
|
||||||
for (final app in installedApps) {
|
for (final app in installedApps) {
|
||||||
final pkg = installedMap[app.identifier]!;
|
final pkg = installedMap[app.identifier]!;
|
||||||
final latest = app.latestFileMetadata;
|
final latest = app.installable;
|
||||||
|
|
||||||
// Determine if update available using versionCode (via PackageManager)
|
// Determine if update available using versionCode (via PackageManager)
|
||||||
final pm = ref.read(packageManagerProvider.notifier);
|
final pm = ref.read(packageManagerProvider.notifier);
|
||||||
|
|||||||
@@ -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
@@ -33,17 +33,18 @@ extension AppExt on App {
|
|||||||
bool get isInstalled =>
|
bool get isInstalled =>
|
||||||
ref.read(packageManagerProvider.notifier).isInstalled(identifier);
|
ref.read(packageManagerProvider.notifier).isInstalled(identifier);
|
||||||
|
|
||||||
/// Latest file metadata associated to the latest release
|
/// Resolved installable for the current platform.
|
||||||
/// Prefers SoftwareAsset (new format) over FileMetadata (old format)
|
/// Prefers direct SoftwareAsset (3063) via `latestAsset` relationship,
|
||||||
/// Note: assumes latest metadata has been loaded for the current platform
|
/// falls back to Release chain (1063).
|
||||||
FileMetadata? get latestFileMetadata =>
|
Installable? get installable =>
|
||||||
|
latestAsset.value ??
|
||||||
latestRelease.value?.latestAsset.value ??
|
latestRelease.value?.latestAsset.value ??
|
||||||
latestRelease.value?.latestMetadata.value;
|
latestRelease.value?.latestMetadata.value;
|
||||||
|
|
||||||
/// Whether there is an update available for the installed app.
|
/// Whether there is an update available for the installed app.
|
||||||
/// Delegates to PackageManager (versionCode-only comparison).
|
/// Delegates to PackageManager (versionCode-only comparison).
|
||||||
bool get hasUpdate {
|
bool get hasUpdate {
|
||||||
final latest = latestFileMetadata;
|
final latest = installable;
|
||||||
if (latest == null) return false;
|
if (latest == null) return false;
|
||||||
return ref.read(packageManagerProvider.notifier).hasUpdate(identifier, latest);
|
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.
|
/// Whether the relay version would be a downgrade from the installed version.
|
||||||
/// Delegates to PackageManager (versionCode-only comparison).
|
/// Delegates to PackageManager (versionCode-only comparison).
|
||||||
bool get hasDowngrade {
|
bool get hasDowngrade {
|
||||||
final latest = latestFileMetadata;
|
final latest = installable;
|
||||||
if (latest == null) return false;
|
if (latest == null) return false;
|
||||||
return ref.read(packageManagerProvider.notifier).hasDowngrade(identifier, latest);
|
return ref.read(packageManagerProvider.notifier).hasDowngrade(identifier, latest);
|
||||||
}
|
}
|
||||||
@@ -64,29 +65,24 @@ extension WidgetRefExt on WidgetRef {
|
|||||||
Ref get asRef => read(Provider((ref) => ref));
|
Ref get asRef => read(Provider((ref) => ref));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Extension to handle both old and new format certificate hashes
|
/// Extension to get APK certificate hashes from either format.
|
||||||
extension FileMetadataExt on FileMetadata {
|
extension InstallableExt on Installable {
|
||||||
/// Returns the APK certificate hash, checking new format first then old
|
/// Returns the primary APK certificate hash.
|
||||||
/// New format (SoftwareAsset): apkCertificateHashes (Set)
|
/// SoftwareAsset: uses apkCertificateHashes. FileMetadata: uses apkSignatureHash.
|
||||||
/// Old format (FileMetadata): apkSignatureHash (String?)
|
|
||||||
String? get certificateHash {
|
String? get certificateHash {
|
||||||
// Try new format first (SoftwareAsset has apkCertificateHashes)
|
|
||||||
if (this is SoftwareAsset) {
|
if (this is SoftwareAsset) {
|
||||||
final hashes = (this as SoftwareAsset).apkCertificateHashes;
|
final hashes = (this as SoftwareAsset).apkCertificateHashes;
|
||||||
if (hashes.isNotEmpty) return hashes.first;
|
if (hashes.isNotEmpty) return hashes.first;
|
||||||
}
|
}
|
||||||
// Fall back to old format
|
|
||||||
return apkSignatureHash;
|
return apkSignatureHash;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns all APK certificate hashes
|
/// Returns all APK certificate hashes.
|
||||||
Set<String> get certificateHashes {
|
Set<String> get certificateHashes {
|
||||||
// Try new format first
|
|
||||||
if (this is SoftwareAsset) {
|
if (this is SoftwareAsset) {
|
||||||
final hashes = (this as SoftwareAsset).apkCertificateHashes;
|
final hashes = (this as SoftwareAsset).apkCertificateHashes;
|
||||||
if (hashes.isNotEmpty) return hashes;
|
if (hashes.isNotEmpty) return hashes;
|
||||||
}
|
}
|
||||||
// Fall back to old format (single hash as set)
|
|
||||||
return apkSignatureHash != null ? {apkSignatureHash!} : {};
|
return apkSignatureHash != null ? {apkSignatureHash!} : {};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -277,7 +277,7 @@ class _ZappersListSection extends ConsumerWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final metadataId = app.latestFileMetadata?.id;
|
final metadataId = app.installable?.id;
|
||||||
|
|
||||||
// Query zaps on app (via #a tag)
|
// Query zaps on app (via #a tag)
|
||||||
final zapsState = ref.watch(
|
final zapsState = ref.watch(
|
||||||
@@ -925,7 +925,7 @@ class DebugVersionsSection extends HookConsumerWidget {
|
|||||||
WidgetRef ref,
|
WidgetRef ref,
|
||||||
App app,
|
App app,
|
||||||
Release release,
|
Release release,
|
||||||
FileMetadata metadata,
|
Installable metadata,
|
||||||
) async {
|
) async {
|
||||||
// Use PackageManager to start download
|
// Use PackageManager to start download
|
||||||
final pm = ref.read(packageManagerProvider.notifier);
|
final pm = ref.read(packageManagerProvider.notifier);
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ class AppInfoTable extends HookConsumerWidget {
|
|||||||
const AppInfoTable({super.key, required this.app, this.fileMetadata});
|
const AppInfoTable({super.key, required this.app, this.fileMetadata});
|
||||||
|
|
||||||
final App app;
|
final App app;
|
||||||
final FileMetadata? fileMetadata;
|
final Installable? fileMetadata;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
|||||||
@@ -25,11 +25,11 @@ class UpdateAllRow extends ConsumerWidget {
|
|||||||
onPressed: () async {
|
onPressed: () async {
|
||||||
final pm = ref.read(packageManagerProvider.notifier);
|
final pm = ref.read(packageManagerProvider.notifier);
|
||||||
final items = allUpdates
|
final items = allUpdates
|
||||||
.where((app) => app.latestFileMetadata != null)
|
.where((app) => app.installable != null)
|
||||||
.map(
|
.map(
|
||||||
(app) => (
|
(app) => (
|
||||||
appId: app.identifier,
|
appId: app.identifier,
|
||||||
target: app.latestFileMetadata!,
|
target: app.installable!,
|
||||||
displayName: app.name,
|
displayName: app.name,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ class CommentsSection extends HookConsumerWidget {
|
|||||||
const CommentsSection({super.key, required this.app, this.fileMetadata});
|
const CommentsSection({super.key, required this.app, this.fileMetadata});
|
||||||
|
|
||||||
final App app;
|
final App app;
|
||||||
final FileMetadata? fileMetadata;
|
final Installable? fileMetadata;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
@@ -112,7 +112,7 @@ class _CommentsSectionLayout extends StatelessWidget {
|
|||||||
final Object? errorException;
|
final Object? errorException;
|
||||||
final Widget addCommentButton;
|
final Widget addCommentButton;
|
||||||
final App? app;
|
final App? app;
|
||||||
final FileMetadata? fileMetadata;
|
final Installable? fileMetadata;
|
||||||
final AppStack? stack;
|
final AppStack? stack;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -247,7 +247,7 @@ class _ThreadedCommentCard extends HookConsumerWidget {
|
|||||||
final Comment comment;
|
final Comment comment;
|
||||||
final int depth;
|
final int depth;
|
||||||
final App? app;
|
final App? app;
|
||||||
final FileMetadata? fileMetadata;
|
final Installable? fileMetadata;
|
||||||
final AppStack? stack;
|
final AppStack? stack;
|
||||||
|
|
||||||
// Thread line colors for different depths
|
// Thread line colors for different depths
|
||||||
@@ -478,7 +478,7 @@ class _ThreadedCommentCard extends HookConsumerWidget {
|
|||||||
class _AddCommentButton extends ConsumerWidget {
|
class _AddCommentButton extends ConsumerWidget {
|
||||||
const _AddCommentButton({required this.fileMetadata, required this.app});
|
const _AddCommentButton({required this.fileMetadata, required this.app});
|
||||||
|
|
||||||
final FileMetadata fileMetadata;
|
final Installable fileMetadata;
|
||||||
final App app;
|
final App app;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -547,7 +547,7 @@ class _AddStackCommentButton extends ConsumerWidget {
|
|||||||
class _CommentComposer extends HookConsumerWidget {
|
class _CommentComposer extends HookConsumerWidget {
|
||||||
const _CommentComposer({required this.fileMetadata, required this.app});
|
const _CommentComposer({required this.fileMetadata, required this.app});
|
||||||
|
|
||||||
final FileMetadata fileMetadata;
|
final Installable fileMetadata;
|
||||||
final App app;
|
final App app;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -701,7 +701,7 @@ class _ReplyComposer extends HookConsumerWidget {
|
|||||||
|
|
||||||
final Comment parentComment;
|
final Comment parentComment;
|
||||||
final App? app;
|
final App? app;
|
||||||
final FileMetadata? fileMetadata;
|
final Installable? fileMetadata;
|
||||||
final AppStack? stack;
|
final AppStack? stack;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ class InstallAlertDialog extends HookConsumerWidget {
|
|||||||
'${app.name ?? app.identifier} will be installed from its original release location:',
|
'${app.name ?? app.identifier} will be installed from its original release location:',
|
||||||
oneLine: false,
|
oneLine: false,
|
||||||
showFullUrl: true,
|
showFullUrl: true,
|
||||||
url: app.latestFileMetadata!.urls.first,
|
url: app.installable!.urls.first,
|
||||||
size: baseTextSize,
|
size: baseTextSize,
|
||||||
),
|
),
|
||||||
] else ...[
|
] else ...[
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ class InstallButton extends ConsumerWidget {
|
|||||||
final hasUpdate = app.hasUpdate;
|
final hasUpdate = app.hasUpdate;
|
||||||
final hasDowngrade = app.hasDowngrade;
|
final hasDowngrade = app.hasDowngrade;
|
||||||
final hasRelease = release != null;
|
final hasRelease = release != null;
|
||||||
final fileMetadata = app.latestFileMetadata;
|
final fileMetadata = app.installable;
|
||||||
|
|
||||||
// Listen for errors to show toasts
|
// Listen for errors to show toasts
|
||||||
ref.listen(installOperationProvider(app.identifier), (prev, next) {
|
ref.listen(installOperationProvider(app.identifier), (prev, next) {
|
||||||
@@ -104,7 +104,7 @@ class InstallButton extends ConsumerWidget {
|
|||||||
required bool hasUpdate,
|
required bool hasUpdate,
|
||||||
required bool hasDowngrade,
|
required bool hasDowngrade,
|
||||||
required bool hasRelease,
|
required bool hasRelease,
|
||||||
required FileMetadata? fileMetadata,
|
required Installable? fileMetadata,
|
||||||
required double fontSize,
|
required double fontSize,
|
||||||
}) {
|
}) {
|
||||||
// Completed is a terminal "result" state and may linger for batch progress UX.
|
// Completed is a terminal "result" state and may linger for batch progress UX.
|
||||||
@@ -285,7 +285,7 @@ class InstallButton extends ConsumerWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
String? _formatTotalSizeMb() {
|
String? _formatTotalSizeMb() {
|
||||||
final sizeBytes = app.latestFileMetadata?.size;
|
final sizeBytes = app.installable?.size;
|
||||||
if (sizeBytes == null || sizeBytes <= 0) return null;
|
if (sizeBytes == null || sizeBytes <= 0) return null;
|
||||||
final mb = sizeBytes / (1024 * 1024);
|
final mb = sizeBytes / (1024 * 1024);
|
||||||
return '${mb.toStringAsFixed(1)} MB';
|
return '${mb.toStringAsFixed(1)} MB';
|
||||||
@@ -561,7 +561,7 @@ class InstallButton extends ConsumerWidget {
|
|||||||
Future<void> _startDownload(
|
Future<void> _startDownload(
|
||||||
BuildContext context,
|
BuildContext context,
|
||||||
WidgetRef ref,
|
WidgetRef ref,
|
||||||
FileMetadata fileMetadata,
|
Installable fileMetadata,
|
||||||
) async {
|
) async {
|
||||||
final pm = ref.read(packageManagerProvider.notifier);
|
final pm = ref.read(packageManagerProvider.notifier);
|
||||||
await pm.startDownload(app.identifier, fileMetadata, displayName: app.name);
|
await pm.startDownload(app.identifier, fileMetadata, displayName: app.name);
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|||||||
import 'package:models/models.dart';
|
import 'package:models/models.dart';
|
||||||
import 'package:purplebase/purplebase.dart';
|
import 'package:purplebase/purplebase.dart';
|
||||||
import 'package:zapstore/services/updates_service.dart';
|
import 'package:zapstore/services/updates_service.dart';
|
||||||
|
import 'package:zapstore/utils/app_query.dart';
|
||||||
import 'package:zapstore/utils/extensions.dart';
|
import 'package:zapstore/utils/extensions.dart';
|
||||||
import 'app_card.dart';
|
import 'app_card.dart';
|
||||||
|
|
||||||
@@ -295,47 +296,34 @@ class LatestReleasesNotifier extends StateNotifier<LatestReleasesState> {
|
|||||||
|
|
||||||
final Ref ref;
|
final Ref ref;
|
||||||
static const int _pageSize = 5;
|
static const int _pageSize = 5;
|
||||||
ProviderSubscription<StorageState<App>>? _sub;
|
ProviderSubscription<StorageState<SoftwareAsset>>? _sub;
|
||||||
// We keep a fixed live head window from query(); older pages are appended
|
|
||||||
|
|
||||||
void _startQuery() {
|
void _startQuery() {
|
||||||
_sub?.close();
|
_sub?.close();
|
||||||
|
|
||||||
_sub = ref.listen<StorageState<App>>(
|
_sub = ref.listen<StorageState<SoftwareAsset>>(
|
||||||
query<App>(
|
appAssetsQuery(
|
||||||
limit: _pageSize,
|
limit: _pageSize,
|
||||||
tags: {
|
tags: {
|
||||||
'#f': {'android-arm64-v8a'},
|
'#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),
|
source: const LocalAndRemoteSource(relays: 'AppCatalog', stream: true),
|
||||||
subscriptionPrefix: 'app-latest',
|
subscriptionPrefix: 'app-latest',
|
||||||
),
|
),
|
||||||
(previous, next) async {
|
(previous, next) {
|
||||||
// Always mirror storage state and ensure olderApps don't duplicate the live head
|
final appState = _appsFromAssetState(next);
|
||||||
if (next is StorageData<App>) {
|
|
||||||
final liveIds = next.models.map((a) => a.id).toSet();
|
if (appState is StorageData<App>) {
|
||||||
|
final liveIds = appState.models.map((a) => a.id).toSet();
|
||||||
final filteredOlder = state.olderApps
|
final filteredOlder = state.olderApps
|
||||||
.where((a) => !liveIds.contains(a.id))
|
.where((a) => !liveIds.contains(a.id))
|
||||||
.toList();
|
.toList();
|
||||||
state = state.copyWith(storage: next, olderApps: filteredOlder);
|
state = state.copyWith(storage: appState, olderApps: filteredOlder);
|
||||||
} else {
|
} 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);
|
state = state.copyWith(isLoadingMore: false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -343,10 +331,31 @@ class LatestReleasesNotifier extends StateNotifier<LatestReleasesState> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Fetch authors for a page of apps (used during pagination)
|
/// Derive a `StorageState<App>` from the SoftwareAsset query results.
|
||||||
/// Note: Profiles are now loaded reactively via `query<Profile>` in individual widgets
|
StorageState<App> _appsFromAssetState(StorageState<SoftwareAsset> assetState) {
|
||||||
Future<void> _loadRelationshipsFor(List<App> appsPage) async {
|
return switch (assetState) {
|
||||||
// No-op: profiles are now loaded reactively via `query<Profile>` with caching
|
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 {
|
Future<void> loadMore() async {
|
||||||
@@ -362,68 +371,26 @@ class LatestReleasesNotifier extends StateNotifier<LatestReleasesState> {
|
|||||||
state = state.copyWith(isLoadingMore: true);
|
state = state.copyWith(isLoadingMore: true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Fetch older apps
|
final result = await fetchAppsByAsset(
|
||||||
final olderPage = await ref.storage.query(
|
ref.storage,
|
||||||
RequestFilter<App>(
|
tags: {
|
||||||
until: oldest,
|
'#f': {'android-arm64-v8a'},
|
||||||
limit: _pageSize,
|
},
|
||||||
tags: {
|
until: oldest,
|
||||||
'#f': {'android-arm64-v8a'},
|
limit: _pageSize,
|
||||||
},
|
|
||||||
).toRequest(),
|
|
||||||
source: const LocalAndRemoteSource(stream: false),
|
source: const LocalAndRemoteSource(stream: false),
|
||||||
subscriptionPrefix: 'app-latest-older',
|
subscriptionPrefix: 'app-latest-older',
|
||||||
);
|
);
|
||||||
|
|
||||||
if (olderPage.isNotEmpty) {
|
if (result.apps.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);
|
|
||||||
|
|
||||||
final existingIds = combined.map((a) => a.id).toSet();
|
final existingIds = combined.map((a) => a.id).toSet();
|
||||||
final uniqueOlder = olderPage
|
final uniqueOlder = result.apps
|
||||||
.where((a) => !existingIds.contains(a.id))
|
.where((a) => !existingIds.contains(a.id))
|
||||||
.toList();
|
.toList();
|
||||||
state = state.copyWith(
|
state = state.copyWith(
|
||||||
olderApps: [...state.olderApps, ...uniqueOlder],
|
olderApps: [...state.olderApps, ...uniqueOlder],
|
||||||
isLoadingMore: false,
|
isLoadingMore: false,
|
||||||
hasMore: olderPage.length >= _pageSize,
|
hasMore: result.assetCount >= _pageSize,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
state = state.copyWith(isLoadingMore: false, hasMore: false);
|
state = state.copyWith(isLoadingMore: false, hasMore: false);
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ class RelevantWhoFollowContainer extends HookConsumerWidget {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Also query zaps on the latest metadata if available
|
// Also query zaps on the latest metadata if available
|
||||||
final latestMetadata = app.latestFileMetadata;
|
final latestMetadata = app.installable;
|
||||||
final metadataZapsState = latestMetadata != null
|
final metadataZapsState = latestMetadata != null
|
||||||
? ref.watch(
|
? ref.watch(
|
||||||
query<Zap>(
|
query<Zap>(
|
||||||
|
|||||||
@@ -28,22 +28,22 @@ class VersionPillWidget extends HookConsumerWidget {
|
|||||||
packageManagerProvider.select((s) => s.installed[app.identifier]),
|
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(
|
final appState = ref.watch(
|
||||||
model<App>(
|
model<App>(
|
||||||
app,
|
app,
|
||||||
and: (a) => {
|
and: (a) => {
|
||||||
|
a.latestAsset.query(source: const LocalSource()),
|
||||||
a.latestRelease.query(
|
a.latestRelease.query(
|
||||||
source: const LocalAndRemoteSource(
|
source: const LocalSource(),
|
||||||
relays: 'AppCatalog',
|
|
||||||
stream: false,
|
|
||||||
),
|
|
||||||
and: (release) => {
|
and: (release) => {
|
||||||
release.latestMetadata.query(),
|
release.latestMetadata.query(source: const LocalSource()),
|
||||||
release.latestAsset.query(),
|
release.latestAsset.query(source: const LocalSource()),
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
source: const LocalSource(),
|
||||||
subscriptionPrefix: 'app-version-pill',
|
subscriptionPrefix: 'app-version-pill',
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -58,8 +58,8 @@ class VersionPillWidget extends HookConsumerWidget {
|
|||||||
final installedPackage = loadedApp.installedPackage;
|
final installedPackage = loadedApp.installedPackage;
|
||||||
final installedVersion = installedPackage?.version;
|
final installedVersion = installedPackage?.version;
|
||||||
final installedVersionCode = installedPackage?.versionCode;
|
final installedVersionCode = installedPackage?.versionCode;
|
||||||
final availableVersion = loadedApp.latestFileMetadata?.version;
|
final availableVersion = loadedApp.installable?.version;
|
||||||
final availableVersionCode = loadedApp.latestFileMetadata?.versionCode;
|
final availableVersionCode = loadedApp.installable?.versionCode;
|
||||||
final isInstalled = loadedApp.isInstalled;
|
final isInstalled = loadedApp.isInstalled;
|
||||||
final updateAvailable = loadedApp.hasUpdate;
|
final updateAvailable = loadedApp.hasUpdate;
|
||||||
final downgradeAvailable = loadedApp.hasDowngrade;
|
final downgradeAvailable = loadedApp.hasDowngrade;
|
||||||
|
|||||||
@@ -427,7 +427,7 @@ class ZapAmountDialog extends HookConsumerWidget {
|
|||||||
: await secureStorage.getNWCString();
|
: await secureStorage.getNWCString();
|
||||||
|
|
||||||
// Build zap request
|
// Build zap request
|
||||||
final latestMetadata = app.latestFileMetadata;
|
final latestMetadata = app.installable;
|
||||||
final author = app.author.value;
|
final author = app.author.value;
|
||||||
|
|
||||||
if (latestMetadata == null || author == null) {
|
if (latestMetadata == null || author == null) {
|
||||||
@@ -445,7 +445,7 @@ class ZapAmountDialog extends HookConsumerWidget {
|
|||||||
if (comment.isNotEmpty) zapRequest.comment = comment;
|
if (comment.isNotEmpty) zapRequest.comment = comment;
|
||||||
zapRequest.linkProfileByPubkey(author.pubkey);
|
zapRequest.linkProfileByPubkey(author.pubkey);
|
||||||
zapRequest.linkModel(app);
|
zapRequest.linkModel(app);
|
||||||
zapRequest.linkModel(latestMetadata);
|
zapRequest.linkModelById(latestMetadata.id);
|
||||||
zapRequest.relays = socialRelays;
|
zapRequest.relays = socialRelays;
|
||||||
|
|
||||||
final signedZapRequest = await zapRequest.signWith(
|
final signedZapRequest = await zapRequest.signWith(
|
||||||
|
|||||||
@@ -6,6 +6,22 @@
|
|||||||
|
|
||||||
#include "generated_plugin_registrant.h"
|
#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) {
|
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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,10 @@
|
|||||||
#
|
#
|
||||||
|
|
||||||
list(APPEND FLUTTER_PLUGIN_LIST
|
list(APPEND FLUTTER_PLUGIN_LIST
|
||||||
|
flutter_secure_storage_linux
|
||||||
|
gtk
|
||||||
|
sqlite3_flutter_libs
|
||||||
|
url_launcher_linux
|
||||||
)
|
)
|
||||||
|
|
||||||
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
||||||
|
|||||||
@@ -5,6 +5,28 @@
|
|||||||
import FlutterMacOS
|
import FlutterMacOS
|
||||||
import Foundation
|
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) {
|
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.
|
||||||
@@ -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.
|
||||||
|
|
||||||
Reference in New Issue
Block a user