diff --git a/analysis_options.yaml b/analysis_options.yaml index 5da7251..2fb84c4 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -10,6 +10,7 @@ analyzer: errors: dangling_library_doc_comments: ignore + deprecated_member_use: ignore include: package:flutter_lints/flutter.yaml linter: diff --git a/lib/main.dart b/lib/main.dart index 4b31c27..15ee050 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -201,7 +201,7 @@ class ZapstoreHome extends StatelessWidget { } final appInitializationProvider = FutureProvider((ref) async { - final dir = await getApplicationDocumentsDirectory(); + final dir = await getApplicationSupportDirectory(); final dbPath = path.join(dir.path, 'zapstore.db'); // Clear storage if requested from a clear all operation diff --git a/lib/screens/profile_screen.dart b/lib/screens/profile_screen.dart index c390959..0a04536 100644 --- a/lib/screens/profile_screen.dart +++ b/lib/screens/profile_screen.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'dart:convert'; import 'package:async_button_builder/async_button_builder.dart'; +import 'package:auto_size_text/auto_size_text.dart'; import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -1379,12 +1380,14 @@ class _DataManagementSection extends ConsumerWidget { color: Theme.of(context).colorScheme.error, ), ), - title: Text( + title: AutoSizeText( 'Clear local storage', style: TextStyle( color: Theme.of(context).colorScheme.error, fontWeight: FontWeight.w600, ), + maxLines: 1, + minFontSize: 12, ), contentPadding: EdgeInsets.zero, onTap: () => _showClearAllDataDialog(context, ref), @@ -1403,7 +1406,7 @@ class _DataManagementSection extends ConsumerWidget { children: [ Icon(Icons.warning, color: Theme.of(context).colorScheme.error), const SizedBox(width: 8), - const Text('Clear local storage'), + Flexible(child: const Text('Clear local storage')), ], ), content: const Text( @@ -1477,80 +1480,111 @@ class _SavedAppsHeading extends ConsumerWidget { return const SizedBox.shrink(); } - final savedAppsAsync = ref.watch(bookmarksProvider); - - return savedAppsAsync.when( - loading: () => const SizedBox.shrink(), - error: (_, __) => const SizedBox.shrink(), - data: (addressableIds) { - if (addressableIds.isEmpty) { - return const SizedBox.shrink(); - } - - final identifiers = addressableIds - .map((id) { - final parts = id.split(':'); - return parts.length >= 3 ? parts[2] : null; - }) - .whereType() - .toSet(); - - if (identifiers.isEmpty) { - return const SizedBox.shrink(); - } - - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 16.0), - child: Text('Saved Apps', style: context.textTheme.headlineSmall), - ); - }, + // Always show heading when signed in + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: Text('Saved Apps', style: context.textTheme.headlineSmall), ); } } -class _SavedAppsSection extends HookConsumerWidget { +class _SavedAppsSection extends ConsumerWidget { const _SavedAppsSection(); @override Widget build(BuildContext context, WidgetRef ref) { final signedInPubkey = ref.watch(Signer.activePubkeyProvider); - final hasLoadedOnce = useState(false); - - if (signedInPubkey == null) { - return const SizedBox.shrink(); - } + if (signedInPubkey == null) return const SizedBox.shrink(); final savedAppsAsync = ref.watch(bookmarksProvider); - return savedAppsAsync.when( - loading: () => _savedAppsLoadingCard(context), - error: (_, __) => const SizedBox.shrink(), - data: (addressableIds) { - if (addressableIds.isEmpty) { - return const SizedBox.shrink(); - } + // Keep previous value during refresh, if available. + final addressableIds = savedAppsAsync.valueOrNull; - final identifiers = addressableIds - .map((id) { - final parts = id.split(':'); - return parts.length >= 3 ? parts[2] : null; - }) - .whereType() - .toSet(); + // Show loading only on first load (when no value exists yet). + if (addressableIds == null) { + return _savedAppsLoadingCard(context); + } - if (identifiers.isEmpty) { - return const SizedBox.shrink(); - } + final identifiers = _toIdentifiers(addressableIds); + return _SavedAppsList(identifiers: identifiers); + } - return _SavedAppsList( - identifiers: identifiers, - hasLoadedOnce: hasLoadedOnce, - ); - }, + Set _toIdentifiers(Set addressableIds) { + return addressableIds + .map((id) => id.split(':')) + .where((parts) => parts.length >= 3) + .map((parts) => parts[2]) + .toSet(); + } + + Widget _savedAppsLoadingCard(BuildContext context) => Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Center( + child: CircularProgressIndicator( + color: Theme.of(context).colorScheme.primary, + ), + ), + ), + ); +} + +class _SavedAppsList extends ConsumerWidget { + const _SavedAppsList({required this.identifiers}); + + final Set identifiers; + + @override + Widget build(BuildContext context, WidgetRef ref) { + // No bookmarks saved - show empty state without querying + if (identifiers.isEmpty) { + return _emptyState(context); + } + + final savedAppsState = ref.watch( + query( + tags: {'#d': identifiers}, + and: (app) => {app.latestRelease.query()}, + source: const LocalSource(), + subscriptionPrefix: 'profile-saved-apps', + ), + ); + + final isLoading = savedAppsState is StorageLoading; + + final savedApps = savedAppsState.models.toList() + ..sort( + (a, b) => (a.name ?? a.identifier).toLowerCase().compareTo( + (b.name ?? b.identifier).toLowerCase(), + ), + ); + + // Show spinner only when we truly have nothing to render yet + // If we're refreshing but still have models, keep showing the list + if (isLoading && savedApps.isEmpty) { + return _loadingCard(context); + } + + if (savedApps.isEmpty) { + return _emptyState(context); + } + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (final app in savedApps) + AppCard(app: app, showUpdateArrow: false, showDescription: false), + ], + ), + ), ); } - Widget _savedAppsLoadingCard(BuildContext context) { + Widget _loadingCard(BuildContext context) { return Card( child: Padding( padding: const EdgeInsets.all(16), @@ -1562,75 +1596,15 @@ class _SavedAppsSection extends HookConsumerWidget { ), ); } -} -class _SavedAppsList extends ConsumerWidget { - const _SavedAppsList({ - required this.identifiers, - required this.hasLoadedOnce, - }); - - final Set identifiers; - final ValueNotifier hasLoadedOnce; - - @override - Widget build(BuildContext context, WidgetRef ref) { - final savedAppsState = ref.watch( - query( - tags: {'#d': identifiers}, - and: (app) => {app.latestRelease.query()}, - source: const LocalSource(), - subscriptionPrefix: 'profile-saved-apps', - ), - ); - - final savedApps = savedAppsState.models.toList() - ..sort( - (a, b) => (a.name ?? a.identifier).toLowerCase().compareTo( - (b.name ?? b.identifier).toLowerCase(), - ), - ); - - // Check if we have loaded at least once - defer state update to after build - if (savedApps.isNotEmpty && !hasLoadedOnce.value) { - WidgetsBinding.instance.addPostFrameCallback((_) { - hasLoadedOnce.value = true; - }); - } - - // Show loading state only if we haven't loaded any apps yet - if (savedApps.isEmpty && !hasLoadedOnce.value) { - return Card( - child: Padding( - padding: const EdgeInsets.all(16), - child: Center( - child: CircularProgressIndicator( - color: Theme.of(context).colorScheme.primary, - ), - ), - ), - ); - } - - // Hide if no apps after loading - if (savedApps.isEmpty) { - return const SizedBox.shrink(); - } - - return Card( - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: savedApps - .map( - (app) => AppCard( - app: app, - showUpdateArrow: false, - showDescription: false, - ), - ) - .toList(), + Widget _emptyState(BuildContext context) { + final theme = Theme.of(context); + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Text( + 'No saved apps yet', + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurface.withValues(alpha: 0.5), ), ), ); diff --git a/lib/screens/search_screen.dart b/lib/screens/search_screen.dart index 3592314..de21796 100644 --- a/lib/screens/search_screen.dart +++ b/lib/screens/search_screen.dart @@ -172,7 +172,7 @@ class _SearchResultsSection extends HookConsumerWidget { // Force the search to hit the default relay group (relay.zapstore.dev) // so a connection appears in Debug Info when searching. source: const RemoteSource(relays: 'AppCatalog', stream: false), - subscriptionPrefix: 'search-results', + subscriptionPrefix: 'app-search-results', ), ); diff --git a/lib/screens/updates_screen.dart b/lib/screens/updates_screen.dart index 1c8e3f6..584c3cd 100644 --- a/lib/screens/updates_screen.dart +++ b/lib/screens/updates_screen.dart @@ -40,9 +40,9 @@ class _ConnectionStatusIndicator extends ConsumerWidget { final poolState = ref.watch(poolStateProvider); final subscriptions = poolState?.subscriptions ?? {}; - // Filter to only "updates" subscription + // Filter to only "app-updates" subscription final updatesSubs = subscriptions.entries - .where((e) => e.key.startsWith('updates')) + .where((e) => e.key.startsWith('app-updates')) .map((e) => e.value); // Check relay status for updates subscription only diff --git a/lib/screens/user_screen.dart b/lib/screens/user_screen.dart index ff1d154..ebd15d8 100644 --- a/lib/screens/user_screen.dart +++ b/lib/screens/user_screen.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; @@ -49,7 +50,7 @@ class UserScreen extends HookConsumerWidget { ), }, source: const LocalAndRemoteSource(relays: 'AppCatalog', stream: false), - subscriptionPrefix: 'user-apps', + subscriptionPrefix: 'app-user-apps', ), ); @@ -382,34 +383,50 @@ class _NpubRow extends StatelessWidget { final abbreviatedNpub = '${npub.substring(0, 12)}...${npub.substring(npub.length - 8)}'; - return GestureDetector( - onTap: () => launchUrl( - Uri.parse('https://npub.world/$npub'), - mode: LaunchMode.externalApplication, - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - Icons.key, - size: 16, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - const SizedBox(width: 4), - Text( - abbreviatedNpub, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Theme.of(context).colorScheme.onSurface, - ), - ), - const SizedBox(width: 4), - Icon( - Icons.open_in_new, + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + GestureDetector( + onTap: () { + Clipboard.setData(ClipboardData(text: npub)); + }, + child: Icon( + Icons.copy, size: 12, color: Theme.of(context).colorScheme.onSurfaceVariant, ), - ], - ), + ), + const SizedBox(width: 4), + GestureDetector( + onTap: () => launchUrl( + Uri.parse('https://npub.world/$npub'), + mode: LaunchMode.externalApplication, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.key, + size: 16, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + const SizedBox(width: 4), + Text( + abbreviatedNpub, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onSurface, + ), + ), + const SizedBox(width: 4), + Icon( + Icons.open_in_new, + size: 12, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ], + ), + ), + ], ); } } diff --git a/lib/services/app_restart_service.dart b/lib/services/app_restart_service.dart index f9a5179..03ab726 100644 --- a/lib/services/app_restart_service.dart +++ b/lib/services/app_restart_service.dart @@ -9,7 +9,7 @@ const _markerFileName = '.clear_on_restart'; /// Checks if storage should be cleared on this launch, and clears the marker. /// Call this BEFORE initializing storage. Future maybeClearStorage(String dbPath) async { - final dir = await getApplicationDocumentsDirectory(); + final dir = await getApplicationSupportDirectory(); final marker = File('${dir.path}/$_markerFileName'); if (await marker.exists()) { final dbFile = File(dbPath); @@ -22,7 +22,7 @@ Future maybeClearStorage(String dbPath) async { /// Sets a marker file and triggers a native app restart. Future restartApp() async { - final dir = await getApplicationDocumentsDirectory(); + final dir = await getApplicationSupportDirectory(); final marker = File('${dir.path}/$_markerFileName'); await marker.create(); await _channel.invokeMethod('restart'); diff --git a/lib/services/background_update_service.dart b/lib/services/background_update_service.dart index f09b9a7..4df6719 100644 --- a/lib/services/background_update_service.dart +++ b/lib/services/background_update_service.dart @@ -1,7 +1,9 @@ -import 'dart:io' show Directory, Platform; +import 'dart:io' show Directory, File, Platform; +import 'dart:ui' as ui; -import 'package:background_downloader/background_downloader.dart'; +import 'package:background_downloader/background_downloader.dart' hide Request; import 'package:flutter_local_notifications/flutter_local_notifications.dart'; +import 'package:flutter/widgets.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:models/models.dart'; import 'package:path/path.dart' as path; @@ -9,7 +11,7 @@ import 'package:path_provider/path_provider.dart'; import 'package:permission_handler/permission_handler.dart'; import 'package:purplebase/purplebase.dart'; import 'package:workmanager/workmanager.dart'; -import 'package:zapstore/services/package_manager/android_package_manager.dart'; +import 'package:zapstore/services/package_manager/background_package_manager.dart'; import 'package:zapstore/services/package_manager/dummy_package_manager.dart'; import 'package:zapstore/services/package_manager/package_manager.dart'; import 'package:zapstore/utils/extensions.dart'; @@ -42,6 +44,8 @@ const kAppCatalogRelaysKey = 'appCatalogRelays'; /// This MUST be a top-level function (not a class method). @pragma('vm:entry-point') void callbackDispatcher() { + WidgetsFlutterBinding.ensureInitialized(); + ui.DartPluginRegistrant.ensureInitialized(); Workmanager().executeTask((task, inputData) async { switch (task) { case kBackgroundUpdateTaskName: @@ -81,11 +85,8 @@ Future _performWeeklyCleanup() async { try { // Delete the file if it exists final filePath = await task.filePath(); - final file = Directory(filePath).parent.listSync().firstWhere( - (f) => f.path.endsWith(task.filename), - orElse: () => Directory(''), - ); - if (file.path.isNotEmpty) { + final file = File(filePath); + if (await file.exists()) { await file.delete(); } } catch (_) {} @@ -105,14 +106,14 @@ Future _performWeeklyCleanup() async { ); if (await downloadDir.exists()) { - final files = downloadDir.listSync(); + final entities = downloadDir.listSync(); final cutoff = DateTime.now().subtract(_staleDownloadThreshold); - for (final file in files) { - if (file is! Directory) { - final stat = await file.stat(); + for (final entity in entities) { + if (entity is File) { + final stat = await entity.stat(); if (stat.modified.isBefore(cutoff)) { - await file.delete(); + await entity.delete(); } } } @@ -145,7 +146,7 @@ Future _checkForUpdatesInBackground(Set? appCatalogRelays) async { storageNotifierProvider.overrideWith(PurplebaseStorageNotifier.new), packageManagerProvider.overrideWith( (ref) => Platform.isAndroid - ? AndroidPackageManager(ref) + ? BackgroundPackageManager(ref) : DummyPackageManager(ref), ), ], @@ -153,7 +154,7 @@ Future _checkForUpdatesInBackground(Set? appCatalogRelays) async { try { // Initialize Purplebase with same DB path as main app - final dir = await getApplicationDocumentsDirectory(); + final dir = await getApplicationSupportDirectory(); final dbPath = path.join(dir.path, 'zapstore.db'); await container.read( @@ -188,18 +189,40 @@ Future _checkForUpdatesInBackground(Set? appCatalogRelays) async { source: const RemoteSource(relays: 'AppCatalog', stream: false), ); - // Load releases for all apps in a single query + // Load releases and their metadata/assets (required for hasUpdate) if (apps.isNotEmpty) { - final addressableIds = apps - .map((app) => app.event.addressableId) - .toSet(); - await storage.query( - RequestFilter(tags: {'#a': addressableIds}).toRequest(), - source: const LocalAndRemoteSource( - relays: 'AppCatalog', - stream: false, - ), - ); + final releaseFilters = apps + .map((app) => app.latestRelease.req?.filters.firstOrNull) + .nonNulls + .toList(); + if (releaseFilters.isNotEmpty) { + final List releases = await storage.query( + Request(releaseFilters), + source: const RemoteSource(relays: 'AppCatalog', stream: false), + ); + + final metadataFilters = releases + .map((r) => r.latestMetadata.req?.filters.firstOrNull) + .nonNulls + .toList(); + if (metadataFilters.isNotEmpty) { + await storage.query( + Request(metadataFilters), + source: const RemoteSource(relays: 'AppCatalog', stream: false), + ); + } + + final assetFilters = releases + .map((r) => r.latestAsset.req?.filters.firstOrNull) + .nonNulls + .toList(); + if (assetFilters.isNotEmpty) { + await storage.query( + Request(assetFilters), + source: const RemoteSource(relays: 'AppCatalog', stream: false), + ); + } + } } // Re-query apps from local to ensure relationships are loaded @@ -255,6 +278,7 @@ Future _showUpdateNotification( android: initializationSettingsAndroid, ); await flutterLocalNotificationsPlugin.initialize(initializationSettings); + await _ensureUpdateNotificationChannel(flutterLocalNotificationsPlugin); // Build notification content final title = updateCount == 1 @@ -366,23 +390,7 @@ class BackgroundUpdateService { // Navigation is handled by the app's normal launch flow }, ); - - // Create notification channel on Android - final androidPlugin = flutterLocalNotificationsPlugin - .resolvePlatformSpecificImplementation< - AndroidFlutterLocalNotificationsPlugin - >(); - - if (androidPlugin != null) { - await androidPlugin.createNotificationChannel( - const AndroidNotificationChannel( - kUpdateNotificationChannelId, - kUpdateNotificationChannelName, - description: kUpdateNotificationChannelDescription, - importance: Importance.defaultImportance, - ), - ); - } + await _ensureUpdateNotificationChannel(flutterLocalNotificationsPlugin); } /// Cancel background update checks @@ -405,6 +413,26 @@ class BackgroundUpdateService { } } +Future _ensureUpdateNotificationChannel( + FlutterLocalNotificationsPlugin plugin, +) async { + final androidPlugin = plugin + .resolvePlatformSpecificImplementation< + AndroidFlutterLocalNotificationsPlugin + >(); + + if (androidPlugin != null) { + await androidPlugin.createNotificationChannel( + const AndroidNotificationChannel( + kUpdateNotificationChannelId, + kUpdateNotificationChannelName, + description: kUpdateNotificationChannelDescription, + importance: Importance.defaultImportance, + ), + ); + } +} + /// Provider for the background update service final backgroundUpdateServiceProvider = Provider( BackgroundUpdateService.new, diff --git a/lib/services/package_manager/android_package_manager.dart b/lib/services/package_manager/android_package_manager.dart index 0e4b350..e8c345e 100644 --- a/lib/services/package_manager/android_package_manager.dart +++ b/lib/services/package_manager/android_package_manager.dart @@ -4,6 +4,7 @@ import 'dart:io'; import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; import 'package:models/models.dart'; +import 'package:zapstore/services/package_manager/installed_packages_snapshot.dart'; import 'package:zapstore/services/package_manager/package_manager.dart'; /// Install status values from native side. @@ -600,6 +601,7 @@ final class AndroidPackageManager extends PackageManager { } state = state.copyWith(installed: {...packages, ...preserved}); + await InstalledPackagesSnapshot.save(state.installed); // Clear operations for apps where the installed version matches the target version // This catches installs that succeeded but we missed the event diff --git a/lib/services/package_manager/background_package_manager.dart b/lib/services/package_manager/background_package_manager.dart new file mode 100644 index 0000000..e72e275 --- /dev/null +++ b/lib/services/package_manager/background_package_manager.dart @@ -0,0 +1,53 @@ +import 'package:models/models.dart'; +import 'package:zapstore/services/package_manager/installed_packages_snapshot.dart'; +import 'package:zapstore/services/package_manager/package_manager.dart'; + +/// Background-safe PackageManager that avoids EventChannel usage. +final class BackgroundPackageManager extends PackageManager { + BackgroundPackageManager(super.ref); + + // Zapstore currently targets arm64 APKs for background checks. + @override + String get platform => 'android-arm64-v8a'; + + @override + String get packageExtension => '.apk'; + + @override + bool get supportsSilentInstall => false; + + @override + Future install( + String appId, + String filePath, { + required String expectedHash, + required int expectedSize, + required FileMetadata target, + }) { + throw UnsupportedError('Install not supported in background'); + } + + @override + Future uninstall(String appId) { + throw UnsupportedError('Uninstall not supported in background'); + } + + @override + Future launchApp(String appId) { + throw UnsupportedError('Launch not supported in background'); + } + + @override + Future requestPermission() { + throw UnsupportedError('Permission not supported in background'); + } + + @override + Future hasPermission() async => false; + + @override + Future syncInstalledPackages() async { + final installed = await InstalledPackagesSnapshot.load(); + state = state.copyWith(installed: installed); + } +} diff --git a/lib/services/package_manager/installed_packages_snapshot.dart b/lib/services/package_manager/installed_packages_snapshot.dart new file mode 100644 index 0000000..043af6f --- /dev/null +++ b/lib/services/package_manager/installed_packages_snapshot.dart @@ -0,0 +1,90 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:path/path.dart' as path; +import 'package:path_provider/path_provider.dart'; +import 'package:zapstore/services/package_manager/package_manager.dart'; + +class InstalledPackagesSnapshot { + static const _fileName = 'installed_packages_snapshot.json'; + + static Future _file() async { + final dir = await getApplicationSupportDirectory(); + return File(path.join(dir.path, _fileName)); + } + + static Future save(Map installed) async { + try { + final file = await _file(); + final tmp = File('${file.path}.tmp'); + final list = installed.values + .map( + (p) => { + 'appId': p.appId, + 'name': p.name, + 'version': p.version, + 'versionCode': p.versionCode, + 'signatureHash': p.signatureHash, + 'canInstallSilently': p.canInstallSilently, + }, + ) + .toList(growable: false); + final payload = jsonEncode({ + 'v': 1, + 'savedAt': DateTime.now().millisecondsSinceEpoch, + 'installed': list, + }); + await tmp.writeAsString(payload, flush: true); + if (await file.exists()) { + await file.delete(); + } + await tmp.rename(file.path); + } catch (e) { + // Best-effort snapshot only. + if (kDebugMode) { + debugPrint('[InstalledPackagesSnapshot] Save failed: $e'); + } + } + } + + static Future> load() async { + try { + final file = await _file(); + if (!await file.exists()) return {}; + + final raw = await file.readAsString(); + final decoded = jsonDecode(raw); + if (decoded is! Map) return {}; + final version = decoded['v']; + if (version != null && version is! int && kDebugMode) { + debugPrint('[InstalledPackagesSnapshot] Unknown schema: $version'); + } + final installed = decoded['installed']; + if (installed is! List) return {}; + + final result = {}; + for (final item in installed) { + if (item is! Map) continue; + final map = Map.from(item); + final appId = map['appId'] as String?; + if (appId == null || appId.isEmpty) continue; + result[appId] = PackageInfo( + appId: appId, + name: map['name'] as String?, + version: (map['version'] as String?) ?? '0.0.0', + versionCode: map['versionCode'] as int?, + signatureHash: (map['signatureHash'] as String?) ?? '', + installTime: null, + canInstallSilently: (map['canInstallSilently'] as bool?) ?? false, + ); + } + return result; + } catch (e) { + if (kDebugMode) { + debugPrint('[InstalledPackagesSnapshot] Load failed: $e'); + } + return {}; + } + } +} diff --git a/lib/services/updates_service.dart b/lib/services/updates_service.dart index 1a5942b..1d6ac14 100644 --- a/lib/services/updates_service.dart +++ b/lib/services/updates_service.dart @@ -104,7 +104,7 @@ class CategorizedAppsNotifier extends Notifier { ), }, source: const LocalAndRemoteSource(relays: 'AppCatalog', stream: true), - subscriptionPrefix: 'updates', + subscriptionPrefix: 'app-updates', ), ); diff --git a/lib/widgets/app_detail_widgets.dart b/lib/widgets/app_detail_widgets.dart index 6025c8b..22855f1 100644 --- a/lib/widgets/app_detail_widgets.dart +++ b/lib/widgets/app_detail_widgets.dart @@ -735,7 +735,7 @@ class DebugVersionsSection extends HookConsumerWidget { tags: app.event.addressableIdTagMap, and: (release) => {release.latestMetadata.query(), release.latestAsset.query()}, source: LocalAndRemoteSource(relays: 'AppCatalog', stream: false), - subscriptionPrefix: 'debug-releases', + subscriptionPrefix: 'app-debug-releases', ), ); diff --git a/lib/widgets/app_stack_container.dart b/lib/widgets/app_stack_container.dart index 5b642dd..c5acd40 100644 --- a/lib/widgets/app_stack_container.dart +++ b/lib/widgets/app_stack_container.dart @@ -31,11 +31,7 @@ String? _extractIdentifier(String addressableId) { List _getPreviewIdentifiers(AppStack stack) { final rawTags = _getRawAppTagValues(stack).toList() ..shuffle(Random(stack.id.hashCode)); - return rawTags - .take(3) - .map(_extractIdentifier) - .whereType() - .toList(); + return rawTags.take(3).map(_extractIdentifier).whereType().toList(); } /// Sort app stacks: franzap/following first, then by recency @@ -67,10 +63,12 @@ List _sortStacks( others.sort((a, b) => b.event.createdAt.compareTo(a.event.createdAt)); return [...followed, ...others]; } else { - final franzapStacks = - stacks.where((s) => s.pubkey == kFranzapPubkey).toList(); - final otherStacks = - stacks.where((s) => s.pubkey != kFranzapPubkey).toList(); + final franzapStacks = stacks + .where((s) => s.pubkey == kFranzapPubkey) + .toList(); + final otherStacks = stacks + .where((s) => s.pubkey != kFranzapPubkey) + .toList(); franzapStacks.shuffle(random); otherStacks.sort((a, b) => b.event.createdAt.compareTo(a.event.createdAt)); @@ -198,8 +196,10 @@ class AppStackContainer extends HookConsumerWidget { scrollController.position.maxScrollExtent - 200) { // Load more if we haven't shown all stacks yet if (visibleCount.value < sortedStacks.length) { - visibleCount.value = (visibleCount.value + _kBatchSize) - .clamp(0, sortedStacks.length); + visibleCount.value = (visibleCount.value + _kBatchSize).clamp( + 0, + sortedStacks.length, + ); } } } @@ -231,9 +231,13 @@ class AppStackContainer extends HookConsumerWidget { if (col * 2 < displayedStacks.length) _StackCard( stack: displayedStacks[col * 2], - author: authorsMap[displayedStacks[col * 2].event.pubkey], - isAuthorLoading: isAuthorLoading(displayedStacks[col * 2].event.pubkey), - previewIdentifiers: stackPreviewIds[displayedStacks[col * 2].id] ?? [], + author: + authorsMap[displayedStacks[col * 2].event.pubkey], + isAuthorLoading: isAuthorLoading( + displayedStacks[col * 2].event.pubkey, + ), + previewIdentifiers: + stackPreviewIds[displayedStacks[col * 2].id] ?? [], appsMap: appsMap, ), // Bottom item @@ -241,9 +245,16 @@ class AppStackContainer extends HookConsumerWidget { const SizedBox(height: 10), _StackCard( stack: displayedStacks[col * 2 + 1], - author: authorsMap[displayedStacks[col * 2 + 1].event.pubkey], - isAuthorLoading: isAuthorLoading(displayedStacks[col * 2 + 1].event.pubkey), - previewIdentifiers: stackPreviewIds[displayedStacks[col * 2 + 1].id] ?? [], + author: + authorsMap[displayedStacks[col * 2 + 1] + .event + .pubkey], + isAuthorLoading: isAuthorLoading( + displayedStacks[col * 2 + 1].event.pubkey, + ), + previewIdentifiers: + stackPreviewIds[displayedStacks[col * 2 + 1].id] ?? + [], appsMap: appsMap, ), ], @@ -450,9 +461,7 @@ class _AppIconsRow extends StatelessWidget { aspectRatio: 1, child: Container( decoration: BoxDecoration( - color: Theme.of(context) - .colorScheme - .surfaceContainerHighest + color: Theme.of(context).colorScheme.surfaceContainerHighest .withValues(alpha: 0.8), borderRadius: BorderRadius.circular(10), ), @@ -481,10 +490,9 @@ class _AppIconsRow extends StatelessWidget { aspectRatio: 1, child: Container( decoration: BoxDecoration( - color: Theme.of(context) - .colorScheme - .surfaceContainerHighest - .withValues(alpha: 0.5), + color: Theme.of( + context, + ).colorScheme.surfaceContainerHighest.withValues(alpha: 0.5), borderRadius: BorderRadius.circular(10), ), ), @@ -520,10 +528,14 @@ class _AppIconTile extends StatelessWidget { fit: BoxFit.cover, fadeInDuration: const Duration(milliseconds: 200), placeholder: (_, __) => Container( - color: Theme.of(context).colorScheme.surfaceContainerHighest, + color: Theme.of( + context, + ).colorScheme.surfaceContainerHighest, ), errorWidget: (_, __, ___) => Container( - color: Theme.of(context).colorScheme.surfaceContainerHighest, + color: Theme.of( + context, + ).colorScheme.surfaceContainerHighest, child: const Icon( Icons.broken_image_outlined, size: 16, @@ -532,7 +544,9 @@ class _AppIconTile extends StatelessWidget { ), ) : Container( - color: Theme.of(context).colorScheme.surfaceContainerHighest, + color: Theme.of( + context, + ).colorScheme.surfaceContainerHighest, child: const Icon( Icons.apps_outlined, size: 16, diff --git a/lib/widgets/latest_releases_container.dart b/lib/widgets/latest_releases_container.dart index d9dd42c..968824f 100644 --- a/lib/widgets/latest_releases_container.dart +++ b/lib/widgets/latest_releases_container.dart @@ -321,7 +321,7 @@ class LatestReleasesNotifier extends StateNotifier { }, // NOTE: It must stream=true source: const LocalAndRemoteSource(relays: 'AppCatalog', stream: true), - subscriptionPrefix: 'latest', + subscriptionPrefix: 'app-latest', ), (previous, next) async { // Always mirror storage state and ensure olderApps don't duplicate the live head @@ -446,9 +446,9 @@ class _LatestReleasesStatusDot extends ConsumerWidget { final poolState = ref.watch(poolStateProvider); final subscriptions = poolState?.subscriptions ?? {}; - // Filter to only "latest" subscription + // Filter to only "app-latest" subscription final latestSubs = subscriptions.entries - .where((e) => e.key.startsWith('latest')) + .where((e) => e.key.startsWith('app-latest')) .map((e) => e.value); // Check relay status for latest subscription diff --git a/pubspec.lock b/pubspec.lock index 607adb5..96efb8a 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -581,26 +581,26 @@ packages: dependency: transitive description: name: leak_tracker - sha256: "6bb818ecbdffe216e81182c2f0714a2e62b593f4a4f13098713ff1685dfb6ab0" + sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" url: "https://pub.dev" source: hosted - version: "10.0.9" + version: "11.0.2" leak_tracker_flutter_testing: dependency: transitive description: name: leak_tracker_flutter_testing - sha256: f8b613e7e6a13ec79cfdc0e97638fddb3ab848452eff057653abd3edba760573 + sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" url: "https://pub.dev" source: hosted - version: "3.0.9" + version: "3.0.10" leak_tracker_testing: dependency: transitive description: name: leak_tracker_testing - sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3" + sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" url: "https://pub.dev" source: hosted - version: "3.0.1" + version: "3.0.2" lints: dependency: transitive description: @@ -653,10 +653,10 @@ packages: dependency: transitive description: name: meta - sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c + sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" url: "https://pub.dev" source: hosted - version: "1.16.0" + version: "1.17.0" mime: dependency: transitive description: @@ -1141,10 +1141,10 @@ packages: dependency: transitive description: name: test_api - sha256: fb31f383e2ee25fbbfe06b40fe21e1e458d14080e3c67e7ba0acfde4df4e0bbd + sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 url: "https://pub.dev" source: hosted - version: "0.7.4" + version: "0.7.7" timezone: dependency: transitive description: @@ -1261,10 +1261,10 @@ packages: dependency: transitive description: name: vector_math - sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803" + sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b url: "https://pub.dev" source: hosted - version: "2.1.4" + version: "2.2.0" vm_service: dependency: transitive description: diff --git a/spec/guidelines/ARCHITECTURE.md b/spec/guidelines/ARCHITECTURE.md index 5af9916..031bebe 100644 --- a/spec/guidelines/ARCHITECTURE.md +++ b/spec/guidelines/ARCHITECTURE.md @@ -95,4 +95,8 @@ onPressed: () async { } ``` +### Subscription prefix naming + +All queries using the `AppCatalog` relay group MUST prefix their `subscriptionPrefix` with `app-`. This is used in the backend. + For detailed API, see models/purplebase READMEs in pub cache.