Files
zapstore/lib/router.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

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;
});