mirror of
https://github.com/zapstore/zapstore.git
synced 2026-10-05 12:38:24 +00:00
feat: backup and restore installed apps (Issue #20)
- Add backup/restore for cataloged installed apps as encrypted AppStack (kind 30267) - New service: installed_apps_backup_service with backup + restore providers - Backup: saves addressable IDs to Nostr social relays via NIP-44 encryption - Restore: fetches from relays, decrypts, resolves to App models; full-screen route - Profile Data Management: Backup and Restore tiles (sign-in required) - Restore screen: sections "To install" / "Already installed", Install All button - Exclude backup identifier from bookmark/stack picker (bookmark_widgets) - UpdateAllRow: optional label for "Install All" in restore screen - Add /profile/restore route; no back arrow (matches app navigation style) Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
b4d43928c8
commit
57878a774e
@@ -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<String, dynamic> event) {
|
||||
final tags = event['tags'] as List<dynamic>?;
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
+26
-1
@@ -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<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(
|
||||
@@ -156,7 +176,12 @@ final routerProvider = Provider<GoRouter>((ref) {
|
||||
state: state,
|
||||
child: const ProfileScreen(),
|
||||
),
|
||||
routes: [_appDetailRoute(), _stackDetailRoute(), _userRoute()],
|
||||
routes: [
|
||||
_appDetailRoute(),
|
||||
_stackDetailRoute(),
|
||||
_userRoute(),
|
||||
_restoreRoute(),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -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<void> 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,
|
||||
|
||||
@@ -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<String> 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<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: 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<App> allApps;
|
||||
final Set<String> 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<App>()
|
||||
.where((app) => !installedIds.contains(app.identifier))
|
||||
.toList();
|
||||
|
||||
final alreadyInstalled = appIdentifiers
|
||||
.map((id) => appsMap[id])
|
||||
.whereType<App>()
|
||||
.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)),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<Future<int> 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<int> _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<App>(
|
||||
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<int> _saveBackup(Ref ref, Signer signer, List<String> 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<Future<List<String>> 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<List<String>> _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<AppStack>(
|
||||
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<String> addressableIds;
|
||||
try {
|
||||
final decryptedContent = await signer.nip44Decrypt(
|
||||
stack.content,
|
||||
signedInPubkey,
|
||||
);
|
||||
addressableIds = (jsonDecode(decryptedContent) as List).cast<String>();
|
||||
} catch (e) {
|
||||
debugPrint('[InstalledAppsBackup] Restore decrypt failed: $e');
|
||||
return [];
|
||||
}
|
||||
|
||||
return addressableIds;
|
||||
}
|
||||
@@ -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<App> 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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user