mirror of
https://github.com/zapstore/zapstore.git
synced 2026-09-14 03:05:06 +00:00
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:
@@ -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,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;
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user