merge master

This commit is contained in:
franzap
2026-01-26 23:40:29 -03:00
17 changed files with 428 additions and 245 deletions
+1
View File
@@ -10,6 +10,7 @@
analyzer: analyzer:
errors: errors:
dangling_library_doc_comments: ignore dangling_library_doc_comments: ignore
deprecated_member_use: ignore
include: package:flutter_lints/flutter.yaml include: package:flutter_lints/flutter.yaml
linter: linter:
+1 -1
View File
@@ -201,7 +201,7 @@ class ZapstoreHome extends StatelessWidget {
} }
final appInitializationProvider = FutureProvider<void>((ref) async { final appInitializationProvider = FutureProvider<void>((ref) async {
final dir = await getApplicationDocumentsDirectory(); final dir = await getApplicationSupportDirectory();
final dbPath = path.join(dir.path, 'zapstore.db'); final dbPath = path.join(dir.path, 'zapstore.db');
// Clear storage if requested from a clear all operation // Clear storage if requested from a clear all operation
+100 -126
View File
@@ -2,6 +2,7 @@ import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'package:async_button_builder/async_button_builder.dart'; 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:collection/collection.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
@@ -1379,12 +1380,14 @@ class _DataManagementSection extends ConsumerWidget {
color: Theme.of(context).colorScheme.error, color: Theme.of(context).colorScheme.error,
), ),
), ),
title: Text( title: AutoSizeText(
'Clear local storage', 'Clear local storage',
style: TextStyle( style: TextStyle(
color: Theme.of(context).colorScheme.error, color: Theme.of(context).colorScheme.error,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
), ),
maxLines: 1,
minFontSize: 12,
), ),
contentPadding: EdgeInsets.zero, contentPadding: EdgeInsets.zero,
onTap: () => _showClearAllDataDialog(context, ref), onTap: () => _showClearAllDataDialog(context, ref),
@@ -1403,7 +1406,7 @@ class _DataManagementSection extends ConsumerWidget {
children: [ children: [
Icon(Icons.warning, color: Theme.of(context).colorScheme.error), Icon(Icons.warning, color: Theme.of(context).colorScheme.error),
const SizedBox(width: 8), const SizedBox(width: 8),
const Text('Clear local storage'), Flexible(child: const Text('Clear local storage')),
], ],
), ),
content: const Text( content: const Text(
@@ -1477,80 +1480,111 @@ class _SavedAppsHeading extends ConsumerWidget {
return const SizedBox.shrink(); return const SizedBox.shrink();
} }
final savedAppsAsync = ref.watch(bookmarksProvider); // Always show heading when signed in
return Padding(
return savedAppsAsync.when( padding: const EdgeInsets.symmetric(horizontal: 16.0),
loading: () => const SizedBox.shrink(), child: Text('Saved Apps', style: context.textTheme.headlineSmall),
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<String>()
.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),
);
},
); );
} }
} }
class _SavedAppsSection extends HookConsumerWidget { class _SavedAppsSection extends ConsumerWidget {
const _SavedAppsSection(); const _SavedAppsSection();
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final signedInPubkey = ref.watch(Signer.activePubkeyProvider); 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); final savedAppsAsync = ref.watch(bookmarksProvider);
return savedAppsAsync.when( // Keep previous value during refresh, if available.
loading: () => _savedAppsLoadingCard(context), final addressableIds = savedAppsAsync.valueOrNull;
error: (_, __) => const SizedBox.shrink(),
data: (addressableIds) {
if (addressableIds.isEmpty) {
return const SizedBox.shrink();
}
final identifiers = addressableIds // Show loading only on first load (when no value exists yet).
.map((id) { if (addressableIds == null) {
final parts = id.split(':'); return _savedAppsLoadingCard(context);
return parts.length >= 3 ? parts[2] : null; }
})
.whereType<String>()
.toSet();
if (identifiers.isEmpty) { final identifiers = _toIdentifiers(addressableIds);
return const SizedBox.shrink(); return _SavedAppsList(identifiers: identifiers);
} }
return _SavedAppsList( Set<String> _toIdentifiers(Set<String> addressableIds) {
identifiers: identifiers, return addressableIds
hasLoadedOnce: hasLoadedOnce, .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<String> 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<App>(
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( return Card(
child: Padding( child: Padding(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
@@ -1562,75 +1596,15 @@ class _SavedAppsSection extends HookConsumerWidget {
), ),
); );
} }
}
class _SavedAppsList extends ConsumerWidget { Widget _emptyState(BuildContext context) {
const _SavedAppsList({ final theme = Theme.of(context);
required this.identifiers, return Padding(
required this.hasLoadedOnce, padding: const EdgeInsets.symmetric(horizontal: 16),
}); child: Text(
'No saved apps yet',
final Set<String> identifiers; style: theme.textTheme.bodyMedium?.copyWith(
final ValueNotifier<bool> hasLoadedOnce; color: theme.colorScheme.onSurface.withValues(alpha: 0.5),
@override
Widget build(BuildContext context, WidgetRef ref) {
final savedAppsState = ref.watch(
query<App>(
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(),
), ),
), ),
); );
+1 -1
View File
@@ -172,7 +172,7 @@ class _SearchResultsSection extends HookConsumerWidget {
// Force the search to hit the default relay group (relay.zapstore.dev) // Force the search to hit the default relay group (relay.zapstore.dev)
// so a connection appears in Debug Info when searching. // so a connection appears in Debug Info when searching.
source: const RemoteSource(relays: 'AppCatalog', stream: false), source: const RemoteSource(relays: 'AppCatalog', stream: false),
subscriptionPrefix: 'search-results', subscriptionPrefix: 'app-search-results',
), ),
); );
+2 -2
View File
@@ -40,9 +40,9 @@ class _ConnectionStatusIndicator extends ConsumerWidget {
final poolState = ref.watch(poolStateProvider); final poolState = ref.watch(poolStateProvider);
final subscriptions = poolState?.subscriptions ?? {}; final subscriptions = poolState?.subscriptions ?? {};
// Filter to only "updates" subscription // Filter to only "app-updates" subscription
final updatesSubs = subscriptions.entries final updatesSubs = subscriptions.entries
.where((e) => e.key.startsWith('updates')) .where((e) => e.key.startsWith('app-updates'))
.map((e) => e.value); .map((e) => e.value);
// Check relay status for updates subscription only // Check relay status for updates subscription only
+43 -26
View File
@@ -1,4 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
@@ -49,7 +50,7 @@ class UserScreen extends HookConsumerWidget {
), ),
}, },
source: const LocalAndRemoteSource(relays: 'AppCatalog', stream: false), source: const LocalAndRemoteSource(relays: 'AppCatalog', stream: false),
subscriptionPrefix: 'user-apps', subscriptionPrefix: 'app-user-apps',
), ),
); );
@@ -382,34 +383,50 @@ class _NpubRow extends StatelessWidget {
final abbreviatedNpub = final abbreviatedNpub =
'${npub.substring(0, 12)}...${npub.substring(npub.length - 8)}'; '${npub.substring(0, 12)}...${npub.substring(npub.length - 8)}';
return GestureDetector( return Row(
onTap: () => launchUrl( mainAxisSize: MainAxisSize.min,
Uri.parse('https://npub.world/$npub'), children: [
mode: LaunchMode.externalApplication, GestureDetector(
), onTap: () {
child: Row( Clipboard.setData(ClipboardData(text: npub));
mainAxisSize: MainAxisSize.min, },
children: [ child: Icon(
Icon( Icons.copy,
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, size: 12,
color: Theme.of(context).colorScheme.onSurfaceVariant, 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,
),
],
),
),
],
); );
} }
} }
+2 -2
View File
@@ -9,7 +9,7 @@ const _markerFileName = '.clear_on_restart';
/// Checks if storage should be cleared on this launch, and clears the marker. /// Checks if storage should be cleared on this launch, and clears the marker.
/// Call this BEFORE initializing storage. /// Call this BEFORE initializing storage.
Future<void> maybeClearStorage(String dbPath) async { Future<void> maybeClearStorage(String dbPath) async {
final dir = await getApplicationDocumentsDirectory(); final dir = await getApplicationSupportDirectory();
final marker = File('${dir.path}/$_markerFileName'); final marker = File('${dir.path}/$_markerFileName');
if (await marker.exists()) { if (await marker.exists()) {
final dbFile = File(dbPath); final dbFile = File(dbPath);
@@ -22,7 +22,7 @@ Future<void> maybeClearStorage(String dbPath) async {
/// Sets a marker file and triggers a native app restart. /// Sets a marker file and triggers a native app restart.
Future<void> restartApp() async { Future<void> restartApp() async {
final dir = await getApplicationDocumentsDirectory(); final dir = await getApplicationSupportDirectory();
final marker = File('${dir.path}/$_markerFileName'); final marker = File('${dir.path}/$_markerFileName');
await marker.create(); await marker.create();
await _channel.invokeMethod('restart'); await _channel.invokeMethod('restart');
+71 -43
View File
@@ -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_local_notifications/flutter_local_notifications.dart';
import 'package:flutter/widgets.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:models/models.dart'; import 'package:models/models.dart';
import 'package:path/path.dart' as path; 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:permission_handler/permission_handler.dart';
import 'package:purplebase/purplebase.dart'; import 'package:purplebase/purplebase.dart';
import 'package:workmanager/workmanager.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/dummy_package_manager.dart';
import 'package:zapstore/services/package_manager/package_manager.dart'; import 'package:zapstore/services/package_manager/package_manager.dart';
import 'package:zapstore/utils/extensions.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). /// This MUST be a top-level function (not a class method).
@pragma('vm:entry-point') @pragma('vm:entry-point')
void callbackDispatcher() { void callbackDispatcher() {
WidgetsFlutterBinding.ensureInitialized();
ui.DartPluginRegistrant.ensureInitialized();
Workmanager().executeTask((task, inputData) async { Workmanager().executeTask((task, inputData) async {
switch (task) { switch (task) {
case kBackgroundUpdateTaskName: case kBackgroundUpdateTaskName:
@@ -81,11 +85,8 @@ Future<bool> _performWeeklyCleanup() async {
try { try {
// Delete the file if it exists // Delete the file if it exists
final filePath = await task.filePath(); final filePath = await task.filePath();
final file = Directory(filePath).parent.listSync().firstWhere( final file = File(filePath);
(f) => f.path.endsWith(task.filename), if (await file.exists()) {
orElse: () => Directory(''),
);
if (file.path.isNotEmpty) {
await file.delete(); await file.delete();
} }
} catch (_) {} } catch (_) {}
@@ -105,14 +106,14 @@ Future<bool> _performWeeklyCleanup() async {
); );
if (await downloadDir.exists()) { if (await downloadDir.exists()) {
final files = downloadDir.listSync(); final entities = downloadDir.listSync();
final cutoff = DateTime.now().subtract(_staleDownloadThreshold); final cutoff = DateTime.now().subtract(_staleDownloadThreshold);
for (final file in files) { for (final entity in entities) {
if (file is! Directory) { if (entity is File) {
final stat = await file.stat(); final stat = await entity.stat();
if (stat.modified.isBefore(cutoff)) { if (stat.modified.isBefore(cutoff)) {
await file.delete(); await entity.delete();
} }
} }
} }
@@ -145,7 +146,7 @@ Future<bool> _checkForUpdatesInBackground(Set<String>? appCatalogRelays) async {
storageNotifierProvider.overrideWith(PurplebaseStorageNotifier.new), storageNotifierProvider.overrideWith(PurplebaseStorageNotifier.new),
packageManagerProvider.overrideWith( packageManagerProvider.overrideWith(
(ref) => Platform.isAndroid (ref) => Platform.isAndroid
? AndroidPackageManager(ref) ? BackgroundPackageManager(ref)
: DummyPackageManager(ref), : DummyPackageManager(ref),
), ),
], ],
@@ -153,7 +154,7 @@ Future<bool> _checkForUpdatesInBackground(Set<String>? appCatalogRelays) async {
try { try {
// Initialize Purplebase with same DB path as main app // 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'); final dbPath = path.join(dir.path, 'zapstore.db');
await container.read( await container.read(
@@ -188,18 +189,40 @@ Future<bool> _checkForUpdatesInBackground(Set<String>? appCatalogRelays) async {
source: const RemoteSource(relays: 'AppCatalog', stream: false), 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) { if (apps.isNotEmpty) {
final addressableIds = apps final releaseFilters = apps
.map((app) => app.event.addressableId) .map((app) => app.latestRelease.req?.filters.firstOrNull)
.toSet(); .nonNulls
await storage.query( .toList();
RequestFilter<Release>(tags: {'#a': addressableIds}).toRequest(), if (releaseFilters.isNotEmpty) {
source: const LocalAndRemoteSource( final List<Release> releases = await storage.query(
relays: 'AppCatalog', Request<Release>(releaseFilters),
stream: false, 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<FileMetadata>(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<SoftwareAsset>(assetFilters),
source: const RemoteSource(relays: 'AppCatalog', stream: false),
);
}
}
} }
// Re-query apps from local to ensure relationships are loaded // Re-query apps from local to ensure relationships are loaded
@@ -255,6 +278,7 @@ Future<void> _showUpdateNotification(
android: initializationSettingsAndroid, android: initializationSettingsAndroid,
); );
await flutterLocalNotificationsPlugin.initialize(initializationSettings); await flutterLocalNotificationsPlugin.initialize(initializationSettings);
await _ensureUpdateNotificationChannel(flutterLocalNotificationsPlugin);
// Build notification content // Build notification content
final title = updateCount == 1 final title = updateCount == 1
@@ -366,23 +390,7 @@ class BackgroundUpdateService {
// Navigation is handled by the app's normal launch flow // Navigation is handled by the app's normal launch flow
}, },
); );
await _ensureUpdateNotificationChannel(flutterLocalNotificationsPlugin);
// 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,
),
);
}
} }
/// Cancel background update checks /// Cancel background update checks
@@ -405,6 +413,26 @@ class BackgroundUpdateService {
} }
} }
Future<void> _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 /// Provider for the background update service
final backgroundUpdateServiceProvider = Provider<BackgroundUpdateService>( final backgroundUpdateServiceProvider = Provider<BackgroundUpdateService>(
BackgroundUpdateService.new, BackgroundUpdateService.new,
@@ -4,6 +4,7 @@ import 'dart:io';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:models/models.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'; import 'package:zapstore/services/package_manager/package_manager.dart';
/// Install status values from native side. /// Install status values from native side.
@@ -600,6 +601,7 @@ final class AndroidPackageManager extends PackageManager {
} }
state = state.copyWith(installed: {...packages, ...preserved}); state = state.copyWith(installed: {...packages, ...preserved});
await InstalledPackagesSnapshot.save(state.installed);
// Clear operations for apps where the installed version matches the target version // Clear operations for apps where the installed version matches the target version
// This catches installs that succeeded but we missed the event // This catches installs that succeeded but we missed the event
@@ -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<void> install(
String appId,
String filePath, {
required String expectedHash,
required int expectedSize,
required FileMetadata target,
}) {
throw UnsupportedError('Install not supported in background');
}
@override
Future<void> uninstall(String appId) {
throw UnsupportedError('Uninstall not supported in background');
}
@override
Future<void> launchApp(String appId) {
throw UnsupportedError('Launch not supported in background');
}
@override
Future<void> requestPermission() {
throw UnsupportedError('Permission not supported in background');
}
@override
Future<bool> hasPermission() async => false;
@override
Future<void> syncInstalledPackages() async {
final installed = await InstalledPackagesSnapshot.load();
state = state.copyWith(installed: installed);
}
}
@@ -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> _file() async {
final dir = await getApplicationSupportDirectory();
return File(path.join(dir.path, _fileName));
}
static Future<void> save(Map<String, PackageInfo> installed) async {
try {
final file = await _file();
final tmp = File('${file.path}.tmp');
final list = installed.values
.map(
(p) => <String, dynamic>{
'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<Map<String, PackageInfo>> 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 = <String, PackageInfo>{};
for (final item in installed) {
if (item is! Map) continue;
final map = Map<String, dynamic>.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 {};
}
}
}
+1 -1
View File
@@ -104,7 +104,7 @@ class CategorizedAppsNotifier extends Notifier<CategorizedApps> {
), ),
}, },
source: const LocalAndRemoteSource(relays: 'AppCatalog', stream: true), source: const LocalAndRemoteSource(relays: 'AppCatalog', stream: true),
subscriptionPrefix: 'updates', subscriptionPrefix: 'app-updates',
), ),
); );
+1 -1
View File
@@ -735,7 +735,7 @@ class DebugVersionsSection extends HookConsumerWidget {
tags: app.event.addressableIdTagMap, tags: app.event.addressableIdTagMap,
and: (release) => {release.latestMetadata.query(), release.latestAsset.query()}, and: (release) => {release.latestMetadata.query(), release.latestAsset.query()},
source: LocalAndRemoteSource(relays: 'AppCatalog', stream: false), source: LocalAndRemoteSource(relays: 'AppCatalog', stream: false),
subscriptionPrefix: 'debug-releases', subscriptionPrefix: 'app-debug-releases',
), ),
); );
+41 -27
View File
@@ -31,11 +31,7 @@ String? _extractIdentifier(String addressableId) {
List<String> _getPreviewIdentifiers(AppStack stack) { List<String> _getPreviewIdentifiers(AppStack stack) {
final rawTags = _getRawAppTagValues(stack).toList() final rawTags = _getRawAppTagValues(stack).toList()
..shuffle(Random(stack.id.hashCode)); ..shuffle(Random(stack.id.hashCode));
return rawTags return rawTags.take(3).map(_extractIdentifier).whereType<String>().toList();
.take(3)
.map(_extractIdentifier)
.whereType<String>()
.toList();
} }
/// Sort app stacks: franzap/following first, then by recency /// Sort app stacks: franzap/following first, then by recency
@@ -67,10 +63,12 @@ List<AppStack> _sortStacks(
others.sort((a, b) => b.event.createdAt.compareTo(a.event.createdAt)); others.sort((a, b) => b.event.createdAt.compareTo(a.event.createdAt));
return [...followed, ...others]; return [...followed, ...others];
} else { } else {
final franzapStacks = final franzapStacks = stacks
stacks.where((s) => s.pubkey == kFranzapPubkey).toList(); .where((s) => s.pubkey == kFranzapPubkey)
final otherStacks = .toList();
stacks.where((s) => s.pubkey != kFranzapPubkey).toList(); final otherStacks = stacks
.where((s) => s.pubkey != kFranzapPubkey)
.toList();
franzapStacks.shuffle(random); franzapStacks.shuffle(random);
otherStacks.sort((a, b) => b.event.createdAt.compareTo(a.event.createdAt)); otherStacks.sort((a, b) => b.event.createdAt.compareTo(a.event.createdAt));
@@ -198,8 +196,10 @@ class AppStackContainer extends HookConsumerWidget {
scrollController.position.maxScrollExtent - 200) { scrollController.position.maxScrollExtent - 200) {
// Load more if we haven't shown all stacks yet // Load more if we haven't shown all stacks yet
if (visibleCount.value < sortedStacks.length) { if (visibleCount.value < sortedStacks.length) {
visibleCount.value = (visibleCount.value + _kBatchSize) visibleCount.value = (visibleCount.value + _kBatchSize).clamp(
.clamp(0, sortedStacks.length); 0,
sortedStacks.length,
);
} }
} }
} }
@@ -231,9 +231,13 @@ class AppStackContainer extends HookConsumerWidget {
if (col * 2 < displayedStacks.length) if (col * 2 < displayedStacks.length)
_StackCard( _StackCard(
stack: displayedStacks[col * 2], stack: displayedStacks[col * 2],
author: authorsMap[displayedStacks[col * 2].event.pubkey], author:
isAuthorLoading: isAuthorLoading(displayedStacks[col * 2].event.pubkey), authorsMap[displayedStacks[col * 2].event.pubkey],
previewIdentifiers: stackPreviewIds[displayedStacks[col * 2].id] ?? [], isAuthorLoading: isAuthorLoading(
displayedStacks[col * 2].event.pubkey,
),
previewIdentifiers:
stackPreviewIds[displayedStacks[col * 2].id] ?? [],
appsMap: appsMap, appsMap: appsMap,
), ),
// Bottom item // Bottom item
@@ -241,9 +245,16 @@ class AppStackContainer extends HookConsumerWidget {
const SizedBox(height: 10), const SizedBox(height: 10),
_StackCard( _StackCard(
stack: displayedStacks[col * 2 + 1], stack: displayedStacks[col * 2 + 1],
author: authorsMap[displayedStacks[col * 2 + 1].event.pubkey], author:
isAuthorLoading: isAuthorLoading(displayedStacks[col * 2 + 1].event.pubkey), authorsMap[displayedStacks[col * 2 + 1]
previewIdentifiers: stackPreviewIds[displayedStacks[col * 2 + 1].id] ?? [], .event
.pubkey],
isAuthorLoading: isAuthorLoading(
displayedStacks[col * 2 + 1].event.pubkey,
),
previewIdentifiers:
stackPreviewIds[displayedStacks[col * 2 + 1].id] ??
[],
appsMap: appsMap, appsMap: appsMap,
), ),
], ],
@@ -450,9 +461,7 @@ class _AppIconsRow extends StatelessWidget {
aspectRatio: 1, aspectRatio: 1,
child: Container( child: Container(
decoration: BoxDecoration( decoration: BoxDecoration(
color: Theme.of(context) color: Theme.of(context).colorScheme.surfaceContainerHighest
.colorScheme
.surfaceContainerHighest
.withValues(alpha: 0.8), .withValues(alpha: 0.8),
borderRadius: BorderRadius.circular(10), borderRadius: BorderRadius.circular(10),
), ),
@@ -481,10 +490,9 @@ class _AppIconsRow extends StatelessWidget {
aspectRatio: 1, aspectRatio: 1,
child: Container( child: Container(
decoration: BoxDecoration( decoration: BoxDecoration(
color: Theme.of(context) color: Theme.of(
.colorScheme context,
.surfaceContainerHighest ).colorScheme.surfaceContainerHighest.withValues(alpha: 0.5),
.withValues(alpha: 0.5),
borderRadius: BorderRadius.circular(10), borderRadius: BorderRadius.circular(10),
), ),
), ),
@@ -520,10 +528,14 @@ class _AppIconTile extends StatelessWidget {
fit: BoxFit.cover, fit: BoxFit.cover,
fadeInDuration: const Duration(milliseconds: 200), fadeInDuration: const Duration(milliseconds: 200),
placeholder: (_, __) => Container( placeholder: (_, __) => Container(
color: Theme.of(context).colorScheme.surfaceContainerHighest, color: Theme.of(
context,
).colorScheme.surfaceContainerHighest,
), ),
errorWidget: (_, __, ___) => Container( errorWidget: (_, __, ___) => Container(
color: Theme.of(context).colorScheme.surfaceContainerHighest, color: Theme.of(
context,
).colorScheme.surfaceContainerHighest,
child: const Icon( child: const Icon(
Icons.broken_image_outlined, Icons.broken_image_outlined,
size: 16, size: 16,
@@ -532,7 +544,9 @@ class _AppIconTile extends StatelessWidget {
), ),
) )
: Container( : Container(
color: Theme.of(context).colorScheme.surfaceContainerHighest, color: Theme.of(
context,
).colorScheme.surfaceContainerHighest,
child: const Icon( child: const Icon(
Icons.apps_outlined, Icons.apps_outlined,
size: 16, size: 16,
+3 -3
View File
@@ -321,7 +321,7 @@ class LatestReleasesNotifier extends StateNotifier<LatestReleasesState> {
}, },
// NOTE: It must stream=true // NOTE: It must stream=true
source: const LocalAndRemoteSource(relays: 'AppCatalog', stream: true), source: const LocalAndRemoteSource(relays: 'AppCatalog', stream: true),
subscriptionPrefix: 'latest', subscriptionPrefix: 'app-latest',
), ),
(previous, next) async { (previous, next) async {
// Always mirror storage state and ensure olderApps don't duplicate the live head // 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 poolState = ref.watch(poolStateProvider);
final subscriptions = poolState?.subscriptions ?? {}; final subscriptions = poolState?.subscriptions ?? {};
// Filter to only "latest" subscription // Filter to only "app-latest" subscription
final latestSubs = subscriptions.entries final latestSubs = subscriptions.entries
.where((e) => e.key.startsWith('latest')) .where((e) => e.key.startsWith('app-latest'))
.map((e) => e.value); .map((e) => e.value);
// Check relay status for latest subscription // Check relay status for latest subscription
+12 -12
View File
@@ -581,26 +581,26 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: leak_tracker name: leak_tracker
sha256: "6bb818ecbdffe216e81182c2f0714a2e62b593f4a4f13098713ff1685dfb6ab0" sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "10.0.9" version: "11.0.2"
leak_tracker_flutter_testing: leak_tracker_flutter_testing:
dependency: transitive dependency: transitive
description: description:
name: leak_tracker_flutter_testing name: leak_tracker_flutter_testing
sha256: f8b613e7e6a13ec79cfdc0e97638fddb3ab848452eff057653abd3edba760573 sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.0.9" version: "3.0.10"
leak_tracker_testing: leak_tracker_testing:
dependency: transitive dependency: transitive
description: description:
name: leak_tracker_testing name: leak_tracker_testing
sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3" sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.0.1" version: "3.0.2"
lints: lints:
dependency: transitive dependency: transitive
description: description:
@@ -653,10 +653,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: meta name: meta
sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.16.0" version: "1.17.0"
mime: mime:
dependency: transitive dependency: transitive
description: description:
@@ -1141,10 +1141,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: test_api name: test_api
sha256: fb31f383e2ee25fbbfe06b40fe21e1e458d14080e3c67e7ba0acfde4df4e0bbd sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.7.4" version: "0.7.7"
timezone: timezone:
dependency: transitive dependency: transitive
description: description:
@@ -1261,10 +1261,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: vector_math name: vector_math
sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803" sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.1.4" version: "2.2.0"
vm_service: vm_service:
dependency: transitive dependency: transitive
description: description:
+4
View File
@@ -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. For detailed API, see models/purplebase READMEs in pub cache.