diff --git a/lib/router.dart b/lib/router.dart index dcefc3d..f3059c6 100644 --- a/lib/router.dart +++ b/lib/router.dart @@ -11,7 +11,6 @@ import 'package:zapstore/screens/user_screen.dart'; import 'package:zapstore/screens/search_screen.dart'; import 'package:zapstore/screens/updates_screen.dart'; import 'package:zapstore/screens/profile_screen.dart'; -import 'package:zapstore/screens/restore_installed_apps_screen.dart'; import 'package:zapstore/services/package_manager/package_manager.dart'; /// Root paths for each navigation branch (used for back navigation handling) @@ -84,25 +83,6 @@ GoRoute _stackDetailRoute() { ); } -/// Helper to build restore installed apps route -GoRoute _restoreRoute() { - return GoRoute( - path: 'restore', - redirect: (context, state) { - final extra = state.extra; - if (extra is! List || extra.isEmpty) return '/profile'; - return null; - }, - pageBuilder: (context, state) { - final ids = state.extra as List; - return _noTransitionPage( - state: state, - child: RestoreInstalledAppsScreen(addressableIds: ids), - ); - }, - ); -} - /// Helper to build user route GoRoute _userRoute() { return GoRoute( @@ -180,7 +160,6 @@ final routerProvider = Provider((ref) { _appDetailRoute(), _stackDetailRoute(), _userRoute(), - _restoreRoute(), ], ), ], diff --git a/lib/screens/main_scaffold.dart b/lib/screens/main_scaffold.dart index 56dbd00..127e201 100644 --- a/lib/screens/main_scaffold.dart +++ b/lib/screens/main_scaffold.dart @@ -3,6 +3,7 @@ import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:models/models.dart'; import 'package:zapstore/router.dart'; +import 'package:zapstore/services/installed_apps_backup_service.dart'; import 'package:zapstore/services/updates_service.dart'; import 'package:zapstore/widgets/common/badges.dart'; import '../widgets/common/profile_avatar.dart'; @@ -118,6 +119,7 @@ class MobileScaffold extends ConsumerWidget { // Watch categorized to keep poller alive (poller is watched by categorized) final categorized = ref.watch(categorizedUpdatesProvider); final poller = ref.watch(updatePollerProvider); + ref.watch(installedAppsBackupListenerProvider); final updateCount = ref.watch(updateCountProvider); final isLoadingUpdates = categorized.showSkeleton || poller.isChecking; @@ -308,6 +310,7 @@ class DesktopScaffold extends ConsumerWidget { // Watch categorized to keep poller alive (poller is watched by categorized) final categorized = ref.watch(categorizedUpdatesProvider); final poller = ref.watch(updatePollerProvider); + ref.watch(installedAppsBackupListenerProvider); final updateCount = ref.watch(updateCountProvider); final isLoadingUpdates = categorized.showSkeleton || poller.isChecking; diff --git a/lib/screens/profile_screen.dart b/lib/screens/profile_screen.dart index 6ecff95..d83c7bb 100644 --- a/lib/screens/profile_screen.dart +++ b/lib/screens/profile_screen.dart @@ -15,6 +15,7 @@ import 'package:url_launcher/url_launcher.dart'; import 'package:purplebase/purplebase.dart'; import 'package:zapstore/main.dart'; import 'package:zapstore/services/bookmarks_service.dart'; +import 'package:zapstore/screens/restore_installed_apps_screen.dart'; import 'package:zapstore/services/installed_apps_backup_service.dart'; import 'package:zapstore/services/package_manager/package_manager.dart'; import 'package:zapstore/utils/extensions.dart'; @@ -186,17 +187,11 @@ class _AuthenticationSection extends ConsumerWidget { FilledButton.icon( onPressed: () => _signOut(context, ref), icon: const Icon(Icons.logout, size: 14), - label: const Text( - 'Sign Out', - style: TextStyle(fontSize: 12), - ), + label: const Text('Sign Out', style: TextStyle(fontSize: 12)), style: FilledButton.styleFrom( backgroundColor: Colors.red.shade900, foregroundColor: Colors.white, - padding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 6, - ), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), minimumSize: const Size(0, 0), tapTargetSize: MaterialTapTargetSize.shrinkWrap, ), @@ -1366,10 +1361,7 @@ class _InstalledAppsTile extends ConsumerWidget { await onAction(context, ref); } catch (e) { if (context.mounted) { - context.showError( - 'Operation failed', - technicalDetails: '$e', - ); + context.showError('Operation failed', technicalDetails: '$e'); } } }, @@ -1381,10 +1373,9 @@ class _InstalledAppsTile extends ConsumerWidget { return ListTile( leading: CircleAvatar( radius: 18, - backgroundColor: Theme.of(context) - .colorScheme - .primary - .withValues(alpha: 0.12), + backgroundColor: Theme.of( + context, + ).colorScheme.primary.withValues(alpha: 0.12), child: isLoading ? SizedBox( width: 20, @@ -1394,17 +1385,16 @@ class _InstalledAppsTile extends ConsumerWidget { color: Theme.of(context).colorScheme.primary, ), ) - : Icon( - icon, - color: Theme.of(context).colorScheme.primary, - ), + : Icon(icon, color: Theme.of(context).colorScheme.primary), ), title: AutoSizeText( title, style: TextStyle( color: isSignedIn ? Theme.of(context).colorScheme.onSurface - : Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.5), + : Theme.of( + context, + ).colorScheme.onSurface.withValues(alpha: 0.5), fontWeight: FontWeight.w600, ), maxLines: 1, @@ -1424,6 +1414,10 @@ class _DataManagementSection extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final pubkey = ref.watch(Signer.activePubkeyProvider); + final backupEnabled = + ref.watch(backupSettingsProvider).valueOrNull ?? false; + return Card( child: Padding( padding: const EdgeInsets.all(16), @@ -1435,6 +1429,23 @@ class _DataManagementSection extends ConsumerWidget { style: Theme.of(context).textTheme.titleMedium, ), const SizedBox(height: 16), + if (pubkey != null) + SwitchListTile( + secondary: Icon( + Icons.cloud_upload_outlined, + color: Theme.of(context).colorScheme.primary, + ), + title: const Text('Enable installed apps backup'), + subtitle: const Text( + 'Automatically syncs your installed apps list to Nostr after each install or uninstall.', + ), + value: backupEnabled, + onChanged: (value) { + ref.read(backupSettingsProvider.notifier).setEnabled(value); + }, + contentPadding: EdgeInsets.zero, + ), + if (pubkey != null) const SizedBox(height: 8), _InstalledAppsTile( icon: Icons.backup, title: 'Backup installed apps', @@ -1459,7 +1470,30 @@ class _DataManagementSection extends ConsumerWidget { context.showInfo('No backup found'); return; } - context.push('/profile/restore', extra: ids); + await showDialog( + context: context, + useRootNavigator: true, + barrierDismissible: false, + builder: (dialogContext) => PopScope( + canPop: false, + onPopInvokedWithResult: (didPop, _) { + if (!didPop) { + Navigator.of(dialogContext, rootNavigator: true).pop(); + } + }, + child: Dialog( + backgroundColor: AppColors.darkBackground, + insetPadding: EdgeInsets.zero, + child: RestoreInstalledAppsScreen( + addressableIds: ids, + onClose: () => Navigator.of( + dialogContext, + rootNavigator: true, + ).pop(), + ), + ), + ), + ); }, ), ListTile( diff --git a/lib/screens/restore_installed_apps_screen.dart b/lib/screens/restore_installed_apps_screen.dart index 4bb3f1d..0b324e6 100644 --- a/lib/screens/restore_installed_apps_screen.dart +++ b/lib/screens/restore_installed_apps_screen.dart @@ -9,16 +9,31 @@ import 'package:zapstore/widgets/batch_progress_banner.dart'; import 'package:zapstore/widgets/common/badges.dart'; import 'package:zapstore/theme.dart'; +/// Shared AppBar for restore screen (empty, loading, error, content). +PreferredSizeWidget _restoreAppBar({VoidCallback? onClose}) { + return AppBar( + title: const Text('Restore from backup'), + leading: onClose != null + ? IconButton(icon: const Icon(Icons.close), onPressed: onClose) + : null, + automaticallyImplyLeading: onClose == null, + ); +} + /// Full-screen restore from backup. Mirrors [AppStackScreen] pattern: /// query with nested relations, skeleton on loading, sort uninstalled first. class RestoreInstalledAppsScreen extends HookConsumerWidget { const RestoreInstalledAppsScreen({ super.key, required this.addressableIds, + this.onClose, }); final List addressableIds; + /// Called when user closes the modal. Required when shown as overlay. + final VoidCallback? onClose; + @override Widget build(BuildContext context, WidgetRef ref) { final appIdentifiers = addressableIds @@ -28,15 +43,9 @@ class RestoreInstalledAppsScreen extends HookConsumerWidget { if (appIdentifiers.isEmpty) { return Scaffold( - appBar: AppBar( - title: const Text('Restore from backup'), - automaticallyImplyLeading: false, - ), + appBar: _restoreAppBar(onClose: onClose), body: Center( - child: Text( - 'No apps to restore', - style: context.textTheme.bodyLarge, - ), + child: Text('No apps to restore', style: context.textTheme.bodyLarge), ), ); } @@ -64,38 +73,33 @@ class RestoreInstalledAppsScreen extends HookConsumerWidget { return switch (appsState) { StorageLoading() => Scaffold( - appBar: AppBar( - title: const Text('Restore from backup'), - automaticallyImplyLeading: false, - ), - body: SingleChildScrollView( - padding: const EdgeInsets.only(top: 8, bottom: 32), - child: _RestoreSkeleton(), - ), + appBar: _restoreAppBar(onClose: onClose), + body: SingleChildScrollView( + padding: const EdgeInsets.only(top: 8, bottom: 32), + child: _RestoreSkeleton(), ), + ), StorageError(:final exception) => Scaffold( - appBar: AppBar( - title: const Text('Restore from backup'), - automaticallyImplyLeading: false, - ), - body: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Icon(Icons.error_outline, size: 64), - const SizedBox(height: 12), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 24), - child: Text(exception.toString(), textAlign: TextAlign.center), - ), - ], - ), + appBar: _restoreAppBar(onClose: onClose), + body: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.error_outline, size: 64), + const SizedBox(height: 12), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: Text(exception.toString(), textAlign: TextAlign.center), + ), + ], ), ), + ), StorageData(:final models) => _RestoreContent( - allApps: models, - appIdentifiers: appIdentifiers, - ), + allApps: models, + appIdentifiers: appIdentifiers, + onClose: onClose, + ), }; } } @@ -104,15 +108,29 @@ class _RestoreContent extends HookConsumerWidget { const _RestoreContent({ required this.allApps, required this.appIdentifiers, + this.onClose, }); final List allApps; final Set appIdentifiers; + final VoidCallback? onClose; @override Widget build(BuildContext context, WidgetRef ref) { - final installedIds = - ref.watch(packageManagerProvider).installed.keys.toSet(); + final installedIds = ref + .watch(packageManagerProvider) + .installed + .keys + .toSet(); + final completedOperationIds = ref.watch( + packageManagerProvider.select( + (s) => s.operations.entries + .where((entry) => entry.value is Completed) + .map((entry) => entry.key) + .toSet(), + ), + ); + final effectiveInstalledIds = {...installedIds, ...completedOperationIds}; final appsMap = {for (final app in allApps) app.identifier: app}; @@ -120,26 +138,27 @@ class _RestoreContent extends HookConsumerWidget { final toInstall = appIdentifiers .map((id) => appsMap[id]) .whereType() - .where((app) => !installedIds.contains(app.identifier)) + .where((app) => !effectiveInstalledIds.contains(app.identifier)) .toList(); final alreadyInstalled = appIdentifiers .map((id) => appsMap[id]) .whereType() - .where((app) => installedIds.contains(app.identifier)) + .where((app) => effectiveInstalledIds.contains(app.identifier)) .toList(); return Scaffold( - appBar: AppBar( - title: const Text('Restore from backup'), - automaticallyImplyLeading: false, - ), + appBar: _restoreAppBar(onClose: onClose), body: ListView( padding: const EdgeInsets.only(top: 8, bottom: 32), children: [ // "Install All" button — same style as UpdateAllRow in updates screen if (toInstall.length > 1) - UpdateAllRow(allUpdates: toInstall, label: 'Install All'), + UpdateAllRow( + allUpdates: toInstall, + label: 'Install All', + installSource: InstallSource.restore, + ), // "To install" section if (toInstall.isNotEmpty) @@ -152,11 +171,14 @@ class _RestoreContent extends HookConsumerWidget { (app) => AppCard( key: ValueKey('restore_${app.identifier}'), app: app, + installSource: InstallSource.restore, showUpdateButton: true, + showInstallWhenNotInstalled: true, showUpdateArrow: false, showSignedBy: false, showZapEncouragement: false, showDescription: false, + onTap: () {}, // No navigation; Install button stays tappable ), ), @@ -177,6 +199,7 @@ class _RestoreContent extends HookConsumerWidget { showSignedBy: false, showZapEncouragement: false, showDescription: false, + ignorePointer: true, // Informational only; no tap ), ), ], @@ -189,8 +212,9 @@ class _RestoreContent extends HookConsumerWidget { child: Text( 'No apps found in backup', style: context.textTheme.bodyLarge?.copyWith( - color: - Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.6), + color: Theme.of( + context, + ).colorScheme.onSurface.withValues(alpha: 0.6), ), ), ), diff --git a/lib/services/installed_apps_backup_service.dart b/lib/services/installed_apps_backup_service.dart index edf8737..d82fc5c 100644 --- a/lib/services/installed_apps_backup_service.dart +++ b/lib/services/installed_apps_backup_service.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:convert'; import 'package:flutter/foundation.dart'; @@ -5,8 +6,135 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:models/models.dart'; import 'package:zapstore/constants/app_constants.dart'; import 'package:zapstore/services/package_manager/package_manager.dart'; +import 'package:zapstore/services/secure_storage_service.dart'; import 'package:zapstore/utils/extensions.dart'; +const _logPrefix = '[InstalledAppsBackup]'; + +/// Persisted setting for auto-backup (off by default). Survives sign-out. +class BackupSettingsNotifier extends AsyncNotifier { + @override + Future build() async { + final storage = ref.read(secureStorageServiceProvider); + return storage.getBackupEnabled(); + } + + Future setEnabled(bool enabled) async { + await ref.read(secureStorageServiceProvider).setBackupEnabled(enabled); + ref.invalidateSelf(); + } +} + +final backupSettingsProvider = + AsyncNotifierProvider( + BackupSettingsNotifier.new, +); + +const _backupDebounceDuration = Duration(seconds: 3); + +/// Listens to batch completion and uninstalls; triggers backup when enabled and signed in. +/// Debounces rapid triggers (e.g. multiple uninstalls) to avoid repeated sign prompts. +/// Watched by MainScaffold to keep alive. +final installedAppsBackupListenerProvider = Provider((ref) { + Timer? debounceTimer; + var activeBatchAppIds = {}; + Set? baselineInstalledKeys; + + void scheduleBackup() { + debounceTimer?.cancel(); + debounceTimer = Timer(_backupDebounceDuration, () { + debounceTimer = null; + _maybeBackup(ref); + }); + } + + ref.listen(batchProgressProvider, (prev, next) { + if (next?.hasInProgress == true) { + activeBatchAppIds = ref + .read(packageManagerProvider) + .operations + .entries + .where((entry) => entry.value.isInProgress) + .map((entry) => entry.key) + .toSet(); + } + + final hasBatchCompleted = + prev?.hasInProgress == true && next?.isAllComplete == true; + if (hasBatchCompleted) { + if (baselineInstalledKeys == null) return; + if (_isRestoreOnlyBatch(ref, activeBatchAppIds)) { + activeBatchAppIds = {}; + baselineInstalledKeys = + ref.read(packageManagerProvider).installed.keys.toSet(); + return; + } + baselineInstalledKeys = + ref.read(packageManagerProvider).installed.keys.toSet(); + scheduleBackup(); + activeBatchAppIds = {}; + } + }); + + ref.listen( + packageManagerProvider.select((s) => s.isScanning), + (prev, next) { + if (prev == true && next == false) { + final keys = ref.read(packageManagerProvider).installed.keys.toSet(); + baselineInstalledKeys ??= keys; + } + }, + ); + + ref.listen( + packageManagerProvider.select((s) => s.installed.length), + (prev, next) { + if (baselineInstalledKeys == null || prev == null || prev == next) return; + final currentKeys = + ref.read(packageManagerProvider).installed.keys.toSet(); + final added = currentKeys.difference(baselineInstalledKeys!); + final removed = baselineInstalledKeys!.difference(currentKeys); + if (added.isEmpty && removed.isEmpty) return; + // Skip isScanning only for additions (avoids false triggers during sync). + // Removals are always from user uninstall — must trigger backup. + if (removed.isEmpty && ref.read(packageManagerProvider).isScanning) return; + final batch = ref.read(batchProgressProvider); + if (batch != null && batch.hasInProgress) return; + baselineInstalledKeys = currentKeys; + scheduleBackup(); + }, + ); + + ref.onDispose(() { + debounceTimer?.cancel(); + }); +}); + +void _maybeBackup(Ref ref) { + final enabledAsync = ref.read(backupSettingsProvider); + final enabled = enabledAsync.valueOrNull ?? false; + final pubkey = ref.read(Signer.activePubkeyProvider); + + if (!enabled || pubkey == null) return; + + unawaited( + _backupInstalledApps(ref).then((count) { + debugPrint('$_logPrefix Backup completed: $count apps'); + }).catchError((e, st) { + debugPrint('$_logPrefix Backup error: $e'); + debugPrint('$_logPrefix $st'); + }), + ); +} + +bool _isRestoreOnlyBatch(Ref ref, Set appIds) { + if (appIds.isEmpty) return false; + final pm = ref.read(packageManagerProvider.notifier); + return appIds.every( + (appId) => pm.getOperationSource(appId) == InstallSource.restore, + ); +} + /// Provider that exposes the backup function with correct Ref. final backupInstalledAppsProvider = Provider Function()>((ref) { return () => _backupInstalledApps(ref); @@ -116,7 +244,7 @@ Future> _restoreInstalledApps(Ref ref) async { ); addressableIds = (jsonDecode(decryptedContent) as List).cast(); } catch (e) { - debugPrint('[InstalledAppsBackup] Restore decrypt failed: $e'); + debugPrint('$_logPrefix Restore decrypt failed: $e'); return []; } diff --git a/lib/services/package_manager/android_package_manager.dart b/lib/services/package_manager/android_package_manager.dart index abe899e..67b4f49 100644 --- a/lib/services/package_manager/android_package_manager.dart +++ b/lib/services/package_manager/android_package_manager.dart @@ -358,11 +358,15 @@ final class AndroidPackageManager extends PackageManager { } @override - void setOperation(String appId, InstallOperation op) { + void setOperation( + String appId, + InstallOperation op, { + InstallSource? source, + }) { // Clear from aborted orphans when a new operation is set, // so we can abort again if it becomes orphaned in a future session. _abortedOrphans.remove(appId); - super.setOperation(appId, op); + super.setOperation(appId, op, source: source); } @override diff --git a/lib/services/package_manager/install_operation.dart b/lib/services/package_manager/install_operation.dart index 7af1843..edb9b06 100644 --- a/lib/services/package_manager/install_operation.dart +++ b/lib/services/package_manager/install_operation.dart @@ -1,5 +1,18 @@ import 'package:models/models.dart'; +/// Source that initiated an install/download operation. +enum InstallSource { + normal, + restore; + + static InstallSource fromMetadata(String? value) { + return switch (value) { + 'restore' => InstallSource.restore, + _ => InstallSource.normal, + }; + } +} + /// Stale download threshold - operations older than this will be cleaned up const staleOperationThreshold = Duration(days: 7); diff --git a/lib/services/package_manager/installed_packages_snapshot.dart b/lib/services/package_manager/installed_packages_snapshot.dart index 043af6f..41a3b63 100644 --- a/lib/services/package_manager/installed_packages_snapshot.dart +++ b/lib/services/package_manager/installed_packages_snapshot.dart @@ -17,7 +17,7 @@ class InstalledPackagesSnapshot { static Future save(Map installed) async { try { final file = await _file(); - final tmp = File('${file.path}.tmp'); + await Directory(path.dirname(file.path)).create(recursive: true); final list = installed.values .map( (p) => { @@ -35,11 +35,7 @@ class InstalledPackagesSnapshot { 'savedAt': DateTime.now().millisecondsSinceEpoch, 'installed': list, }); - await tmp.writeAsString(payload, flush: true); - if (await file.exists()) { - await file.delete(); - } - await tmp.rename(file.path); + await file.writeAsString(payload, flush: true); } catch (e) { // Best-effort snapshot only. if (kDebugMode) { diff --git a/lib/services/package_manager/package_manager.dart b/lib/services/package_manager/package_manager.dart index 57f8b45..25cc171 100644 --- a/lib/services/package_manager/package_manager.dart +++ b/lib/services/package_manager/package_manager.dart @@ -134,6 +134,9 @@ abstract class PackageManager extends StateNotifier { /// Lock to prevent concurrent queue processing bool _processingQueue = false; + /// Tracks the source for each app operation (normal vs restore). + final Map _operationSourceByAppId = {}; + /// Dynamic max concurrent downloads based on device capability int get maxConcurrentDownloads => DeviceCapabilitiesCache.capabilities.maxConcurrentDownloads; @@ -294,16 +297,30 @@ abstract class PackageManager extends StateNotifier { .map((e) => e.key) .toList(); + InstallSource getOperationSource(String appId) { + return _operationSourceByAppId[appId] ?? InstallSource.normal; + } + // ═══════════════════════════════════════════════════════════════════════════ // STATE MANAGEMENT (Public for subclass use) // ═══════════════════════════════════════════════════════════════════════════ - void setOperation(String appId, InstallOperation op) { + void setOperation( + String appId, + InstallOperation op, { + InstallSource? source, + }) { + if (source != null) { + _operationSourceByAppId[appId] = source; + } else { + _operationSourceByAppId.putIfAbsent(appId, () => InstallSource.normal); + } state = state.copyWith(operations: {...state.operations, appId: op}); _updateWatchdogTimer(); } void clearOperation(String appId) { + _operationSourceByAppId.remove(appId); state = state.copyWith( operations: Map.from(state.operations)..remove(appId), ); @@ -314,11 +331,20 @@ abstract class PackageManager extends StateNotifier { /// Called after the batch completion display timeout. /// Terminal states: Completed, OperationFailed, InstallCancelled. void clearCompletedOperations() { + final completedIds = state.operations.entries + .where( + (entry) => + entry.value is Completed || + entry.value is OperationFailed || + entry.value is InstallCancelled, + ) + .map((entry) => entry.key) + .toSet(); final remaining = Map.of(state.operations) - ..removeWhere( - (_, op) => - op is Completed || op is OperationFailed || op is InstallCancelled, - ); + ..removeWhere((appId, _) => completedIds.contains(appId)); + for (final appId in completedIds) { + _operationSourceByAppId.remove(appId); + } state = state.copyWith(operations: remaining); _updateWatchdogTimer(); } @@ -333,6 +359,7 @@ abstract class PackageManager extends StateNotifier { String appId, FileMetadata target, { String? displayName, + InstallSource source = InstallSource.normal, }) async { await _ensureDownloaderReady(); final existing = getOperation(appId); @@ -366,6 +393,7 @@ abstract class PackageManager extends StateNotifier { setOperation( appId, DownloadQueued(target: target, displayName: displayName), + source: source, ); // Process queue to potentially start this download immediately @@ -376,7 +404,14 @@ abstract class PackageManager extends StateNotifier { /// Queue multiple downloads at once - staggered to prevent UI flood. /// This is the primary method for "Update All" functionality. Future queueDownloads( - List<({String appId, FileMetadata target, String? displayName})> items, + List< + ({ + String appId, + FileMetadata target, + String? displayName, + InstallSource source, + }) + > items, ) async { await _ensureDownloaderReady(); @@ -404,6 +439,7 @@ abstract class PackageManager extends StateNotifier { setOperation( item.appId, DownloadQueued(target: item.target, displayName: item.displayName), + source: item.source, ); } @@ -718,6 +754,7 @@ abstract class PackageManager extends StateNotifier { appId, target.id, isCdnRetry: isCdnRetry, + source: getOperationSource(appId), ); final task = DownloadTask( @@ -771,9 +808,10 @@ abstract class PackageManager extends StateNotifier { final metaData = update.task.metaData; bool isCdnRetry = false; if (metaData.isNotEmpty) { - final (decodedAppId, _, cdnRetry) = _parseTaskMetadata(metaData); + final (decodedAppId, _, cdnRetry, source) = _parseTaskMetadata(metaData); isCdnRetry = cdnRetry; if (decodedAppId != null) { + _operationSourceByAppId.putIfAbsent(decodedAppId, () => source); final op = getOperation(decodedAppId); // Ensure the operation actually matches this taskId (metadata could be stale). if (op is Downloading && op.taskId == update.task.taskId) { @@ -1181,7 +1219,7 @@ abstract class PackageManager extends StateNotifier { continue; } - final (appId, metadataId, _) = _parseTaskMetadata(metaData); + final (appId, metadataId, _, source) = _parseTaskMetadata(metaData); if (appId == null) { await _cleanupTask(task); continue; @@ -1199,7 +1237,7 @@ abstract class PackageManager extends StateNotifier { continue; } - await _restoreOperation(appId, record, task, fileMetadata); + await _restoreOperation(appId, record, task, fileMetadata, source); } } catch (e) { debugPrint('Failed to restore operations: $e'); @@ -1211,6 +1249,7 @@ abstract class PackageManager extends StateNotifier { TaskRecord record, DownloadTask task, FileMetadata fileMetadata, + InstallSource source, ) async { switch (record.status) { case TaskStatus.complete: @@ -1237,6 +1276,7 @@ abstract class PackageManager extends StateNotifier { progress: record.progress, taskId: task.taskId, ), + source: source, ); try { await _downloader.resume(task); @@ -1263,6 +1303,7 @@ abstract class PackageManager extends StateNotifier { progress: record.progress, taskId: task.taskId, ), + source: source, ); break; @@ -1304,19 +1345,29 @@ abstract class PackageManager extends StateNotifier { String appId, String metadataId, { bool isCdnRetry = false, + InstallSource source = InstallSource.normal, }) { - return '$appId|$metadataId|${isCdnRetry ? '1' : '0'}'; + return '$appId|$metadataId|${isCdnRetry ? '1' : '0'}|${source.name}'; } - (String? appId, String? metadataId, bool isCdnRetry) _parseTaskMetadata( + (String? appId, String? metadataId, bool isCdnRetry, InstallSource source) + _parseTaskMetadata( String metaData, ) { final parts = metaData.split('|'); if (parts.length >= 2) { final isCdnRetry = parts.length >= 3 && parts[2] == '1'; - return (parts[0], parts[1], isCdnRetry); + final source = InstallSource.fromMetadata( + parts.length >= 4 ? parts[3] : null, + ); + return (parts[0], parts[1], isCdnRetry, source); } - return (metaData.isNotEmpty ? metaData : null, null, false); + return ( + metaData.isNotEmpty ? metaData : null, + null, + false, + InstallSource.normal, + ); } Future _loadFileMetadata( diff --git a/lib/services/secure_storage_service.dart b/lib/services/secure_storage_service.dart index 4496c51..6767dd5 100644 --- a/lib/services/secure_storage_service.dart +++ b/lib/services/secure_storage_service.dart @@ -126,6 +126,26 @@ class SecureStorageService { value: jsonEncode(relays.toList()), ); } + + // ========================================================================= + // Installed Apps Backup + // ========================================================================= + + static const _backupEnabledKey = 'installed_apps_backup_enabled'; + + /// Whether installed apps backup is enabled (off by default). + Future getBackupEnabled() async { + final value = await _storage.read(key: _backupEnabledKey); + return value == 'true'; + } + + /// Store the installed apps backup toggle state. + Future setBackupEnabled(bool enabled) async { + await _storage.write( + key: _backupEnabledKey, + value: enabled.toString(), + ); + } } /// Persists the AmberSigner pubkey in flutter_secure_storage. diff --git a/lib/widgets/app_card.dart b/lib/widgets/app_card.dart index f4f0581..65e5098 100644 --- a/lib/widgets/app_card.dart +++ b/lib/widgets/app_card.dart @@ -25,6 +25,17 @@ class AppCard extends HookConsumerWidget { final bool showUpdateButton; final bool showZapEncouragement; final bool showDescription; + final InstallSource installSource; + + /// When provided, used instead of default navigation on tap. + /// Use when AppCard is shown outside GoRouter tree (e.g. in a dialog). + final VoidCallback? onTap; + + /// When true, tap is disabled (e.g. informational display only). + final bool ignorePointer; + + /// When true, show install button even when app has no update (e.g. restore "to install"). + final bool showInstallWhenNotInstalled; const AppCard({ super.key, @@ -35,6 +46,10 @@ class AppCard extends HookConsumerWidget { this.showUpdateButton = false, this.showZapEncouragement = false, this.showDescription = true, + this.installSource = InstallSource.normal, + this.onTap, + this.ignorePointer = false, + this.showInstallWhenNotInstalled = false, }); @override @@ -54,121 +69,139 @@ class AppCard extends HookConsumerWidget { ? _stripMarkdown(app!.description) : 'No description available'; - Widget buildCard(Profile? publisher, bool isPublisherLoading) => GestureDetector( - onTap: () { - final segments = GoRouterState.of(context).uri.pathSegments; - final first = segments.isNotEmpty ? segments.first : 'search'; - // Prefer naddr so the detail screen can uniquely identify the app - // (identifier + author) and not accidentally resolve to a different - // publisher's app with the same identifier. - final naddr = Utils.encodeShareableIdentifier( - AddressInput( - identifier: app!.identifier, - author: app!.pubkey, - kind: app!.event.kind, - relays: const [], - ), - ); - context.push('/$first/app/$naddr'); - }, - child: Container( - margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 2), - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(20), - color: Theme.of(context).colorScheme.surface.withValues(alpha: 0.6), - border: Border.all( - color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.2), - width: 1, - ), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Header row: Icon + Name/Version (icon matches header height) - LayoutBuilder( - builder: (context, constraints) { - // Icon takes a little over 20% of available width - final iconSize = (constraints.maxWidth * 0.21).clamp( - 50.0, - 68.0, - ); - return Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // App Icon (stretches to match name + version height, ~20% width) - _buildAppIcon(context, iconSize), - - const SizedBox(width: 14), - - // App Name and Version (always stacked) - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - // App name with optional "by publisher" inline - _buildAppNameWithPublisher(context, publisher, isPublisherLoading), - const SizedBox(height: 8), - Align( - alignment: Alignment.centerLeft, - child: VersionPillWidget( - app: app!, - showUpdateArrow: showUpdateArrow, - ), - ), - ], + Widget buildCard(Profile? publisher, bool isPublisherLoading) { + final card = GestureDetector( + onTap: ignorePointer + ? null + : (onTap ?? + () { + final segments = GoRouterState.of(context).uri.pathSegments; + final first = segments.isNotEmpty + ? segments.first + : 'search'; + final naddr = Utils.encodeShareableIdentifier( + AddressInput( + identifier: app!.identifier, + author: app!.pubkey, + kind: app!.event.kind, + relays: const [], ), - ), - ], - ); - }, + ); + context.push('/$first/app/$naddr'); + }), + child: Container( + margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 2), + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(20), + color: Theme.of(context).colorScheme.surface.withValues(alpha: 0.6), + border: Border.all( + color: Theme.of( + context, + ).colorScheme.outline.withValues(alpha: 0.2), + width: 1, ), - - // App Description rendered as plain text (markdown stripped) - if (showDescription) ...[ - const SizedBox(height: 10), - Text( - descriptionText, - style: descriptionStyle, - overflow: TextOverflow.ellipsis, - maxLines: 2, - softWrap: true, - ), - ], - - // Update button (for apps with updates or currently downloading/installing) - if (showUpdateButton) + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header row: Icon + Name/Version (icon matches header height) LayoutBuilder( builder: (context, constraints) { - // Match the text column start (icon width + spacing) + // Icon takes a little over 20% of available width final iconSize = (constraints.maxWidth * 0.21).clamp( 50.0, 68.0, ); - final leftInset = iconSize + 14; + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // App Icon (stretches to match name + version height, ~20% width) + _buildAppIcon(context, iconSize), - return Padding( - padding: EdgeInsets.only(left: leftInset), - child: SizedBox( - width: (constraints.maxWidth - leftInset).clamp( - 0.0, - constraints.maxWidth, + const SizedBox(width: 14), + + // App Name and Version (always stacked) + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + // App name with optional "by publisher" inline + _buildAppNameWithPublisher( + context, + publisher, + isPublisherLoading, + ), + const SizedBox(height: 8), + Align( + alignment: Alignment.centerLeft, + child: VersionPillWidget( + app: app!, + showUpdateArrow: showUpdateArrow, + ), + ), + ], + ), ), - child: _AppCardUpdateButtonSection(app: app!), - ), + ], ); }, ), - // Zap encouragement (only for downloading/installing developer-signed apps) - if (showZapEncouragement) - _AppCardZapEncouragementSection(app: app!, publisher: publisher), - ], + // App Description rendered as plain text (markdown stripped) + if (showDescription) ...[ + const SizedBox(height: 10), + Text( + descriptionText, + style: descriptionStyle, + overflow: TextOverflow.ellipsis, + maxLines: 2, + softWrap: true, + ), + ], + + // Update button (for apps with updates or currently downloading/installing) + if (showUpdateButton || showInstallWhenNotInstalled) + LayoutBuilder( + builder: (context, constraints) { + // Match the text column start (icon width + spacing) + final iconSize = (constraints.maxWidth * 0.21).clamp( + 50.0, + 68.0, + ); + final leftInset = iconSize + 14; + + return Padding( + padding: EdgeInsets.only(left: leftInset), + child: SizedBox( + width: (constraints.maxWidth - leftInset).clamp( + 0.0, + constraints.maxWidth, + ), + child: _AppCardUpdateButtonSection( + app: app!, + installSource: installSource, + showWhenNotInstalled: showInstallWhenNotInstalled, + ), + ), + ); + }, + ), + + // Zap encouragement (only for downloading/installing developer-signed apps) + if (showZapEncouragement) + _AppCardZapEncouragementSection( + app: app!, + publisher: publisher, + ), + ], + ), ), - ), - ); + ); + return card; + } if (!needsPublisher) return buildCard(null, false); @@ -452,15 +485,24 @@ class AppCard extends HookConsumerWidget { } class _AppCardUpdateButtonSection extends ConsumerWidget { - const _AppCardUpdateButtonSection({required this.app}); + const _AppCardUpdateButtonSection({ + required this.app, + this.installSource = InstallSource.normal, + this.showWhenNotInstalled = false, + }); final App app; + final InstallSource installSource; + final bool showWhenNotInstalled; @override Widget build(BuildContext context, WidgetRef ref) { final operation = ref.watch(installOperationProvider(app.identifier)); + final installedPkg = ref.watch(installedPackageProvider(app.identifier)); final hasOperation = operation != null; - final shouldShow = app.hasUpdate || hasOperation; + final isInstalled = installedPkg != null; + final shouldShow = + app.hasUpdate || hasOperation || (showWhenNotInstalled && !isInstalled); if (!shouldShow) return const SizedBox.shrink(); @@ -472,6 +514,7 @@ class _AppCardUpdateButtonSection extends ConsumerWidget { child: _CompactInstallButton( app: app, release: app.latestRelease.value, + installSource: installSource, ), ), ], @@ -512,12 +555,22 @@ class _AppCardZapEncouragementSection extends ConsumerWidget { class _CompactInstallButton extends ConsumerWidget { final App app; final Release? release; + final InstallSource installSource; - const _CompactInstallButton({required this.app, this.release}); + const _CompactInstallButton({ + required this.app, + this.release, + this.installSource = InstallSource.normal, + }); @override Widget build(BuildContext context, WidgetRef ref) { - return InstallButton(app: app, release: release, compact: true); + return InstallButton( + app: app, + release: release, + compact: true, + installSource: installSource, + ); } } diff --git a/lib/widgets/batch_progress_banner.dart b/lib/widgets/batch_progress_banner.dart index ac5c03c..ba8d7bc 100644 --- a/lib/widgets/batch_progress_banner.dart +++ b/lib/widgets/batch_progress_banner.dart @@ -12,10 +12,12 @@ class UpdateAllRow extends ConsumerWidget { super.key, required this.allUpdates, this.label = 'Update All', + this.installSource = InstallSource.normal, }); final List allUpdates; final String label; + final InstallSource installSource; @override Widget build(BuildContext context, WidgetRef ref) { @@ -36,6 +38,7 @@ class UpdateAllRow extends ConsumerWidget { appId: app.identifier, target: app.latestFileMetadata!, displayName: app.name, + source: installSource, ), ) .toList(); diff --git a/lib/widgets/install_button.dart b/lib/widgets/install_button.dart index a1154d6..5119acc 100644 --- a/lib/widgets/install_button.dart +++ b/lib/widgets/install_button.dart @@ -18,11 +18,13 @@ class InstallButton extends ConsumerWidget { required this.app, this.release, this.compact = false, + this.installSource = InstallSource.normal, }); final App app; final Release? release; final bool compact; + final InstallSource installSource; @override Widget build(BuildContext context, WidgetRef ref) { @@ -564,7 +566,12 @@ class InstallButton extends ConsumerWidget { FileMetadata fileMetadata, ) async { final pm = ref.read(packageManagerProvider.notifier); - await pm.startDownload(app.identifier, fileMetadata, displayName: app.name); + await pm.startDownload( + app.identifier, + fileMetadata, + displayName: app.name, + source: installSource, + ); } void _pauseDownload(WidgetRef ref) {