feat: auto-backup installed apps on install/uninstall

Back up the list of installed apps to Nostr (encrypted AppStack) when
batch installs complete or apps are uninstalled. Toggle in profile, off
by default. Restore screen fetches backup and lets users reinstall apps.

Changes by file:

- installed_apps_backup_service: Listener for batch completion and
  installed-list changes; debounced backup (3s); restore-only batches
  ignored; timer cancelled on provider dispose; logs reduced to errors
  and completion only; null-aware assignment for baseline keys.

- secure_storage_service: Persistence for backup-enabled toggle (off
  by default).

- profile_screen: Toggle to enable/disable backup; entry point for
  restore flow.

- restore_installed_apps_screen: Restore UI with shared AppBar; lists
  apps to install and already installed; cards navigate to app detail.

- main_scaffold: Watches backup listener provider so it stays active.

- package_manager: Removed unused installSourceProvider; install source
  handling for restore operations.

- install_operation, android_package_manager, install_button,
  batch_progress_banner: InstallSource.restore support for restore flow.

- app_card: Factory constructors (forRestore, forDiscovery, etc.) to
  simplify restore and other screens.

- router, installed_packages_snapshot: Supporting wiring for the feature.
This commit is contained in:
Henrique Velloso
2026-02-26 18:27:41 -03:00
parent 57878a774e
commit 84906164bb
13 changed files with 531 additions and 216 deletions
-21
View File
@@ -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<String> || extra.isEmpty) return '/profile';
return null;
},
pageBuilder: (context, state) {
final ids = state.extra as List<String>;
return _noTransitionPage(
state: state,
child: RestoreInstalledAppsScreen(addressableIds: ids),
);
},
);
}
/// Helper to build user route
GoRoute _userRoute() {
return GoRoute(
@@ -180,7 +160,6 @@ final routerProvider = Provider<GoRouter>((ref) {
_appDetailRoute(),
_stackDetailRoute(),
_userRoute(),
_restoreRoute(),
],
),
],
+3
View File
@@ -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;
+56 -22
View File
@@ -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<void>(
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(
+70 -46
View File
@@ -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<String> 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<App> allApps;
final Set<String> 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<App>()
.where((app) => !installedIds.contains(app.identifier))
.where((app) => !effectiveInstalledIds.contains(app.identifier))
.toList();
final alreadyInstalled = appIdentifiers
.map((id) => appsMap[id])
.whereType<App>()
.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),
),
),
),
+129 -1
View File
@@ -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<bool> {
@override
Future<bool> build() async {
final storage = ref.read(secureStorageServiceProvider);
return storage.getBackupEnabled();
}
Future<void> setEnabled(bool enabled) async {
await ref.read(secureStorageServiceProvider).setBackupEnabled(enabled);
ref.invalidateSelf();
}
}
final backupSettingsProvider =
AsyncNotifierProvider<BackupSettingsNotifier, bool>(
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<void>((ref) {
Timer? debounceTimer;
var activeBatchAppIds = <String>{};
Set<String>? baselineInstalledKeys;
void scheduleBackup() {
debounceTimer?.cancel();
debounceTimer = Timer(_backupDebounceDuration, () {
debounceTimer = null;
_maybeBackup(ref);
});
}
ref.listen<BatchProgress?>(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 = <String>{};
baselineInstalledKeys =
ref.read(packageManagerProvider).installed.keys.toSet();
return;
}
baselineInstalledKeys =
ref.read(packageManagerProvider).installed.keys.toSet();
scheduleBackup();
activeBatchAppIds = <String>{};
}
});
ref.listen<bool>(
packageManagerProvider.select((s) => s.isScanning),
(prev, next) {
if (prev == true && next == false) {
final keys = ref.read(packageManagerProvider).installed.keys.toSet();
baselineInstalledKeys ??= keys;
}
},
);
ref.listen<int>(
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<String> 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<Future<int> Function()>((ref) {
return () => _backupInstalledApps(ref);
@@ -116,7 +244,7 @@ Future<List<String>> _restoreInstalledApps(Ref ref) async {
);
addressableIds = (jsonDecode(decryptedContent) as List).cast<String>();
} catch (e) {
debugPrint('[InstalledAppsBackup] Restore decrypt failed: $e');
debugPrint('$_logPrefix Restore decrypt failed: $e');
return [];
}
@@ -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
@@ -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);
@@ -17,7 +17,7 @@ class InstalledPackagesSnapshot {
static Future<void> save(Map<String, PackageInfo> 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) => <String, dynamic>{
@@ -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) {
@@ -134,6 +134,9 @@ abstract class PackageManager extends StateNotifier<PackageManagerState> {
/// Lock to prevent concurrent queue processing
bool _processingQueue = false;
/// Tracks the source for each app operation (normal vs restore).
final Map<String, InstallSource> _operationSourceByAppId = {};
/// Dynamic max concurrent downloads based on device capability
int get maxConcurrentDownloads =>
DeviceCapabilitiesCache.capabilities.maxConcurrentDownloads;
@@ -294,16 +297,30 @@ abstract class PackageManager extends StateNotifier<PackageManagerState> {
.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<PackageManagerState> {
/// 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<PackageManagerState> {
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<PackageManagerState> {
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<PackageManagerState> {
/// Queue multiple downloads at once - staggered to prevent UI flood.
/// This is the primary method for "Update All" functionality.
Future<void> queueDownloads(
List<({String appId, FileMetadata target, String? displayName})> items,
List<
({
String appId,
FileMetadata target,
String? displayName,
InstallSource source,
})
> items,
) async {
await _ensureDownloaderReady();
@@ -404,6 +439,7 @@ abstract class PackageManager extends StateNotifier<PackageManagerState> {
setOperation(
item.appId,
DownloadQueued(target: item.target, displayName: item.displayName),
source: item.source,
);
}
@@ -718,6 +754,7 @@ abstract class PackageManager extends StateNotifier<PackageManagerState> {
appId,
target.id,
isCdnRetry: isCdnRetry,
source: getOperationSource(appId),
);
final task = DownloadTask(
@@ -771,9 +808,10 @@ abstract class PackageManager extends StateNotifier<PackageManagerState> {
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<PackageManagerState> {
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<PackageManagerState> {
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<PackageManagerState> {
TaskRecord record,
DownloadTask task,
FileMetadata fileMetadata,
InstallSource source,
) async {
switch (record.status) {
case TaskStatus.complete:
@@ -1237,6 +1276,7 @@ abstract class PackageManager extends StateNotifier<PackageManagerState> {
progress: record.progress,
taskId: task.taskId,
),
source: source,
);
try {
await _downloader.resume(task);
@@ -1263,6 +1303,7 @@ abstract class PackageManager extends StateNotifier<PackageManagerState> {
progress: record.progress,
taskId: task.taskId,
),
source: source,
);
break;
@@ -1304,19 +1345,29 @@ abstract class PackageManager extends StateNotifier<PackageManagerState> {
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<FileMetadata?> _loadFileMetadata(
+20
View File
@@ -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<bool> getBackupEnabled() async {
final value = await _storage.read(key: _backupEnabledKey);
return value == 'true';
}
/// Store the installed apps backup toggle state.
Future<void> setBackupEnabled(bool enabled) async {
await _storage.write(
key: _backupEnabledKey,
value: enabled.toString(),
);
}
}
/// Persists the AmberSigner pubkey in flutter_secure_storage.
+157 -104
View File
@@ -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,
);
}
}
+3
View File
@@ -12,10 +12,12 @@ class UpdateAllRow extends ConsumerWidget {
super.key,
required this.allUpdates,
this.label = 'Update All',
this.installSource = InstallSource.normal,
});
final List<App> 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();
+8 -1
View File
@@ -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) {