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 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),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 =>
|
||||
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!} : {};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 ...[
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
@@ -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