diff --git a/lib/constants/app_constants.dart b/lib/constants/app_constants.dart index a144b94..129f2b6 100644 --- a/lib/constants/app_constants.dart +++ b/lib/constants/app_constants.dart @@ -12,15 +12,20 @@ const kFranzapPubkey = /// Identifier for storing user saved apps const kAppBookmarksIdentifier = 'zapstore-bookmarks'; +/// Identifier for encrypted backup of installed apps +const kInstalledAppsBackupIdentifier = 'zapstore-installed'; + /// Event filter for app stacks - excludes saved apps and stacks with zero App references bool appStackEventFilter(Map event) { final tags = event['tags'] as List?; if (tags == null) return false; - // Check for saved apps identifier in 'd' tag + // Check for saved apps / backup identifiers in 'd' tag (exclude from public stacks) for (final tag in tags) { if (tag is List && tag.isNotEmpty && tag[0] == 'd') { - if (tag.length > 1 && tag[1] == kAppBookmarksIdentifier) { + if (tag.length > 1 && + (tag[1] == kAppBookmarksIdentifier || + tag[1] == kInstalledAppsBackupIdentifier)) { return false; } } diff --git a/lib/router.dart b/lib/router.dart index 324248e..dcefc3d 100644 --- a/lib/router.dart +++ b/lib/router.dart @@ -11,6 +11,7 @@ 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) @@ -83,6 +84,25 @@ 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( @@ -156,7 +176,12 @@ final routerProvider = Provider((ref) { state: state, child: const ProfileScreen(), ), - routes: [_appDetailRoute(), _stackDetailRoute(), _userRoute()], + routes: [ + _appDetailRoute(), + _stackDetailRoute(), + _userRoute(), + _restoreRoute(), + ], ), ], ), diff --git a/lib/screens/profile_screen.dart b/lib/screens/profile_screen.dart index 3baf8a4..6ecff95 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/services/installed_apps_backup_service.dart'; import 'package:zapstore/services/package_manager/package_manager.dart'; import 'package:zapstore/utils/extensions.dart'; import 'package:zapstore/widgets/common/profile_identity_row.dart'; @@ -1334,6 +1335,90 @@ class _EmptyState extends StatelessWidget { } } +class _InstalledAppsTile extends ConsumerWidget { + const _InstalledAppsTile({ + required this.icon, + required this.title, + required this.onAction, + }); + + final IconData icon; + final String title; + final Future Function(BuildContext context, WidgetRef ref) onAction; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final signedInPubkey = ref.watch(Signer.activePubkeyProvider); + final isSignedIn = signedInPubkey != null; + + return AsyncButtonBuilder( + onPressed: () async { + if (!isSignedIn) { + if (context.mounted) { + context.showError( + 'Sign in required', + description: 'You need to sign in to use this feature.', + ); + } + return; + } + try { + await onAction(context, ref); + } catch (e) { + if (context.mounted) { + context.showError( + 'Operation failed', + technicalDetails: '$e', + ); + } + } + }, + builder: (context, child, callback, buttonState) { + final isLoading = buttonState.maybeWhen( + loading: () => true, + orElse: () => false, + ); + return ListTile( + leading: CircleAvatar( + radius: 18, + backgroundColor: Theme.of(context) + .colorScheme + .primary + .withValues(alpha: 0.12), + child: isLoading + ? SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + 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), + fontWeight: FontWeight.w600, + ), + maxLines: 1, + minFontSize: 12, + ), + contentPadding: EdgeInsets.zero, + onTap: isLoading || !isSignedIn ? null : callback, + ); + }, + child: const SizedBox.shrink(), + ); + } +} + class _DataManagementSection extends ConsumerWidget { const _DataManagementSection(); @@ -1350,6 +1435,33 @@ class _DataManagementSection extends ConsumerWidget { style: Theme.of(context).textTheme.titleMedium, ), const SizedBox(height: 16), + _InstalledAppsTile( + icon: Icons.backup, + title: 'Backup installed apps', + onAction: (context, ref) async { + final count = await ref.read(backupInstalledAppsProvider)(); + if (context.mounted) { + context.showInfo( + count == 0 + ? 'No cataloged apps to backup' + : 'Backed up $count app${count == 1 ? '' : 's'}', + ); + } + }, + ), + _InstalledAppsTile( + icon: Icons.restore, + title: 'Restore installed apps', + onAction: (context, ref) async { + final ids = await ref.read(restoreInstalledAppsProvider)(); + if (!context.mounted) return; + if (ids.isEmpty) { + context.showInfo('No backup found'); + return; + } + context.push('/profile/restore', extra: ids); + }, + ), ListTile( leading: CircleAvatar( radius: 18, diff --git a/lib/screens/restore_installed_apps_screen.dart b/lib/screens/restore_installed_apps_screen.dart new file mode 100644 index 0000000..4bb3f1d --- /dev/null +++ b/lib/screens/restore_installed_apps_screen.dart @@ -0,0 +1,275 @@ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:models/models.dart'; +import 'package:skeletonizer/skeletonizer.dart'; +import 'package:zapstore/services/package_manager/package_manager.dart'; +import 'package:zapstore/utils/extensions.dart'; +import 'package:zapstore/widgets/app_card.dart'; +import 'package:zapstore/widgets/batch_progress_banner.dart'; +import 'package:zapstore/widgets/common/badges.dart'; +import 'package:zapstore/theme.dart'; + +/// 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, + }); + + final List addressableIds; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final appIdentifiers = addressableIds + .where((id) => id.startsWith('32267:')) + .map((id) => id.split(':').skip(2).join(':')) + .toSet(); + + if (appIdentifiers.isEmpty) { + return Scaffold( + appBar: AppBar( + title: const Text('Restore from backup'), + automaticallyImplyLeading: false, + ), + body: Center( + child: Text( + 'No apps to restore', + style: context.textTheme.bodyLarge, + ), + ), + ); + } + + final platform = ref.read(packageManagerProvider.notifier).platform; + + final appsState = ref.watch( + query( + tags: { + '#d': appIdentifiers, + '#f': {platform}, + }, + and: (app) => { + app.latestRelease.query( + and: (release) => { + release.latestMetadata.query(), + release.latestAsset.query(), + }, + ), + }, + source: const LocalAndRemoteSource(relays: 'AppCatalog', stream: false), + subscriptionPrefix: 'restore-backup-apps', + ), + ); + + 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(), + ), + ), + 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), + ), + ], + ), + ), + ), + StorageData(:final models) => _RestoreContent( + allApps: models, + appIdentifiers: appIdentifiers, + ), + }; + } +} + +class _RestoreContent extends HookConsumerWidget { + const _RestoreContent({ + required this.allApps, + required this.appIdentifiers, + }); + + final List allApps; + final Set appIdentifiers; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final installedIds = + ref.watch(packageManagerProvider).installed.keys.toSet(); + + final appsMap = {for (final app in allApps) app.identifier: app}; + + // Preserve backup order for both lists + final toInstall = appIdentifiers + .map((id) => appsMap[id]) + .whereType() + .where((app) => !installedIds.contains(app.identifier)) + .toList(); + + final alreadyInstalled = appIdentifiers + .map((id) => appsMap[id]) + .whereType() + .where((app) => installedIds.contains(app.identifier)) + .toList(); + + return Scaffold( + appBar: AppBar( + title: const Text('Restore from backup'), + automaticallyImplyLeading: false, + ), + 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'), + + // "To install" section + if (toInstall.isNotEmpty) + _SectionHeader( + icon: Icons.download_outlined, + title: 'To install', + count: toInstall.length, + ), + ...toInstall.map( + (app) => AppCard( + key: ValueKey('restore_${app.identifier}'), + app: app, + showUpdateButton: true, + showUpdateArrow: false, + showSignedBy: false, + showZapEncouragement: false, + showDescription: false, + ), + ), + + // "Already installed" section + if (alreadyInstalled.isNotEmpty) ...[ + _SectionHeader( + icon: Icons.check_circle, + title: 'Already installed', + count: alreadyInstalled.length, + iconColor: Colors.green.shade400, + ), + ...alreadyInstalled.map( + (app) => AppCard( + key: ValueKey('installed_${app.identifier}'), + app: app, + showUpdateButton: false, + showUpdateArrow: app.hasUpdate, + showSignedBy: false, + showZapEncouragement: false, + showDescription: false, + ), + ), + ], + + // Empty state — only when backup has nothing at all + if (toInstall.isEmpty && alreadyInstalled.isEmpty) + Padding( + padding: const EdgeInsets.all(32), + child: Center( + child: Text( + 'No apps found in backup', + style: context.textTheme.bodyLarge?.copyWith( + color: + Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.6), + ), + ), + ), + ), + ], + ), + ); + } +} + +class _SectionHeader extends StatelessWidget { + const _SectionHeader({ + required this.icon, + required this.title, + required this.count, + this.iconColor, + }); + + final IconData icon; + final String title; + final int count; + final Color? iconColor; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + Icon(icon, size: 20, color: iconColor ?? AppColors.darkActionPrimary), + const SizedBox(width: 8), + Text(title, style: context.textTheme.titleMedium), + const SizedBox(width: 8), + CountBadge(count: count, color: AppColors.darkPillBackground), + ], + ), + ); + } +} + +class _RestoreSkeleton extends StatelessWidget { + @override + Widget build(BuildContext context) { + return SkeletonizerConfig( + data: AppColors.getSkeletonizerConfig(Theme.of(context).brightness), + child: Skeletonizer( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Section header skeleton — same padding as _SectionHeader + Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + Container( + height: 20, + width: 100, + decoration: BoxDecoration( + color: AppColors.darkSkeletonBase, + borderRadius: BorderRadius.circular(4), + ), + ), + const SizedBox(width: 8), + Container( + height: 20, + width: 24, + decoration: BoxDecoration( + color: AppColors.darkSkeletonBase, + borderRadius: BorderRadius.circular(10), + ), + ), + ], + ), + ), + const SizedBox(height: 4), + ...List.generate(3, (_) => const AppCard(isLoading: true)), + ], + ), + ), + ); + } +} diff --git a/lib/services/installed_apps_backup_service.dart b/lib/services/installed_apps_backup_service.dart new file mode 100644 index 0000000..edf8737 --- /dev/null +++ b/lib/services/installed_apps_backup_service.dart @@ -0,0 +1,124 @@ +import 'dart:convert'; + +import 'package:flutter/foundation.dart'; +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/utils/extensions.dart'; + +/// Provider that exposes the backup function with correct Ref. +final backupInstalledAppsProvider = Provider Function()>((ref) { + return () => _backupInstalledApps(ref); +}); + +/// Backs up the list of cataloged installed apps as an encrypted AppStack (kind 30267) +/// to Nostr social relays. Only apps that are both installed and in the Zapstore +/// catalog are included. +/// +/// Returns the count of backed-up apps. +/// Throws if user is not signed in or on failure. +Future _backupInstalledApps(Ref ref) async { + final signer = ref.read(Signer.activeSignerProvider); + final signedInPubkey = ref.read(Signer.activePubkeyProvider); + + if (signer == null || signedInPubkey == null) { + throw StateError('Sign in required to backup installed apps'); + } + + final pmState = ref.read(packageManagerProvider); + final installedIds = pmState.installed.keys.toSet(); + + if (installedIds.isEmpty) { + return _saveBackup(ref, signer, []); + } + + final platform = ref.read(packageManagerProvider.notifier).platform; + final storage = ref.read(storageNotifierProvider.notifier); + + final apps = await storage.query( + RequestFilter( + tags: { + '#d': installedIds, + '#f': {platform}, + }, + ).toRequest(subscriptionPrefix: 'installed-backup'), + source: const LocalSource(), + ); + + final appIds = apps + .map((app) => '${app.event.kind}:${app.pubkey}:${app.identifier}') + .toList(); + + return _saveBackup(ref, signer, appIds); +} + +Future _saveBackup(Ref ref, Signer signer, List appIds) async { + final platform = ref.read(packageManagerProvider.notifier).platform; + final storage = ref.read(storageNotifierProvider.notifier); + + final partialStack = PartialAppStack.withEncryptedApps( + name: 'Installed Apps', + identifier: kInstalledAppsBackupIdentifier, + apps: appIds, + platform: platform, + ); + + final signedStack = await partialStack.signWith(signer); + + await storage.save({signedStack}); + + storage.publish( + {signedStack}, + source: RemoteSource(relays: 'social'), + ); + + return appIds.length; +} + +/// Provider that exposes the restore function with correct Ref. +/// Returns decrypted addressable IDs from the backup (lightweight, no catalog queries). +final restoreInstalledAppsProvider = Provider> Function()>((ref) { + return () => _restoreInstalledApps(ref); +}); + +/// Fetches the encrypted backup from Nostr relays and decrypts it. +/// Returns raw addressable IDs (e.g. "32267:pubkey:identifier"). +/// The UI is responsible for resolving these to App models reactively. +Future> _restoreInstalledApps(Ref ref) async { + final signer = ref.read(Signer.activeSignerProvider); + final signedInPubkey = ref.read(Signer.activePubkeyProvider); + + if (signer == null || signedInPubkey == null) { + throw StateError('Sign in required to restore installed apps'); + } + + final storage = ref.read(storageNotifierProvider.notifier); + + final stacks = await storage.query( + RequestFilter( + authors: {signedInPubkey}, + tags: {'#d': {kInstalledAppsBackupIdentifier}}, + ).toRequest(subscriptionPrefix: 'installed-restore'), + source: const LocalAndRemoteSource(relays: 'social', stream: false), + ); + + final stack = stacks.firstOrNull; + if (stack == null || stack.content.isEmpty) { + return []; + } + + List addressableIds; + try { + final decryptedContent = await signer.nip44Decrypt( + stack.content, + signedInPubkey, + ); + addressableIds = (jsonDecode(decryptedContent) as List).cast(); + } catch (e) { + debugPrint('[InstalledAppsBackup] Restore decrypt failed: $e'); + return []; + } + + return addressableIds; +} diff --git a/lib/widgets/batch_progress_banner.dart b/lib/widgets/batch_progress_banner.dart index 5ddc0de..ac5c03c 100644 --- a/lib/widgets/batch_progress_banner.dart +++ b/lib/widgets/batch_progress_banner.dart @@ -5,12 +5,17 @@ import 'package:zapstore/services/package_manager/package_manager.dart'; import 'package:zapstore/theme.dart'; import 'package:zapstore/utils/extensions.dart'; -/// "Update All" button row. +/// "Update All" / "Install All" button row. /// Progress and "All done" states are handled by sticky banners in _UpdatesListBody. class UpdateAllRow extends ConsumerWidget { - const UpdateAllRow({super.key, required this.allUpdates}); + const UpdateAllRow({ + super.key, + required this.allUpdates, + this.label = 'Update All', + }); final List allUpdates; + final String label; @override Widget build(BuildContext context, WidgetRef ref) { @@ -38,7 +43,7 @@ class UpdateAllRow extends ConsumerWidget { }, icon: const Icon(Icons.download, size: 16, color: Colors.white), label: Text( - 'Update All (${allUpdates.length})', + '$label (${allUpdates.length})', style: const TextStyle( fontSize: 14, fontWeight: FontWeight.w600, diff --git a/lib/widgets/bookmark_widgets.dart b/lib/widgets/bookmark_widgets.dart index 684decd..5526b14 100644 --- a/lib/widgets/bookmark_widgets.dart +++ b/lib/widgets/bookmark_widgets.dart @@ -299,7 +299,11 @@ class _AddToStackDialogSignedIn extends HookConsumerWidget { (t) => t is List && t.isNotEmpty && t[0] == 'd', orElse: () => null, ); - if (dTag != null && dTag[1] == kAppBookmarksIdentifier) return false; + if (dTag != null && + (dTag[1] == kAppBookmarksIdentifier || + dTag[1] == kInstalledAppsBackupIdentifier)) { + return false; + } // Check has at least one 'a' tag (app reference) final hasAppRef = tags.any( (t) => t is List && t.isNotEmpty && t[0] == 'a',