mirror of
https://github.com/zapstore/zapstore.git
synced 2026-10-05 12:38:24 +00:00
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.
200 lines
6.2 KiB
Dart
200 lines
6.2 KiB
Dart
import 'dart:async';
|
|
|
|
import 'package:flutter/widgets.dart';
|
|
import 'package:go_router/go_router.dart';
|
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|
import 'package:models/models.dart';
|
|
import 'package:zapstore/screens/main_scaffold.dart';
|
|
import 'package:zapstore/screens/app_detail_screen.dart';
|
|
import 'package:zapstore/screens/app_stack_screen.dart';
|
|
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/services/package_manager/package_manager.dart';
|
|
|
|
/// Root paths for each navigation branch (used for back navigation handling)
|
|
const kBranchRoots = ['/search', '/updates', '/profile'];
|
|
|
|
final rootNavigatorKey = GlobalKey<NavigatorState>();
|
|
|
|
typedef _ResolvedRoute = ({String identifier, String? author});
|
|
|
|
CustomTransitionPage<void> _noTransitionPage({
|
|
required GoRouterState state,
|
|
required Widget child,
|
|
}) {
|
|
return CustomTransitionPage<void>(
|
|
key: state.pageKey,
|
|
child: child,
|
|
transitionsBuilder: (context, animation, secondaryAnimation, child) => child,
|
|
transitionDuration: Duration.zero,
|
|
reverseTransitionDuration: Duration.zero,
|
|
);
|
|
}
|
|
|
|
_ResolvedRoute _resolveNaddrRouteId(String rawId) {
|
|
if (rawId.startsWith('naddr1')) {
|
|
try {
|
|
final decoded = Utils.decodeShareableIdentifier(rawId);
|
|
if (decoded is AddressData) {
|
|
return (identifier: decoded.identifier, author: decoded.author);
|
|
}
|
|
} catch (_) {
|
|
// Fall back to treating it as a plain identifier.
|
|
}
|
|
}
|
|
return (identifier: rawId, author: null);
|
|
}
|
|
|
|
/// Helper to build app detail route
|
|
GoRoute _appDetailRoute() {
|
|
return GoRoute(
|
|
path: 'app/:id',
|
|
pageBuilder: (context, state) {
|
|
final rawId = state.pathParameters['id']!;
|
|
final resolved = _resolveNaddrRouteId(rawId);
|
|
return _noTransitionPage(
|
|
state: state,
|
|
child: AppDetailScreen(
|
|
appId: resolved.identifier,
|
|
authorPubkey: resolved.author,
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
/// Helper to build stack detail route
|
|
GoRoute _stackDetailRoute() {
|
|
return GoRoute(
|
|
path: 'stack/:id',
|
|
pageBuilder: (context, state) {
|
|
final rawId = state.pathParameters['id']!;
|
|
final resolved = _resolveNaddrRouteId(rawId);
|
|
return _noTransitionPage(
|
|
state: state,
|
|
child: AppStackScreen(
|
|
stackId: resolved.identifier,
|
|
authorPubkey: resolved.author,
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
/// Helper to build user route
|
|
GoRoute _userRoute() {
|
|
return GoRoute(
|
|
path: 'user/:pubkey',
|
|
pageBuilder: (context, state) {
|
|
final pubkey = state.pathParameters['pubkey']!;
|
|
return _noTransitionPage(
|
|
state: state,
|
|
child: UserScreen(pubkey: pubkey),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
final routerProvider = Provider<GoRouter>((ref) {
|
|
String? previousPath;
|
|
|
|
final router = GoRouter(
|
|
navigatorKey: rootNavigatorKey,
|
|
initialLocation: '/search',
|
|
routes: [
|
|
// Top-level route for market:// intents
|
|
// Redirect to search branch to show with nav bar
|
|
GoRoute(
|
|
path: '/market/:packageId',
|
|
redirect: (context, state) {
|
|
final packageId = state.pathParameters['packageId'];
|
|
if (packageId == null || packageId.isEmpty) {
|
|
return '/search';
|
|
}
|
|
return '/search/app/$packageId';
|
|
},
|
|
),
|
|
// Single stateful shell route that handles everything
|
|
StatefulShellRoute.indexedStack(
|
|
builder: (context, state, navigationShell) =>
|
|
MainScaffold(navigationShell: navigationShell),
|
|
branches: [
|
|
// Search tab branch with nested detail routes
|
|
StatefulShellBranch(
|
|
routes: [
|
|
GoRoute(
|
|
path: '/search',
|
|
pageBuilder: (context, state) => _noTransitionPage(
|
|
state: state,
|
|
child: const SearchScreen(),
|
|
),
|
|
routes: [_appDetailRoute(), _stackDetailRoute(), _userRoute()],
|
|
),
|
|
],
|
|
),
|
|
// Updates tab branch with nested detail routes
|
|
StatefulShellBranch(
|
|
routes: [
|
|
GoRoute(
|
|
path: '/updates',
|
|
pageBuilder: (context, state) => _noTransitionPage(
|
|
state: state,
|
|
child: const UpdatesScreen(),
|
|
),
|
|
routes: [_appDetailRoute(), _stackDetailRoute(), _userRoute()],
|
|
),
|
|
],
|
|
),
|
|
// Profile tab branch with nested detail routes
|
|
StatefulShellBranch(
|
|
routes: [
|
|
GoRoute(
|
|
path: '/profile',
|
|
pageBuilder: (context, state) => _noTransitionPage(
|
|
state: state,
|
|
child: const ProfileScreen(),
|
|
),
|
|
routes: [
|
|
_appDetailRoute(),
|
|
_stackDetailRoute(),
|
|
_userRoute(),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
],
|
|
);
|
|
|
|
// Listen for route changes to trigger actions
|
|
void onRouteChange() {
|
|
final currentPath = router.routerDelegate.currentConfiguration.uri.path;
|
|
final isUpdatesRoute = currentPath.startsWith('/updates');
|
|
final wasUpdatesRoute = previousPath?.startsWith('/updates') ?? false;
|
|
previousPath = currentPath;
|
|
|
|
Future.microtask(() {
|
|
// Sync installed packages on every navigation to catch sideloads,
|
|
// external installs/uninstalls, and self-updating apps.
|
|
// This is a local-only platform channel call (~100-500ms, no network).
|
|
unawaited(
|
|
ref.read(packageManagerProvider.notifier).syncInstalledPackages(),
|
|
);
|
|
|
|
// Clear completed operations when navigating AWAY from updates
|
|
// This cleans up the "All done" state without affecting the count while visible
|
|
if (wasUpdatesRoute && !isUpdatesRoute) {
|
|
ref.read(packageManagerProvider.notifier).clearCompletedOperations();
|
|
}
|
|
});
|
|
}
|
|
|
|
router.routerDelegate.addListener(onRouteChange);
|
|
ref.onDispose(() => router.routerDelegate.removeListener(onRouteChange));
|
|
|
|
return router;
|
|
});
|