From 57b3311dd669088b547c5da042b6d9124b3600b1 Mon Sep 17 00:00:00 2001 From: franzap <_@franzap.com> Date: Thu, 16 Jul 2026 16:12:28 -0300 Subject: [PATCH] Device key restore UX --- lib/main.dart | 10 +- lib/screens/profile_screen.dart | 200 +++++++++++++----- lib/screens/search_screen.dart | 176 +++++++++++---- lib/services/device_backup_service.dart | 138 +++++------- lib/services/device_key_service.dart | 12 ++ lib/services/settings_service.dart | 10 - lib/widgets/device_restore_dialog.dart | 18 +- spec/work/WORK-026-new-device-key-reminder.md | 49 +++++ 8 files changed, 404 insertions(+), 209 deletions(-) create mode 100644 spec/work/WORK-026-new-device-key-reminder.md diff --git a/lib/main.dart b/lib/main.dart index a7bf974..179b290 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -400,9 +400,10 @@ final storageReadyProvider = FutureProvider((ref) async { // Initialize device key signer — must be registered before any encrypted // queries fire, so EncryptableModel.prepareAfterLoading can find it. - final deviceKey = await ref - .read(deviceKeyServiceProvider) - .getOrCreatePrivateKey(); + final deviceKeyService = ref.read(deviceKeyServiceProvider); + final hasExistingDeviceKey = await deviceKeyService.hasPrivateKey(); + ref.read(isNewDeviceKeyProvider.notifier).state = !hasExistingDeviceKey; + final deviceKey = await deviceKeyService.getOrCreatePrivateKey(); final deviceSigner = Bip340PrivateKeySigner(deviceKey, ref); await deviceSigner.signIn(setAsActive: false); ref.read(devicePubkeyProvider.notifier).state = deviceSigner.pubkey; @@ -447,9 +448,6 @@ final appInitializationProvider = FutureProvider((ref) async { await _attemptAutoSignIn(ref); WidgetsBinding.instance.addPostFrameCallback((_) { - if (ref.read(Signer.activePubkeyProvider) == null) { - unawaited(maybeOfferInitialDeviceRestore(ref)); - } unawaited(ref.read(appCatalogRelayServiceProvider).checkForUpdates()); }); }); diff --git a/lib/screens/profile_screen.dart b/lib/screens/profile_screen.dart index fc62f60..f66ae8e 100644 --- a/lib/screens/profile_screen.dart +++ b/lib/screens/profile_screen.dart @@ -15,15 +15,19 @@ import 'package:zapstore/services/background_update_service.dart'; import 'package:url_launcher/url_launcher.dart'; import 'package:purplebase/purplebase.dart'; import 'package:zapstore/main.dart'; +import 'package:zapstore/services/device_backup_service.dart'; import 'package:zapstore/services/device_key_service.dart'; +import 'package:zapstore/services/device_private_sync_service.dart'; import 'package:zapstore/services/device_state_service.dart'; import 'package:zapstore/services/log_service.dart' as app_logs; import 'package:zapstore/services/package_manager/package_manager.dart'; import 'package:zapstore/services/settings_service.dart'; import 'package:zapstore/utils/extensions.dart'; +import 'package:zapstore/utils/debug_utils.dart'; import 'package:zapstore/utils/nostr_route.dart'; import 'package:zapstore/widgets/common/profile_identity_row.dart'; import 'package:zapstore/widgets/common/stack_link_card.dart'; +import 'package:zapstore/widgets/device_restore_dialog.dart'; import 'package:zapstore/theme.dart'; import 'package:zapstore/services/notification_service.dart'; import 'package:zapstore/widgets/common/note_parser.dart'; @@ -63,13 +67,13 @@ class ProfileScreen extends ConsumerWidget { const SizedBox(height: 16), - // App Catalog Relay Management Section - const RelayManagementCard(), + // Data Management Section + const _DataManagementSection(), const SizedBox(height: 16), - // Data Management Section - const _DataManagementSection(), + // App Catalog Relay Management Section + const RelayManagementCard(), const SizedBox(height: 24), @@ -1412,6 +1416,7 @@ class _DeviceKeyCard extends HookConsumerWidget { final shortened = '${npub.substring(0, 12)}...${npub.substring(npub.length - 8)}'; final isCopying = useState(false); + final isRestoring = useState(false); final powElapsed = useState(Duration.zero); useEffect(() { final startedAt = deviceState.startedAt; @@ -1510,55 +1515,148 @@ class _DeviceKeyCard extends HookConsumerWidget { ], ), ), - TextButton.icon( - onPressed: !deviceState.isReady || isCopying.value - ? null - : () async { - final shouldCopy = await showDialog( - context: context, - builder: (dialogContext) => AlertDialog( - title: const Text('Copy device private key?'), - content: const Text( - 'This nsec controls your private Zapstore data, including saved apps ' - 'and unmanaged apps. Anyone with it can read and modify that data.', - ), - actions: [ - TextButton( - onPressed: () => - Navigator.pop(dialogContext, false), - child: const Text('Cancel'), + Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextButton.icon( + onPressed: !deviceState.isReady || isCopying.value + ? null + : () async { + final shouldCopy = await showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: const Text('Copy device private key?'), + content: const Text( + 'This nsec controls your private Zapstore data, including saved apps ' + 'and unmanaged apps. Anyone with it can read and modify that data.', + ), + actions: [ + TextButton( + onPressed: () => + Navigator.pop(dialogContext, false), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () => + Navigator.pop(dialogContext, true), + child: const Text('Copy nsec'), + ), + ], ), - FilledButton( - onPressed: () => Navigator.pop(dialogContext, true), - child: const Text('Copy nsec'), - ), - ], - ), - ); - if (shouldCopy != true) return; - - isCopying.value = true; - try { - final nsec = await ref - .read(deviceKeyServiceProvider) - .getNsec(); - await Clipboard.setData(ClipboardData(text: nsec)); - if (context.mounted) { - context.showInfo('Device nsec copied'); - } - } catch (e) { - if (context.mounted) { - context.showError( - 'Could not copy device key', - technicalDetails: e.toString(), ); - } - } finally { - isCopying.value = false; - } - }, - icon: const Icon(Icons.copy, size: 18), - label: const Text('Copy nsec'), + if (shouldCopy != true) return; + + isCopying.value = true; + try { + final nsec = await ref + .read(deviceKeyServiceProvider) + .getNsec(); + await Clipboard.setData(ClipboardData(text: nsec)); + if (context.mounted) { + context.showInfo('Device nsec copied'); + } + } catch (e) { + if (context.mounted) { + context.showError( + 'Could not copy device key', + technicalDetails: e.toString(), + ); + } + } finally { + isCopying.value = false; + } + }, + icon: const Icon(Icons.copy, size: 18), + label: const Text('Copy nsec'), + ), + TextButton.icon( + onPressed: isRestoring.value + ? null + : () async { + final result = await showDialog( + context: context, + builder: (_) => const DeviceRestoreDialog(), + ); + if (result == null || !context.mounted) return; + + if (result.action == DeviceRestoreAction.amber) { + isRestoring.value = true; + try { + await ref + .read(deviceBackupServiceProvider) + .restoreFromAmber(ref: ref.read(refProvider)); + if (context.mounted) { + context.showInfo('Device key restored'); + } + } catch (error, stack) { + app_logs.LogService.I.warn( + 'Amber device key restore failed', + tag: 'backup', + err: error, + stack: stack, + ); + if (context.mounted) { + context.showError( + 'Could not restore device key', + technicalDetails: error.toString(), + ); + } + } finally { + isRestoring.value = false; + } + return; + } + + final privateKeyHex = ref + .read(deviceKeyServiceProvider) + .parsePrivateKey(result.key ?? ''); + if (privateKeyHex == null) { + context.showError('Enter a valid device nsec.'); + return; + } + + isRestoring.value = true; + try { + await ref + .read(deviceBackupServiceProvider) + .restoreDeviceKey( + ref: ref.read(refProvider), + privateKeyHex: privateKeyHex, + ); + await ref + .read(devicePrivateSyncProvider.notifier) + .syncRestoredKey(); + if (!ref.read(deviceStateProvider).isReady) { + unawaited( + ref + .read(deviceStateProvider.notifier) + .bootstrap(), + ); + } + if (context.mounted) { + context.showInfo('Device key restored'); + } + } catch (error, stack) { + app_logs.LogService.I.warn( + 'device key restore failed', + tag: 'backup', + err: error, + stack: stack, + ); + if (context.mounted) { + context.showError( + 'Could not restore device key', + technicalDetails: error.toString(), + ); + } + } finally { + isRestoring.value = false; + } + }, + icon: const Icon(Icons.restore, size: 18), + label: const Text('Restore'), + ), + ], ), ], ), diff --git a/lib/screens/search_screen.dart b/lib/screens/search_screen.dart index e96b629..5fc812a 100644 --- a/lib/screens/search_screen.dart +++ b/lib/screens/search_screen.dart @@ -10,6 +10,7 @@ import '../widgets/latest_releases_container.dart'; import '../widgets/search_app_card.dart'; import '../utils/extensions.dart'; import '../main.dart'; +import '../services/device_key_service.dart'; import '../services/package_manager/package_manager.dart'; /// Main search and app discovery screen @@ -67,58 +68,71 @@ class SearchScreen extends HookConsumerWidget { backgroundColor: Colors.transparent, body: Column( children: [ - // Professional search bar with better spacing + // Sticky search bar (+ optional device-key reminder) Container( padding: const EdgeInsets.fromLTRB(12, 0, 12, 18), - child: ValueListenableBuilder( - valueListenable: searchController, - builder: (context, value, _) { - final hasText = value.text.isNotEmpty; + child: Column( + children: [ + ValueListenableBuilder( + valueListenable: searchController, + builder: (context, value, _) { + final hasText = value.text.isNotEmpty; - return SearchBar( - controller: searchController, - focusNode: searchFocusNode, - hintText: 'Search apps', - leading: Icon( - Icons.search_rounded, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - trailing: [ - if (hasText) - IconButton( - icon: Icon( - Icons.clear_rounded, - color: Theme.of( - context, - ).colorScheme.onSurfaceVariant.withValues(alpha: 0.6), + return SearchBar( + controller: searchController, + focusNode: searchFocusNode, + hintText: 'Search apps', + leading: Icon( + Icons.search_rounded, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + trailing: [ + if (hasText) + IconButton( + icon: Icon( + Icons.clear_rounded, + color: Theme.of(context) + .colorScheme + .onSurfaceVariant + .withValues(alpha: 0.6), + ), + onPressed: () { + searchController.clear(); + searchQuery.value = ''; + searchFocusNode.requestFocus(); + }, + tooltip: 'Clear search', + ), + ], + onSubmitted: performSearch, + elevation: WidgetStateProperty.all(0), + backgroundColor: WidgetStateProperty.all( + Theme.of(context).colorScheme.surfaceContainerHighest + .withValues(alpha: 0.8), + ), + shape: WidgetStateProperty.all( + RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + side: BorderSide( + color: Theme.of( + context, + ).colorScheme.outline.withValues(alpha: 0.3), + width: 1, + ), ), - onPressed: () { - searchController.clear(); - searchQuery.value = ''; - searchFocusNode.requestFocus(); - }, - tooltip: 'Clear search', ), - ], - onSubmitted: performSearch, - elevation: WidgetStateProperty.all(0), - backgroundColor: WidgetStateProperty.all( - Theme.of(context).colorScheme.surfaceContainerHighest - .withValues(alpha: 0.8), + ); + }, + ), + if (ref.watch(isNewDeviceKeyProvider)) ...[ + const SizedBox(height: 12), + _NewDeviceKeyReminder( + onTap: () => context.go('/profile'), + onDismiss: () => + ref.read(isNewDeviceKeyProvider.notifier).state = false, ), - shape: WidgetStateProperty.all( - RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), - side: BorderSide( - color: Theme.of( - context, - ).colorScheme.outline.withValues(alpha: 0.3), - width: 1, - ), - ), - ), - ); - }, + ], + ], ), ), // Scrollable content @@ -163,6 +177,76 @@ class SearchScreen extends HookConsumerWidget { } } +class _NewDeviceKeyReminder extends StatelessWidget { + const _NewDeviceKeyReminder({required this.onTap, required this.onDismiss}); + + final VoidCallback onTap; + final VoidCallback onDismiss; + + @override + Widget build(BuildContext context) { + // Dark yellow (hue ~50°), not muddy brown — reads as yellow on dark UI. + const toastBackground = Color(0xFFC9A000); + const toastForeground = Colors.white; + + return Material( + color: toastBackground, + elevation: 2, + shadowColor: Colors.black38, + borderRadius: BorderRadius.circular(14), + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(14), + child: Padding( + padding: const EdgeInsets.fromLTRB(14, 12, 4, 12), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Padding( + padding: EdgeInsets.only(top: 1), + child: Icon( + Icons.vpn_key_outlined, + color: toastForeground, + size: 20, + ), + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Welcome to Zapstore', + style: Theme.of(context).textTheme.titleSmall?.copyWith( + color: toastForeground, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 3), + Text( + 'Used Zapstore before? Restore your device key via nsec or by signing in with Amber.', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: toastForeground.withValues(alpha: 0.92), + ), + ), + ], + ), + ), + IconButton( + onPressed: onDismiss, + tooltip: 'Dismiss', + icon: const Icon(Icons.close_rounded, color: toastForeground), + constraints: const BoxConstraints(minWidth: 48, minHeight: 48), + padding: EdgeInsets.zero, + ), + ], + ), + ), + ), + ); + } +} + class _SearchResultsSection extends HookConsumerWidget { const _SearchResultsSection({ required this.searchQuery, diff --git a/lib/services/device_backup_service.dart b/lib/services/device_backup_service.dart index 57e0b5f..2d6a741 100644 --- a/lib/services/device_backup_service.dart +++ b/lib/services/device_backup_service.dart @@ -13,15 +13,17 @@ import 'package:zapstore/services/device_private_event_service.dart'; import 'package:zapstore/services/device_private_sync_service.dart'; import 'package:zapstore/services/device_state_service.dart'; import 'package:zapstore/services/log_service.dart'; -import 'package:zapstore/services/settings_service.dart'; import 'package:zapstore/widgets/device_backup_dialog.dart'; -import 'package:zapstore/widgets/device_restore_dialog.dart'; /// Backs up the device key to the active Amber identity and restores it again. /// /// The relay record is authored by Amber, unlike portable device state which is /// always authored by the recovered device key. class DeviceBackupService { + Future? _amberRestore; + + bool get isRestoringFromAmber => _amberRestore != null; + /// Retained as a lifecycle hook for callers from the previous recovery /// implementation. Device-key backup has no long-lived foreground request. void beginWork() {} @@ -126,106 +128,74 @@ class DeviceBackupService { await signer.signIn(setAsActive: false); ref.read(devicePubkeyProvider.notifier).state = pubkey; } + + /// Signs in to Amber, verifies its backup, and offers to replace the key. + /// + /// This is explicitly user-initiated from Device key management. It prevents + /// the regular sign-in backup from overwriting the remote backup first. + Future restoreFromAmber({required Ref ref}) { + return _amberRestore ??= _restoreFromAmber(ref).whenComplete(() { + _amberRestore = null; + }); + } + + Future _restoreFromAmber(Ref ref) async { + await ref.read(amberSignerProvider).signIn(); + final amberSigner = ref.read(Signer.activeSignerProvider); + if (amberSigner == null) { + throw const DeviceBackupException('Could not sign in with Amber.'); + } + + final privateKeyHex = await fetchAmberBackup( + ref: ref, + amberSigner: amberSigner, + ); + if (privateKeyHex == null) { + throw const DeviceBackupException( + 'No device key backup was found for this Amber identity.', + ); + } + + final context = rootNavigatorKey.currentState?.overlay?.context; + if (context == null || !context.mounted) { + throw const DeviceBackupException('Restore screen is unavailable.'); + } + final restore = await showDialog( + context: context, + builder: (_) => DeviceBackupRestoreDialog( + onRestore: () => Navigator.of(context).pop(true), + onKeepCurrent: () => Navigator.of(context).pop(false), + ), + ); + if (restore != true) return; + + await restoreDeviceKey(ref: ref, privateKeyHex: privateKeyHex); + await ref.read(devicePrivateSyncProvider.notifier).syncRestoredKey(); + if (!ref.read(deviceStateProvider).isReady) { + unawaited(ref.read(deviceStateProvider.notifier).bootstrap()); + } + } } final deviceBackupServiceProvider = Provider( (ref) => DeviceBackupService(), ); -/// Runs the one-time recovery choice after the navigator overlay is available. -Future maybeOfferInitialDeviceRestore(Ref ref) async { - final settings = ref.read(settingsServiceProvider); - final temp = await settings.loadTemp(); - if (temp.restoreOnboardingComplete) return; - final context = rootNavigatorKey.currentState?.overlay?.context; - if (context == null || !context.mounted) return; - - final result = await showDialog( - context: context, - barrierDismissible: false, - builder: (_) => const DeviceRestoreDialog(), - ); - if (result == null) return; - - switch (result.action) { - case DeviceRestoreAction.startFresh: - await settings.saveTemp(temp.copyWith(restoreOnboardingComplete: true)); - unawaited(ref.read(deviceStateProvider.notifier).bootstrap()); - break; - case DeviceRestoreAction.pasteKey: - final privateKeyHex = ref - .read(deviceKeyServiceProvider) - .parsePrivateKey(result.key ?? ''); - if (privateKeyHex == null) { - LogService.I.warn('invalid pasted device key', tag: 'backup'); - return; - } - await ref - .read(deviceBackupServiceProvider) - .restoreDeviceKey(ref: ref, privateKeyHex: privateKeyHex); - await ref.read(devicePrivateSyncProvider.notifier).syncRestoredKey(); - final restored = await ref - .read(deviceStateProvider.notifier) - .restoreFromLocalEvent(); - if (!restored) { - unawaited(ref.read(deviceStateProvider.notifier).bootstrap()); - } - await settings.saveTemp(temp.copyWith(restoreOnboardingComplete: true)); - break; - case DeviceRestoreAction.amber: - await ref.read(amberSignerProvider).signIn(); - break; - } -} - -/// Offers Amber recovery only once per fresh local installation. +/// Backs up the current device key after a normal Amber sign-in. Future maybeOfferDeviceBackup(Ref ref) async { final amberSigner = ref.read(Signer.activeSignerProvider); if (amberSigner == null) return; - final settings = ref.read(settingsServiceProvider); - final temp = await settings.loadTemp(); final service = ref.read(deviceBackupServiceProvider); + if (service.isRestoringFromAmber) return; try { - if (!temp.restoreOnboardingComplete) { - final privateKeyHex = await service.fetchAmberBackup( - ref: ref, - amberSigner: amberSigner, - ); - if (privateKeyHex != null) { - final context = rootNavigatorKey.currentState?.overlay?.context; - if (context != null && context.mounted) { - final restore = await showDialog( - context: context, - barrierDismissible: false, - builder: (_) => DeviceBackupRestoreDialog( - onRestore: () => Navigator.of(context).pop(true), - onKeepCurrent: () => Navigator.of(context).pop(false), - ), - ); - if (restore == true) { - await service.restoreDeviceKey( - ref: ref, - privateKeyHex: privateKeyHex, - ); - await ref - .read(devicePrivateSyncProvider.notifier) - .syncRestoredKey(); - if (!ref.read(deviceStateProvider).isReady) { - unawaited(ref.read(deviceStateProvider.notifier).bootstrap()); - } - } - } - } - await settings.saveTemp(temp.copyWith(restoreOnboardingComplete: true)); - } await service.backupDeviceKey(ref: ref, amberSigner: amberSigner); if (!ref.read(deviceStateProvider).isReady) { unawaited(ref.read(deviceStateProvider.notifier).bootstrap()); } } catch (error, stack) { LogService.I.warn( - 'device key backup or recovery failed', + 'device key backup failed', tag: 'backup', err: error, stack: stack, diff --git a/lib/services/device_key_service.dart b/lib/services/device_key_service.dart index b6de1ee..01db869 100644 --- a/lib/services/device_key_service.dart +++ b/lib/services/device_key_service.dart @@ -12,6 +12,12 @@ const _kDeviceKey = 'device_key'; /// Manages the device private key. The nsec is intentionally isolated from /// portable settings and temporary local state. class DeviceKeyService { + /// Whether secure storage already contains a usable device key. + Future hasPrivateKey() async { + final existing = await _storage.read(key: _kDeviceKey); + return existing != null && existing.isNotEmpty; + } + /// Load existing device key or generate a new one. Returns hex private key. Future getOrCreatePrivateKey() async { final existing = await _storage.read(key: _kDeviceKey); @@ -113,3 +119,9 @@ final deviceKeyServiceProvider = Provider( /// The device pubkey (hex). Available after storageReadyProvider resolves. final devicePubkeyProvider = StateProvider((_) => null); + +/// Whether this process generated the device key for a fresh installation. +/// +/// This is intentionally session-only: once the new key is stored, subsequent +/// launches have no need to show the recovery reminder. +final isNewDeviceKeyProvider = StateProvider((_) => false); diff --git a/lib/services/settings_service.dart b/lib/services/settings_service.dart index be510e2..b89fadd 100644 --- a/lib/services/settings_service.dart +++ b/lib/services/settings_service.dart @@ -51,14 +51,12 @@ class TempSettings { final DateTime? seenUntil; final DateTime? deletionSyncedUntil; final LogLevel logLevel; - final bool restoreOnboardingComplete; const TempSettings({ this.lastAppOpened, this.seenUntil, this.deletionSyncedUntil, this.logLevel = LogLevel.debug, - this.restoreOnboardingComplete = false, }); factory TempSettings.fromJson(Map json) => TempSettings( @@ -66,8 +64,6 @@ class TempSettings { seenUntil: _parseDateTime(json['seenUntil']), deletionSyncedUntil: _parseDateTime(json['deletionSyncedUntil']), logLevel: LogLevel.parse(json['logLevel'] as String?) ?? LogLevel.debug, - restoreOnboardingComplete: - json['restoreOnboardingComplete'] as bool? ?? false, ); Map toJson() => { @@ -77,7 +73,6 @@ class TempSettings { if (deletionSyncedUntil != null) 'deletionSyncedUntil': deletionSyncedUntil!.millisecondsSinceEpoch, if (logLevel != LogLevel.debug) 'logLevel': logLevel.name, - if (restoreOnboardingComplete) 'restoreOnboardingComplete': true, }; TempSettings copyWith({ @@ -85,14 +80,11 @@ class TempSettings { DateTime? seenUntil, DateTime? deletionSyncedUntil, LogLevel? logLevel, - bool? restoreOnboardingComplete, }) => TempSettings( lastAppOpened: lastAppOpened ?? this.lastAppOpened, seenUntil: seenUntil ?? this.seenUntil, deletionSyncedUntil: deletionSyncedUntil ?? this.deletionSyncedUntil, logLevel: logLevel ?? this.logLevel, - restoreOnboardingComplete: - restoreOnboardingComplete ?? this.restoreOnboardingComplete, ); } @@ -125,7 +117,6 @@ class LocalSettings { bool? backgroundAutoUpdatesEnabled, Set? trustedSigners, LogLevel? logLevel, - bool? restoreOnboardingComplete, bool clearNwc = false, }) => LocalSettings( nwcConnectionString: clearNwc @@ -140,7 +131,6 @@ class LocalSettings { seenUntil: seenUntil, deletionSyncedUntil: deletionSyncedUntil, logLevel: logLevel, - restoreOnboardingComplete: restoreOnboardingComplete, ), ); } diff --git a/lib/widgets/device_restore_dialog.dart b/lib/widgets/device_restore_dialog.dart index 2c36c7c..0d6fdff 100644 --- a/lib/widgets/device_restore_dialog.dart +++ b/lib/widgets/device_restore_dialog.dart @@ -6,7 +6,7 @@ import 'package:zapstore/constants/app_constants.dart'; import 'package:zapstore/services/package_manager/package_manager.dart'; import 'package:zapstore/widgets/common/base_dialog.dart'; -enum DeviceRestoreAction { startFresh, pasteKey, amber } +enum DeviceRestoreAction { pasteKey, amber } class DeviceRestoreResult { const DeviceRestoreResult(this.action, {this.key}); @@ -15,7 +15,7 @@ class DeviceRestoreResult { final String? key; } -/// First-run choice for recovering an existing device identity. +/// Lets a user replace the current device identity with a recovered one. class DeviceRestoreDialog extends HookConsumerWidget { const DeviceRestoreDialog({super.key}); @@ -29,7 +29,7 @@ class DeviceRestoreDialog extends HookConsumerWidget { ); return BaseDialog( - title: const BaseDialogTitle('Restore device'), + title: const BaseDialogTitle('Restore device key'), titleIcon: const Icon(Icons.restore, size: 20), content: BaseDialogContent( children: [ @@ -38,10 +38,7 @@ class DeviceRestoreDialog extends HookConsumerWidget { 'device nsec or Amber.', ), const SizedBox(height: 8), - const Text( - 'Older local settings are not migrated. If you are updating, ' - 'copy your old device nsec before continuing.', - ), + const Text('Restoring replaces this device’s current key.'), const SizedBox(height: 16), TextField( controller: controller, @@ -88,11 +85,8 @@ class DeviceRestoreDialog extends HookConsumerWidget { ), actions: [ TextButton( - onPressed: () => Navigator.pop( - context, - const DeviceRestoreResult(DeviceRestoreAction.startFresh), - ), - child: const Text('Start fresh'), + onPressed: () => Navigator.pop(context), + child: const Text('Cancel'), ), ], ); diff --git a/spec/work/WORK-026-new-device-key-reminder.md b/spec/work/WORK-026-new-device-key-reminder.md new file mode 100644 index 0000000..cd3aeb0 --- /dev/null +++ b/spec/work/WORK-026-new-device-key-reminder.md @@ -0,0 +1,49 @@ +# WORK-026 - New Device Key Reminder + +**Feature:** FEAT-006-device-key.md +**Status:** In Progress + +## Tasks + +- [x] 1. Detect a missing device key before generating the fresh key + - Files: `lib/main.dart`, `lib/services/device_key_service.dart` + - Keep the result in memory for the current app session only. +- [x] 2. Replace the startup restore dialog with an inline reminder + - Files: `lib/screens/search_screen.dart`, `lib/services/device_backup_service.dart` + - Render the reminder between search and stacks only for a newly generated key. +- [x] 3. Support restoring a pasted nsec from Device key management + - Files: `lib/screens/profile_screen.dart`, `lib/widgets/device_restore_dialog.dart` + `lib/services/device_backup_service.dart` + - Validate, replace, and sync a pasted-nsec or Amber-restored key without + blocking the UI. +- [x] 4. Verify analysis and targeted tests + - `fvm flutter test test/services/device_key_service_test.dart + test/services/device_backup_service_test.dart + test/services/settings_service_test.dart` + - `HOME=/tmp fvm flutter analyze` + +## Verification + +- The startup flow captures `device_key` presence before generating the key; + only an absent key enables the session-only reminder. +- Pasting an invalid nsec surfaces an error without replacing the current key. +- A successful restore replaces the device key before the one-shot private-data + synchronization begins. + +## Decisions + +### 2026-07-15 - New-key reminder is session-only + +**Context:** A first-run restore dialog blocks discovery and requires users to +choose a recovery method before using the store. + +**Decision:** Capture whether `device_key` was absent before creating it, then +show a non-interactive reminder for the current app session. The key's presence +suppresses it on later launches; no onboarding state is written. + +**Rationale:** Device-key recovery remains accessible from Data Management at +any time without treating Amber as a prerequisite or gating the UI. + +The obsolete persisted `restoreOnboardingComplete` value is removed. Amber +recovery is now explicitly initiated from Device key management, which prevents +normal Amber sign-in backup from overwriting a recovery record first.