Files
zapstore/lib/screens/restore_installed_apps_screen.dart
T
Henrique Velloso 84906164bb 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.
2026-02-26 18:27:41 -03:00

300 lines
9.2 KiB
Dart

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';
/// 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
.where((id) => id.startsWith('32267:'))
.map((id) => id.split(':').skip(2).join(':'))
.toSet();
if (appIdentifiers.isEmpty) {
return Scaffold(
appBar: _restoreAppBar(onClose: onClose),
body: Center(
child: Text('No apps to restore', style: context.textTheme.bodyLarge),
),
);
}
final platform = ref.read(packageManagerProvider.notifier).platform;
final appsState = ref.watch(
query<App>(
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: _restoreAppBar(onClose: onClose),
body: SingleChildScrollView(
padding: const EdgeInsets.only(top: 8, bottom: 32),
child: _RestoreSkeleton(),
),
),
StorageError(:final exception) => Scaffold(
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,
onClose: onClose,
),
};
}
}
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 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};
// Preserve backup order for both lists
final toInstall = appIdentifiers
.map((id) => appsMap[id])
.whereType<App>()
.where((app) => !effectiveInstalledIds.contains(app.identifier))
.toList();
final alreadyInstalled = appIdentifiers
.map((id) => appsMap[id])
.whereType<App>()
.where((app) => effectiveInstalledIds.contains(app.identifier))
.toList();
return Scaffold(
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',
installSource: InstallSource.restore,
),
// "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,
installSource: InstallSource.restore,
showUpdateButton: true,
showInstallWhenNotInstalled: true,
showUpdateArrow: false,
showSignedBy: false,
showZapEncouragement: false,
showDescription: false,
onTap: () {}, // No navigation; Install button stays tappable
),
),
// "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,
ignorePointer: true, // Informational only; no tap
),
),
],
// 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)),
],
),
),
);
}
}