From cb470bf67ddda7ab682bff862d6c3258ad5cb06d Mon Sep 17 00:00:00 2001 From: franzap <_@franzap.com> Date: Fri, 10 Apr 2026 22:24:49 -0300 Subject: [PATCH] Improve settings storage, save installed apps and show private stacks --- lib/main.dart | 22 +- lib/screens/app_stack_screen.dart | 212 +++++++++++++++---- lib/screens/profile_screen.dart | 195 +++++------------- lib/screens/user_screen.dart | 66 +----- lib/services/background_update_service.dart | 16 +- lib/services/deletion_processor.dart | 13 +- lib/services/secure_storage_service.dart | 216 -------------------- lib/services/settings_service.dart | 191 +++++++++++++++++ lib/services/updates_service.dart | 10 +- lib/widgets/app_stack_container.dart | 52 ++--- lib/widgets/common/stack_link_card.dart | 132 ++++++++++++ lib/widgets/nwc_widgets.dart | 24 ++- lib/widgets/relay_management_card.dart | 17 +- lib/widgets/zap_widgets.dart | 20 +- 14 files changed, 643 insertions(+), 543 deletions(-) delete mode 100644 lib/services/secure_storage_service.dart create mode 100644 lib/services/settings_service.dart create mode 100644 lib/widgets/common/stack_link_card.dart diff --git a/lib/main.dart b/lib/main.dart index 17f836c..0f7758d 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -14,7 +14,7 @@ import 'package:amber_signer/amber_signer.dart'; import 'package:zapstore/services/app_restart_service.dart'; import 'package:zapstore/services/background_update_service.dart'; import 'package:zapstore/services/notification_service.dart'; -import 'package:zapstore/services/secure_storage_service.dart'; +import 'package:zapstore/services/settings_service.dart'; import 'package:zapstore/router.dart'; import 'package:zapstore/services/package_manager/package_manager.dart'; import 'package:zapstore/theme.dart'; @@ -242,9 +242,8 @@ final appInitializationProvider = FutureProvider((ref) async { // Load local relay config BEFORE storage init // This ensures custom relays work even when signed out - final secureStorage = ref.read(secureStorageServiceProvider); - final localRelays = await secureStorage.getAppCatalogRelays(); - final appCatalogRelays = localRelays ?? {_kDefaultAppCatalogRelay}; + final settings = await ref.read(settingsServiceProvider).load(); + final appCatalogRelays = settings.appCatalogRelays ?? {_kDefaultAppCatalogRelay}; // Initialize storage with local relay config await ref.read( @@ -275,7 +274,9 @@ final appInitializationProvider = FutureProvider((ref) async { await DeviceCapabilitiesCache.initialize(); // Record app open time for background notification throttling - await secureStorage.setLastAppOpenedTime(DateTime.now()); + await ref.read(settingsServiceProvider).update( + (s) => s.copyWith(lastAppOpened: DateTime.now()), + ); // Ensure installed packages are available before anything categorizes final packageManager = ref.read(packageManagerProvider.notifier); @@ -303,10 +304,10 @@ Future _maybeCopySeedDatabase(String dbPath) async { final dbFile = File(dbPath); if (dbFile.existsSync()) return; - final customRelays = await SecureStorageService().getAppCatalogRelays(); - final isDefault = customRelays == null || - (customRelays.length == 1 && - customRelays.contains(_kDefaultAppCatalogRelay)); + final settings = await SettingsService().load(); + final isDefault = settings.appCatalogRelays == null || + (settings.appCatalogRelays!.length == 1 && + settings.appCatalogRelays!.contains(_kDefaultAppCatalogRelay)); if (!isDefault) return; try { @@ -402,7 +403,6 @@ class _AppLifecycleObserver with WidgetsBindingObserver { /// Record that the user opened the app. /// This is used to check inactivity for background notifications. Future _recordAppOpened() async { - final secureStorage = SecureStorageService(); - await secureStorage.setLastAppOpenedTime(DateTime.now()); + await SettingsService().update((s) => s.copyWith(lastAppOpened: DateTime.now())); } } diff --git a/lib/screens/app_stack_screen.dart b/lib/screens/app_stack_screen.dart index 1e0d026..0d0d213 100644 --- a/lib/screens/app_stack_screen.dart +++ b/lib/screens/app_stack_screen.dart @@ -1,5 +1,8 @@ +import 'dart:convert'; + import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:models/models.dart'; import 'package:skeletonizer/skeletonizer.dart'; @@ -44,12 +47,17 @@ class AppStackScreen extends HookConsumerWidget { final stack = stackState.models.firstOrNull; if (stack == null) { - return Scaffold( - body: SingleChildScrollView( - padding: const EdgeInsets.all(16), - child: _AppStackSkeleton(), - ), - ); + // Still loading — show skeleton + if (stackState is StorageLoading) { + return Scaffold( + body: SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: _AppStackSkeleton(), + ), + ); + } + // Loading complete but stack not found + return _NotFoundScaffold(stackId: stackId); } return _AppStackContentWithApps(stack: stack); @@ -64,10 +72,63 @@ class _AppStackContentWithApps extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final appAddressableIds = stack.event - .getTagSetValues('a') - .where((id) => id.startsWith('32267:')) - .toSet(); + final isEncrypted = stack.content.isNotEmpty; + + // For encrypted stacks, decrypt content to get app IDs + final decryptedAppIds = useState?>(null); + final decryptError = useState(null); + + useEffect(() { + if (!isEncrypted) return null; + + Future decrypt() async { + final signer = ref.read(Signer.activeSignerProvider); + final pubkey = ref.read(Signer.activePubkeyProvider); + + if (signer == null || pubkey == null) { + decryptError.value = 'Sign in required to view this stack'; + return; + } + + try { + final decrypted = await signer.nip44Decrypt(stack.content, pubkey); + final ids = (jsonDecode(decrypted) as List).cast().toSet(); + decryptedAppIds.value = ids; + } catch (e) { + decryptError.value = 'Failed to decrypt stack'; + } + } + + decrypt(); + return null; + }, [stack.content]); + + // Handle decrypt error + if (decryptError.value != null) { + return _AppStackContent( + stack: stack, + apps: const [], + errorMessage: decryptError.value, + ); + } + + // For encrypted stacks, wait for decryption + if (isEncrypted && decryptedAppIds.value == null) { + return Scaffold( + body: SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: _AppStackSkeleton(), + ), + ); + } + + // Get app addressable IDs from either tags (public) or decrypted content (private) + final appAddressableIds = isEncrypted + ? decryptedAppIds.value! + : stack.event + .getTagSetValues('a') + .where((id) => id.startsWith('32267:')) + .toSet(); if (appAddressableIds.isEmpty) { return _AppStackContent(stack: stack, apps: const []); @@ -139,12 +200,58 @@ class _ErrorScaffold extends StatelessWidget { } } +class _NotFoundScaffold extends StatelessWidget { + final String stackId; + const _NotFoundScaffold({required this.stackId}); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('App Stack')), + body: Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.apps_outlined, + size: 64, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + const SizedBox(height: 16), + Text( + 'Stack not found', + style: Theme.of(context).textTheme.titleLarge, + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + Text( + 'This stack may have been deleted or is not available', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + textAlign: TextAlign.center, + ), + ], + ), + ), + ), + ); + } +} + /// Internal widget that displays stack details class _AppStackContent extends HookConsumerWidget { final AppStack stack; final List apps; + final String? errorMessage; - const _AppStackContent({required this.stack, required this.apps}); + const _AppStackContent({ + required this.stack, + required this.apps, + this.errorMessage, + }); @override Widget build(BuildContext context, WidgetRef ref) { @@ -211,11 +318,12 @@ class _AppStackContent extends HookConsumerWidget { ...sortedApps.map( (app) => AppCard(app: app, showUpdateArrow: app.hasUpdate), ), - // Comments section - Padding( - padding: const EdgeInsets.only(top: 24), - child: StackCommentsSection(stack: stack), - ), + // Comments section - hidden for private/encrypted stacks + if (stack.content.isEmpty) + Padding( + padding: const EdgeInsets.only(top: 24), + child: StackCommentsSection(stack: stack), + ), ], ), ), @@ -261,15 +369,36 @@ class _StackHeader extends StatelessWidget { final Profile? author; final bool isAuthorLoading; + bool get _isEncrypted => stack.content.isNotEmpty; + @override Widget build(BuildContext context) { + final subtitleColor = Theme.of(context).colorScheme.onSurfaceVariant; + final subtitleStyle = Theme.of(context).textTheme.bodySmall?.copyWith( + color: subtitleColor, + ); + return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - // Stack name - Text( - stack.name ?? stack.identifier, - style: context.textTheme.headlineMedium, + // Stack name with padlock for private stacks + Row( + children: [ + Flexible( + child: Text( + stack.name ?? stack.identifier, + style: context.textTheme.headlineMedium, + ), + ), + if (_isEncrypted) ...[ + const SizedBox(width: 8), + Icon( + Icons.lock, + size: 20, + color: Theme.of(context).colorScheme.onSurface, + ), + ], + ], ), const SizedBox(height: 8), // Published by author - always show, with fallback to npub @@ -283,27 +412,34 @@ class _StackHeader extends StatelessWidget { onTap: () => pushUser(context, stack.pubkey), ), const SizedBox(height: 4), - // Last updated timestamp + // Metadata row: updated timestamp + private indicator Row( children: [ - Icon( - Icons.update, - size: 14, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), + Icon(Icons.update, size: 14, color: subtitleColor), const SizedBox(width: 4), - Text( - 'Updated ', - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - TimeAgoText( - stack.event.createdAt, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), + Text('Updated ', style: subtitleStyle), + TimeAgoText(stack.event.createdAt, style: subtitleStyle), + if (_isEncrypted) ...[ + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: subtitleColor.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(12), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.lock_outline, size: 12, color: subtitleColor), + const SizedBox(width: 4), + Text( + 'Private', + style: subtitleStyle?.copyWith(fontWeight: FontWeight.w600), + ), + ], + ), + ), + ], ], ), if (stack.description case final description?) ...[ diff --git a/lib/screens/profile_screen.dart b/lib/screens/profile_screen.dart index fdefb98..9b2eef4 100644 --- a/lib/screens/profile_screen.dart +++ b/lib/screens/profile_screen.dart @@ -14,13 +14,12 @@ import 'package:zapstore/services/app_restart_service.dart'; 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/package_manager/package_manager.dart'; -import 'package:zapstore/services/secure_storage_service.dart'; +import 'package:zapstore/services/settings_service.dart'; import 'package:zapstore/utils/extensions.dart'; import 'package:zapstore/utils/nostr_route.dart'; import 'package:zapstore/widgets/common/profile_identity_row.dart'; -import 'package:zapstore/widgets/app_card.dart'; +import 'package:zapstore/widgets/common/stack_link_card.dart'; import 'package:zapstore/theme.dart'; import 'package:zapstore/services/notification_service.dart'; import 'package:zapstore/widgets/common/note_parser.dart'; @@ -42,13 +41,8 @@ class ProfileScreen extends ConsumerWidget { const SizedBox(height: 24), - // Saved Apps Heading - const _SavedAppsHeading(), - - const SizedBox(height: 16), - - // Saved Apps Section - const _SavedAppsSection(), + // User Stacks Section (Saved Apps + Installed Apps) + const _UserStacksSection(), const SizedBox(height: 24), @@ -1404,8 +1398,8 @@ class _InstalledAppsBackupToggle extends ConsumerWidget { final pubkey = ref.watch(Signer.activePubkeyProvider); if (pubkey == null) return const SizedBox.shrink(); - final backupAsync = ref.watch(installedAppsBackupEnabledProvider); - final enabled = backupAsync.valueOrNull ?? false; + final settingsAsync = ref.watch(localSettingsProvider); + final enabled = settingsAsync.valueOrNull?.installedAppsBackupEnabled ?? false; return SwitchListTile( secondary: CircleAvatar( @@ -1419,9 +1413,9 @@ class _InstalledAppsBackupToggle extends ConsumerWidget { contentPadding: EdgeInsets.zero, onChanged: (value) async { await ref - .read(secureStorageServiceProvider) - .setInstalledAppsBackupEnabled(value); - ref.invalidate(installedAppsBackupEnabledProvider); + .read(settingsServiceProvider) + .update((s) => s.copyWith(installedAppsBackupEnabled: value)); + ref.invalidate(localSettingsProvider); }, ); } @@ -1551,149 +1545,64 @@ class _DataManagementSection extends ConsumerWidget { } } -class _SavedAppsHeading extends ConsumerWidget { - const _SavedAppsHeading(); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final signedInPubkey = ref.watch(Signer.activePubkeyProvider); - - if (signedInPubkey == null) { - return const SizedBox.shrink(); - } - - // Always show heading when signed in - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 16.0), - child: Text('Saved Apps', style: context.textTheme.headlineSmall), - ); - } -} - -class _SavedAppsSection extends ConsumerWidget { - const _SavedAppsSection(); +class _UserStacksSection extends ConsumerWidget { + const _UserStacksSection(); @override Widget build(BuildContext context, WidgetRef ref) { final signedInPubkey = ref.watch(Signer.activePubkeyProvider); if (signedInPubkey == null) return const SizedBox.shrink(); - final savedAppsAsync = ref.watch(bookmarksProvider); - - // Keep previous value during refresh, if available. - final addressableIds = savedAppsAsync.valueOrNull; - - // Show loading only on first load (when no value exists yet). - if (addressableIds == null) { - return _savedAppsLoadingCard(context); - } - - final identifiers = _toIdentifiers(addressableIds); - return _SavedAppsList(identifiers: identifiers); - } - - Set _toIdentifiers(Set addressableIds) { - return addressableIds - .map((id) => id.split(':')) - .where((parts) => parts.length >= 3) - .map((parts) => parts[2]) - .toSet(); - } - - Widget _savedAppsLoadingCard(BuildContext context) => Card( - child: Padding( - padding: const EdgeInsets.all(16), - child: Center( - child: CircularProgressIndicator( - color: Theme.of(context).colorScheme.primary, - ), - ), - ), - ); -} - -class _SavedAppsList extends ConsumerWidget { - const _SavedAppsList({required this.identifiers}); - - final Set identifiers; - - @override - Widget build(BuildContext context, WidgetRef ref) { - // No bookmarks saved - show empty state without querying - if (identifiers.isEmpty) { - return _emptyState(context); - } - - final savedAppsState = ref.watch( - query( - tags: {'#d': identifiers}, - and: (app) => { - app.latestAsset.query(), - app.latestRelease.query( - and: (release) => {release.latestMetadata.query()}, - ), - }, + // Query for ALL user's stacks (we'll filter to encrypted ones) + final stacksState = ref.watch( + query( + authors: {signedInPubkey}, source: const LocalAndRemoteSource(relays: 'AppCatalog', stream: false), - subscriptionPrefix: 'app-profile-saved-apps', + subscriptionPrefix: 'app-profile-user-stacks', ), ); - final isLoading = savedAppsState is StorageLoading; + // Filter to only encrypted (private) stacks + final privateStacks = stacksState.models + .where((s) => s.content.isNotEmpty) + .toList(); - final savedApps = savedAppsState.models.toList() - ..sort( - (a, b) => (a.name ?? a.identifier).toLowerCase().compareTo( - (b.name ?? b.identifier).toLowerCase(), - ), - ); - - // Show spinner only when we truly have nothing to render yet - // If we're refreshing but still have models, keep showing the list - if (isLoading && savedApps.isEmpty) { - return _loadingCard(context); + // Don't show section if no private stacks exist + if (privateStacks.isEmpty) { + return const SizedBox.shrink(); } - if (savedApps.isEmpty) { - return _emptyState(context); - } + // Sort: Saved Apps first, then Installed Apps, then others alphabetically + privateStacks.sort((a, b) { + if (a.identifier == kAppBookmarksIdentifier) return -1; + if (b.identifier == kAppBookmarksIdentifier) return 1; + if (a.identifier == kInstalledAppsBackupIdentifier) return -1; + if (b.identifier == kInstalledAppsBackupIdentifier) return 1; + return (a.name ?? a.identifier).compareTo(b.name ?? b.identifier); + }); - return Card( - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - for (final app in savedApps) - AppCard(app: app, showUpdateArrow: false, showDescription: false), - ], + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: Text('Your Private Stacks', style: context.textTheme.headlineSmall), ), - ), - ); - } - - Widget _loadingCard(BuildContext context) { - return Card( - child: Padding( - padding: const EdgeInsets.all(16), - child: Center( - child: CircularProgressIndicator( - color: Theme.of(context).colorScheme.primary, - ), - ), - ), - ); - } - - Widget _emptyState(BuildContext context) { - final theme = Theme.of(context); - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: Text( - 'No saved apps yet', - style: theme.textTheme.bodyMedium?.copyWith( - color: theme.colorScheme.onSurface.withValues(alpha: 0.5), - ), - ), + const SizedBox(height: 16), + ...privateStacks.asMap().entries.map((entry) { + final index = entry.key; + final stack = entry.value; + final displayName = stack.identifier == kAppBookmarksIdentifier + ? 'Saved Apps' + : stack.identifier == kInstalledAppsBackupIdentifier + ? 'Installed Apps' + : null; + return Padding( + padding: EdgeInsets.only(top: index > 0 ? 8 : 0), + child: StackLinkCard(stack: stack, displayName: displayName), + ); + }), + ], ); } } diff --git a/lib/screens/user_screen.dart b/lib/screens/user_screen.dart index f46a601..01e4735 100644 --- a/lib/screens/user_screen.dart +++ b/lib/screens/user_screen.dart @@ -2,8 +2,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:models/models.dart'; +import 'package:zapstore/widgets/common/stack_link_card.dart'; import 'package:zapstore/utils/extensions.dart'; -import 'package:zapstore/utils/nostr_route.dart'; import '../theme.dart'; import '../widgets/common/note_parser.dart'; import '../widgets/common/profile_identity_row.dart'; @@ -132,7 +132,10 @@ class UserScreen extends HookConsumerWidget { SliverList( delegate: SliverChildBuilderDelegate((context, index) { final stack = stacks[index]; - return _StackLinkCard(stack: stack, pubkey: pubkey); + return Padding( + padding: const EdgeInsets.only(top: 8), + child: StackLinkCard(stack: stack), + ); }, childCount: stacks.length), ), ], @@ -259,65 +262,6 @@ class _UserZapsList extends HookConsumerWidget { } } -class _StackLinkCard extends StatelessWidget { - const _StackLinkCard({required this.stack, required this.pubkey}); - - final AppStack stack; - final String pubkey; - - @override - Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.fromLTRB(16, 8, 16, 8), - child: InkWell( - onTap: () => pushStack( - context, - stack.identifier, - author: pubkey, - kind: stack.event.kind, - ), - borderRadius: BorderRadius.circular(12), - child: Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surfaceContainerHighest, - borderRadius: BorderRadius.circular(12), - border: Border.all( - color: Theme.of( - context, - ).colorScheme.outline.withValues(alpha: 0.2), - ), - ), - child: Row( - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - stack.name ?? stack.identifier, - style: context.textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w600, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ], - ), - ), - const SizedBox(width: 12), - Icon( - Icons.chevron_right, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ], - ), - ), - ), - ); - } -} - class _UserBio extends HookWidget { const _UserBio({required this.profile}); diff --git a/lib/services/background_update_service.dart b/lib/services/background_update_service.dart index a886e6e..38aa6e2 100644 --- a/lib/services/background_update_service.dart +++ b/lib/services/background_update_service.dart @@ -18,7 +18,7 @@ import 'package:zapstore/services/package_manager/background_package_manager.dar import 'package:zapstore/services/package_manager/dummy_package_manager.dart'; import 'package:zapstore/services/package_manager/package_manager.dart'; import 'package:zapstore/services/catalog_fetcher.dart'; -import 'package:zapstore/services/secure_storage_service.dart'; +import 'package:zapstore/services/settings_service.dart'; import 'package:zapstore/utils/extensions.dart'; /// Unique task name for background update checking @@ -234,17 +234,17 @@ Future _showUpdateNotificationIfNeeded( List updates, Map installables, ) async { - final secureStorage = SecureStorageService(); + final settingsService = SettingsService(); + final settings = await settingsService.load(); // Skip if user recently opened the app - final lastOpened = await secureStorage.getLastAppOpenedTime(); - if (lastOpened != null && - DateTime.now().difference(lastOpened) < _inactivityThreshold) { + if (settings.lastAppOpened != null && + DateTime.now().difference(settings.lastAppOpened!) < _inactivityThreshold) { return; } // Get the "seen until" timestamp - updates with createdAt > this are new - final seenUntil = await secureStorage.getSeenUntil(); + final seenUntil = settings.seenUntil; // Filter to only updates that are genuinely new: // - installable.createdAt > seenUntil (not already notified via background) @@ -261,7 +261,7 @@ Future _showUpdateNotificationIfNeeded( } // Must be newer than last app open (if any) - user may have seen it in UI - if (lastOpened != null && !releaseTime.isAfter(lastOpened)) { + if (settings.lastAppOpened != null && !releaseTime.isAfter(settings.lastAppOpened!)) { return false; } @@ -307,7 +307,7 @@ Future _showUpdateNotificationIfNeeded( ); // Update seenUntil to now - future checks will only notify about releases after this - await secureStorage.setSeenUntil(DateTime.now()); + await settingsService.update((s) => s.copyWith(seenUntil: DateTime.now())); } /// Service for managing background update checks diff --git a/lib/services/deletion_processor.dart b/lib/services/deletion_processor.dart index eaa444f..e6e7a9b 100644 --- a/lib/services/deletion_processor.dart +++ b/lib/services/deletion_processor.dart @@ -1,7 +1,7 @@ import 'package:models/models.dart'; import 'package:purplebase/purplebase.dart'; import 'package:zapstore/constants/app_constants.dart'; -import 'package:zapstore/services/secure_storage_service.dart'; +import 'package:zapstore/services/settings_service.dart'; /// Fetches NIP-09 kind-5 deletion events from the AppCatalog relay since the /// last sync cursor, applies them to local storage, and advances the cursor. @@ -15,14 +15,16 @@ import 'package:zapstore/services/secure_storage_service.dart'; /// original author — those targets are deleted explicitly here. Future processDeletions({ required PurplebaseStorageNotifier storage, - required SecureStorageService secureStorage, + required SettingsService settingsService, required String subscriptionPrefix, }) async { - final lastSync = await secureStorage.getDeletionsSyncedUntil(); + final settings = await settingsService.load(); + final lastSync = settings.deletionSyncedUntil; // First run: no local data to delete, just seed the cursor. if (lastSync == null) { - await secureStorage.setDeletionsSyncedUntil(DateTime.now()); + await settingsService.update( + (s) => s.copyWith(deletionSyncedUntil: DateTime.now())); return; } @@ -35,7 +37,8 @@ Future processDeletions({ subscriptionPrefix: subscriptionPrefix, ); - await secureStorage.setDeletionsSyncedUntil(DateTime.now()); + await settingsService.update( + (s) => s.copyWith(deletionSyncedUntil: DateTime.now())); if (deletionRequests.isEmpty) return; diff --git a/lib/services/secure_storage_service.dart b/lib/services/secure_storage_service.dart deleted file mode 100644 index 99174b4..0000000 --- a/lib/services/secure_storage_service.dart +++ /dev/null @@ -1,216 +0,0 @@ -import 'dart:convert'; - -import 'package:amber_signer/amber_signer.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:flutter_secure_storage/flutter_secure_storage.dart'; - -/// Service for securely storing sensitive data (NWC connection strings, -/// app catalog relays) using platform-native secure storage -/// (Keychain on iOS, KeyStore on Android). -/// -/// This does NOT require user authentication - data is encrypted at rest -/// by the platform's secure storage mechanism. -class SecureStorageService { - SecureStorageService(); - - // Use explicit options for reliability across platforms - static final _storage = FlutterSecureStorage( - aOptions: const AndroidOptions(encryptedSharedPreferences: true), - iOptions: const IOSOptions( - accessibility: KeychainAccessibility.first_unlock, - ), - ); - - static const _nwcKey = 'nwc_connection_string'; - static const _appCatalogRelaysKey = 'app_catalog_relays'; - - /// Get the stored NWC connection string - Future getNWCString() async { - final value = await _storage.read(key: _nwcKey); - return (value?.isNotEmpty == true) ? value : null; - } - - /// Store an NWC connection string - Future setNWCString(String connectionString) async { - await _storage.write(key: _nwcKey, value: connectionString); - } - - /// Clear the stored NWC connection string - Future clearNWCString() async { - await _storage.delete(key: _nwcKey); - } - - /// Check if an NWC connection string is stored - Future hasNWCString() async { - final value = await _storage.read(key: _nwcKey); - return value?.isNotEmpty == true; - } - - // ========================================================================= - // App Open Tracking (for background notification throttling) - // ========================================================================= - - static const _lastAppOpenedKey = 'last_app_opened'; - - /// Get the last time the user opened the app. - Future getLastAppOpenedTime() async { - final value = await _storage.read(key: _lastAppOpenedKey); - if (int.tryParse(value ?? '') case final ms?) { - return DateTime.fromMillisecondsSinceEpoch(ms); - } - return null; - } - - /// Store the last app opened time. - Future setLastAppOpenedTime(DateTime time) async { - await _storage.write( - key: _lastAppOpenedKey, - value: '${time.millisecondsSinceEpoch}', - ); - } - - // ========================================================================= - // Seen Until Timestamp (for background notification deduplication) - // ========================================================================= - - static const _seenUntilKey = 'seen_until'; - - /// Get the "seen until" timestamp. - /// Updates with release.createdAt <= this timestamp have already been notified. - Future getSeenUntil() async { - final value = await _storage.read(key: _seenUntilKey); - if (int.tryParse(value ?? '') case final ms?) { - return DateTime.fromMillisecondsSinceEpoch(ms); - } - return null; - } - - /// Store the "seen until" timestamp. - /// Called when a notification is shown, set to now() so future checks - /// only notify about releases created after this time. - Future setSeenUntil(DateTime time) async { - await _storage.write( - key: _seenUntilKey, - value: '${time.millisecondsSinceEpoch}', - ); - } - - // ========================================================================= - // App Catalog Relays - // ========================================================================= - - /// Get the stored app catalog relay URLs. - /// - /// Returns null if no relays have been stored (use defaults). - /// Returns empty set if user explicitly cleared all relays (invalid state, - /// but handled gracefully). - Future?> getAppCatalogRelays() async { - final json = await _storage.read(key: _appCatalogRelaysKey); - if (json == null || json.isEmpty) return null; - try { - final list = jsonDecode(json) as List; - return Set.from(list.cast()); - } catch (e) { - // Corrupted data - treat as unset - return null; - } - } - - // ========================================================================= - // Deletion Sync Timestamp (for NIP-09 incremental checks) - // ========================================================================= - - static const _deletionSyncedUntilKey = 'deletion_synced_until'; - - /// Get the timestamp of the last successful NIP-09 deletion sync. - /// Used as `since` on the next kind-5 query so checks are incremental. - Future getDeletionsSyncedUntil() async { - final value = await _storage.read(key: _deletionSyncedUntilKey); - if (int.tryParse(value ?? '') case final ms?) { - return DateTime.fromMillisecondsSinceEpoch(ms); - } - return null; - } - - /// Persist the deletion sync cursor after a successful kind-5 fetch. - Future setDeletionsSyncedUntil(DateTime time) async { - await _storage.write( - key: _deletionSyncedUntilKey, - value: '${time.millisecondsSinceEpoch}', - ); - } - - // ========================================================================= - // App Catalog Relays - // ========================================================================= - - /// Store app catalog relay URLs. - /// - /// This is the local source of truth for relay configuration, - /// used to initialize the app before sign-in. - Future setAppCatalogRelays(Set relays) async { - await _storage.write( - key: _appCatalogRelaysKey, - value: jsonEncode(relays.toList()), - ); - } - - // ========================================================================= - // Installed Apps Backup - // ========================================================================= - - static const _installedAppsBackupKey = 'installed_apps_backup_enabled'; - - Future isInstalledAppsBackupEnabled() async { - final value = await _storage.read(key: _installedAppsBackupKey); - return value == 'true'; - } - - Future setInstalledAppsBackupEnabled(bool enabled) async { - await _storage.write(key: _installedAppsBackupKey, value: '$enabled'); - } -} - -/// Persists the AmberSigner pubkey in flutter_secure_storage. -/// This survives app data clears (database deletion) and is encrypted. -class SecureStoragePubkeyPersistence implements AmberPubkeyPersistence { - static const _key = 'amber_pubkey'; - - @override - Future persistPubkey(String pubkey) async { - await SecureStorageService._storage.write(key: _key, value: pubkey); - } - - @override - Future loadPubkey() async { - return SecureStorageService._storage.read(key: _key); - } - - @override - Future clearPubkey() async { - await SecureStorageService._storage.delete(key: _key); - } -} - -final secureStorageServiceProvider = Provider( - (ref) => SecureStorageService(), -); - -/// Whether an NWC connection string is currently stored. -/// -/// Use `ref.invalidate(hasNwcStringProvider)` after updating or clearing the -/// stored string to refresh UI. -final hasNwcStringProvider = FutureProvider.autoDispose((ref) async { - final secureStorage = ref.watch(secureStorageServiceProvider); - return secureStorage.hasNWCString(); -}); - -/// Whether the installed apps backup setting is enabled. -/// -/// Use `ref.invalidate(installedAppsBackupEnabledProvider)` after toggling -/// the setting to refresh UI. -final installedAppsBackupEnabledProvider = - FutureProvider.autoDispose((ref) async { - final secureStorage = ref.watch(secureStorageServiceProvider); - return secureStorage.isInstalledAppsBackupEnabled(); -}); diff --git a/lib/services/settings_service.dart b/lib/services/settings_service.dart new file mode 100644 index 0000000..113e528 --- /dev/null +++ b/lib/services/settings_service.dart @@ -0,0 +1,191 @@ +import 'dart:convert'; + +import 'package:amber_signer/amber_signer.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; + +const _storage = FlutterSecureStorage( + aOptions: AndroidOptions(encryptedSharedPreferences: true), + iOptions: IOSOptions(accessibility: KeychainAccessibility.first_unlock), +); + +/// All local settings stored as a single JSON blob in secure storage. +class LocalSettings { + final String? nwcConnectionString; + final Set? appCatalogRelays; + final DateTime? lastAppOpened; + final DateTime? seenUntil; + final DateTime? deletionSyncedUntil; + final bool installedAppsBackupEnabled; + + const LocalSettings({ + this.nwcConnectionString, + this.appCatalogRelays, + this.lastAppOpened, + this.seenUntil, + this.deletionSyncedUntil, + this.installedAppsBackupEnabled = false, + }); + + bool get hasNwcString => nwcConnectionString?.isNotEmpty == true; + + factory LocalSettings.fromJson(Map json) { + return LocalSettings( + nwcConnectionString: json['nwc'] as String?, + appCatalogRelays: (json['relays'] as List?)?.cast().toSet(), + lastAppOpened: _parseDateTime(json['lastAppOpened']), + seenUntil: _parseDateTime(json['seenUntil']), + deletionSyncedUntil: _parseDateTime(json['deletionSyncedUntil']), + installedAppsBackupEnabled: json['backupEnabled'] as bool? ?? false, + ); + } + + Map toJson() => { + if (nwcConnectionString != null) 'nwc': nwcConnectionString, + if (appCatalogRelays != null) 'relays': appCatalogRelays!.toList(), + if (lastAppOpened != null) + 'lastAppOpened': lastAppOpened!.millisecondsSinceEpoch, + if (seenUntil != null) 'seenUntil': seenUntil!.millisecondsSinceEpoch, + if (deletionSyncedUntil != null) + 'deletionSyncedUntil': deletionSyncedUntil!.millisecondsSinceEpoch, + if (installedAppsBackupEnabled) 'backupEnabled': true, + }; + + LocalSettings copyWith({ + String? nwcConnectionString, + Set? appCatalogRelays, + DateTime? lastAppOpened, + DateTime? seenUntil, + DateTime? deletionSyncedUntil, + bool? installedAppsBackupEnabled, + bool clearNwc = false, + }) { + return LocalSettings( + nwcConnectionString: + clearNwc ? null : (nwcConnectionString ?? this.nwcConnectionString), + appCatalogRelays: appCatalogRelays ?? this.appCatalogRelays, + lastAppOpened: lastAppOpened ?? this.lastAppOpened, + seenUntil: seenUntil ?? this.seenUntil, + deletionSyncedUntil: deletionSyncedUntil ?? this.deletionSyncedUntil, + installedAppsBackupEnabled: + installedAppsBackupEnabled ?? this.installedAppsBackupEnabled, + ); + } + + static DateTime? _parseDateTime(dynamic value) => + value is int ? DateTime.fromMillisecondsSinceEpoch(value) : null; +} + +/// Service for reading and writing local settings. +class SettingsService { + static const _key = 'settings'; + + // Legacy keys for migration + static const _legacyNwcKey = 'nwc_connection_string'; + static const _legacyRelaysKey = 'app_catalog_relays'; + static const _legacyLastAppOpenedKey = 'last_app_opened'; + static const _legacySeenUntilKey = 'seen_until'; + static const _legacyDeletionSyncedUntilKey = 'deletion_synced_until'; + static const _legacyBackupKey = 'installed_apps_backup_enabled'; + + Future load() async { + final json = await _storage.read(key: _key); + if (json != null && json.isNotEmpty) { + try { + return LocalSettings.fromJson(jsonDecode(json) as Map); + } catch (_) { + return const LocalSettings(); + } + } + + // Migrate from legacy format if present + return _migrateFromLegacy(); + } + + Future _migrateFromLegacy() async { + final nwc = await _storage.read(key: _legacyNwcKey); + final relaysJson = await _storage.read(key: _legacyRelaysKey); + final lastAppOpened = await _storage.read(key: _legacyLastAppOpenedKey); + final seenUntil = await _storage.read(key: _legacySeenUntilKey); + final deletionSynced = await _storage.read(key: _legacyDeletionSyncedUntilKey); + final backupEnabled = await _storage.read(key: _legacyBackupKey); + + // No legacy data found + if ([nwc, relaysJson, lastAppOpened, seenUntil, deletionSynced, backupEnabled] + .every((v) => v == null || v.isEmpty)) { + return const LocalSettings(); + } + + // Parse legacy values + Set? relays; + if (relaysJson != null && relaysJson.isNotEmpty) { + try { + relays = Set.from((jsonDecode(relaysJson) as List).cast()); + } catch (_) {} + } + + final settings = LocalSettings( + nwcConnectionString: (nwc?.isNotEmpty == true) ? nwc : null, + appCatalogRelays: relays, + lastAppOpened: _parseLegacyDateTime(lastAppOpened), + seenUntil: _parseLegacyDateTime(seenUntil), + deletionSyncedUntil: _parseLegacyDateTime(deletionSynced), + installedAppsBackupEnabled: backupEnabled == 'true', + ); + + // Save migrated settings and clean up legacy keys + await save(settings); + await Future.wait([ + _storage.delete(key: _legacyNwcKey), + _storage.delete(key: _legacyRelaysKey), + _storage.delete(key: _legacyLastAppOpenedKey), + _storage.delete(key: _legacySeenUntilKey), + _storage.delete(key: _legacyDeletionSyncedUntilKey), + _storage.delete(key: _legacyBackupKey), + ]); + + return settings; + } + + static DateTime? _parseLegacyDateTime(String? value) { + if (value == null || value.isEmpty) return null; + final ms = int.tryParse(value); + return ms != null ? DateTime.fromMillisecondsSinceEpoch(ms) : null; + } + + Future save(LocalSettings settings) async { + await _storage.write(key: _key, value: jsonEncode(settings.toJson())); + } + + Future update( + LocalSettings Function(LocalSettings) updater) async { + final current = await load(); + final updated = updater(current); + await save(updated); + return updated; + } +} + +/// Persists the AmberSigner pubkey in secure storage. +class SecureStoragePubkeyPersistence implements AmberPubkeyPersistence { + static const _key = 'amber_pubkey'; + + @override + Future persistPubkey(String pubkey) => + _storage.write(key: _key, value: pubkey); + + @override + Future loadPubkey() => _storage.read(key: _key); + + @override + Future clearPubkey() => _storage.delete(key: _key); +} + +final settingsServiceProvider = Provider( + (ref) => SettingsService(), +); + +/// Current local settings. Invalidate after updates to refresh UI. +final localSettingsProvider = FutureProvider((ref) async { + return ref.watch(settingsServiceProvider).load(); +}); diff --git a/lib/services/updates_service.dart b/lib/services/updates_service.dart index 4f34dc9..9f32258 100644 --- a/lib/services/updates_service.dart +++ b/lib/services/updates_service.dart @@ -8,7 +8,7 @@ import 'package:purplebase/purplebase.dart'; import 'package:zapstore/services/catalog_fetcher.dart'; import 'package:zapstore/services/deletion_processor.dart'; import 'package:zapstore/services/package_manager/package_manager.dart'; -import 'package:zapstore/services/secure_storage_service.dart'; +import 'package:zapstore/services/settings_service.dart'; import 'package:zapstore/utils/extensions.dart'; /// How often to poll for updates from remote relays @@ -160,7 +160,7 @@ class UpdatePollerNotifier extends StateNotifier { ), processDeletions( storage: storage, - secureStorage: ref.read(secureStorageServiceProvider), + settingsService: ref.read(settingsServiceProvider), subscriptionPrefix: 'app-deletions-poll', ), ]); @@ -195,10 +195,8 @@ class UpdatePollerNotifier extends StateNotifier { final pubkey = ref.read(Signer.activePubkeyProvider); if (pubkey == null) return; - final enabled = await ref - .read(secureStorageServiceProvider) - .isInstalledAppsBackupEnabled(); - if (!enabled) return; + final settings = await ref.read(settingsServiceProvider).load(); + if (!settings.installedAppsBackupEnabled) return; final signer = ref.read(Signer.activeSignerProvider)!; final pmNotifier = ref.read(packageManagerProvider.notifier); diff --git a/lib/widgets/app_stack_container.dart b/lib/widgets/app_stack_container.dart index 8d1622d..dffd30a 100644 --- a/lib/widgets/app_stack_container.dart +++ b/lib/widgets/app_stack_container.dart @@ -25,17 +25,17 @@ Set getRawAppTagValues(AppStack stack) { /// Helper to compute preview app addressable IDs for a stack (3 apps max). /// Returns full addressable IDs (e.g. '32267:pubkey:identifier'). List getPreviewAddressableIds(AppStack stack) { - final rawTags = getRawAppTagValues(stack) - .where((id) => id.startsWith('32267:')) - .toList() - ..shuffle(Random(stack.id.hashCode)); + final rawTags = + getRawAppTagValues(stack).where((id) => id.startsWith('32267:')).toList() + ..shuffle(Random(stack.id.hashCode)); return rawTags.take(3).toList(); } /// Decompose addressable IDs (e.g. '32267:pubkey:identifier') into /// the sets of authors and identifiers needed for a query filter. ({Set authors, Set identifiers}) decomposeAddressableIds( - Iterable addressableIds) { + Iterable addressableIds, +) { final authors = {}; final identifiers = {}; for (final id in addressableIds) { @@ -85,7 +85,7 @@ class AppStackContainer extends HookConsumerWidget { tags: { '#f': {platform}, }, - source: const LocalAndRemoteSource(relays: 'AppCatalog'), + source: const LocalAndRemoteSource(relays: 'AppCatalog', stream: false), subscriptionPrefix: 'app-stack', schemaFilter: appStackEventFilter, ), @@ -133,8 +133,7 @@ class AppStackContainer extends HookConsumerWidget { : null; final appsMap = { - for (final app in previewAppsState?.models ?? []) - app.id: app, + for (final app in previewAppsState?.models ?? []) app.id: app, }; // Infinite horizontal scroll: load more when near end @@ -393,23 +392,26 @@ class StackCard extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Builder(builder: (context) { - final titleStyle = context.textTheme.titleMedium?.copyWith( - fontFamily: 'Inter', - fontSize: - (context.textTheme.titleMedium?.fontSize ?? 16) * 0.9, - ); - final lineHeight = (titleStyle?.fontSize ?? 14.4) * - (titleStyle?.height ?? 1.2); - return SizedBox( - height: lineHeight * 2, - child: _FadingText( - stack.name ?? stack.identifier, - style: titleStyle, - maxLines: 2, - ), - ); - }), + Builder( + builder: (context) { + final titleStyle = context.textTheme.titleMedium?.copyWith( + fontFamily: 'Inter', + fontSize: + (context.textTheme.titleMedium?.fontSize ?? 16) * 0.9, + ); + final lineHeight = + (titleStyle?.fontSize ?? 14.4) * + (titleStyle?.height ?? 1.2); + return SizedBox( + height: lineHeight * 2, + child: _FadingText( + stack.name ?? stack.identifier, + style: titleStyle, + maxLines: 2, + ), + ); + }, + ), if (showAuthor) ...[ const SizedBox(height: 6), Row( diff --git a/lib/widgets/common/stack_link_card.dart b/lib/widgets/common/stack_link_card.dart new file mode 100644 index 0000000..873567f --- /dev/null +++ b/lib/widgets/common/stack_link_card.dart @@ -0,0 +1,132 @@ +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:models/models.dart'; +import 'package:zapstore/utils/extensions.dart'; +import 'package:zapstore/utils/nostr_route.dart'; + +/// Shared stack link card used in profile and user screens. +/// Shows stack name, app count badge, and padlock icon for private stacks. +class StackLinkCard extends HookConsumerWidget { + const StackLinkCard({ + super.key, + required this.stack, + this.displayName, + }); + + final AppStack stack; + final String? displayName; + + bool get _isEncrypted => stack.content.isNotEmpty; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final appCount = useState(null); + + useEffect(() { + if (_isEncrypted) { + Future decrypt() async { + final signer = ref.read(Signer.activeSignerProvider); + final pubkey = ref.read(Signer.activePubkeyProvider); + if (signer == null || pubkey == null) return; + + try { + final decrypted = await signer.nip44Decrypt(stack.content, pubkey); + final ids = (jsonDecode(decrypted) as List).cast(); + appCount.value = ids.length; + } catch (_) { + // Decryption failed, leave count as null + } + } + + decrypt(); + } else { + // Public stack: count 'a' tags + final count = stack.event + .getTagSetValues('a') + .where((id) => id.startsWith('32267:')) + .length; + appCount.value = count; + } + return null; + }, [stack.content, stack.id]); + + final title = displayName ?? stack.name ?? stack.identifier; + + return Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 0), + child: InkWell( + onTap: () => pushStack( + context, + stack.identifier, + author: stack.pubkey, + kind: stack.event.kind, + ), + borderRadius: BorderRadius.circular(12), + child: Container( + width: double.infinity, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.2), + ), + ), + child: Row( + children: [ + Expanded( + child: Row( + children: [ + Flexible( + child: Text( + title, + style: context.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + if (_isEncrypted) ...[ + const SizedBox(width: 6), + Icon( + Icons.lock, + size: 16, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ], + if (appCount.value != null && appCount.value! > 0) ...[ + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHigh, + borderRadius: BorderRadius.circular(12), + ), + child: Text( + '${appCount.value}', + style: context.textTheme.labelSmall?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ], + ), + ), + const SizedBox(width: 12), + Icon( + Icons.chevron_right, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/widgets/nwc_widgets.dart b/lib/widgets/nwc_widgets.dart index 81a06fd..4a0ce9f 100644 --- a/lib/widgets/nwc_widgets.dart +++ b/lib/widgets/nwc_widgets.dart @@ -3,7 +3,7 @@ import 'package:flutter/services.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:zapstore/services/notification_service.dart'; -import 'package:zapstore/services/secure_storage_service.dart'; +import 'package:zapstore/services/settings_service.dart'; import 'package:zapstore/theme.dart'; import 'package:zapstore/utils/extensions.dart'; @@ -13,8 +13,8 @@ class NWCConnectionCard extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final hasNwc = ref.watch(hasNwcStringProvider); - final connected = hasNwc.maybeWhen(data: (v) => v, orElse: () => false); + final settingsAsync = ref.watch(localSettingsProvider); + final connected = settingsAsync.valueOrNull?.hasNwcString ?? false; return Card( child: Padding( @@ -42,7 +42,7 @@ class NWCConnectionCard extends HookConsumerWidget { const SizedBox(height: 12), // Connection Status - hasNwc.when( + settingsAsync.when( loading: () => Row( children: [ SizedBox( @@ -59,7 +59,7 @@ class NWCConnectionCard extends HookConsumerWidget { const Text('Checking wallet connection...'), ], ), - data: (value) => value + data: (settings) => settings.hasNwcString ? Row( children: [ Icon( @@ -159,7 +159,7 @@ class NWCConnectionCard extends HookConsumerWidget { builder: (context) => NWCConnectionDialog(ref: ref), ); if (connected == true) { - ref.invalidate(hasNwcStringProvider); + ref.invalidate(localSettingsProvider); } } @@ -195,9 +195,10 @@ class NWCConnectionCard extends HookConsumerWidget { if (confirmed == true) { try { - final secureStorage = ref.read(secureStorageServiceProvider); - await secureStorage.clearNWCString(); - ref.invalidate(hasNwcStringProvider); + await ref + .read(settingsServiceProvider) + .update((s) => s.copyWith(clearNwc: true)); + ref.invalidate(localSettingsProvider); if (context.mounted) { context.showInfo( 'Wallet disconnected', @@ -325,8 +326,9 @@ class NWCConnectionDialog extends HookWidget { isLoading.value = true; try { - final secureStorage = ref.read(secureStorageServiceProvider); - await secureStorage.setNWCString(nwcString.trim()); + await ref + .read(settingsServiceProvider) + .update((s) => s.copyWith(nwcConnectionString: nwcString.trim())); if (context.mounted) { Navigator.pop(context, true); diff --git a/lib/widgets/relay_management_card.dart b/lib/widgets/relay_management_card.dart index 753f93b..4dc3d2b 100644 --- a/lib/widgets/relay_management_card.dart +++ b/lib/widgets/relay_management_card.dart @@ -5,7 +5,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:purplebase/purplebase.dart'; import 'package:zapstore/services/app_restart_service.dart'; import 'package:zapstore/services/notification_service.dart'; -import 'package:zapstore/services/secure_storage_service.dart'; +import 'package:zapstore/services/settings_service.dart'; /// App Catalog Relay Management Card - manages app catalog relays. /// These are relays for discovering apps, NOT social relays like Damus/Primal. @@ -29,7 +29,7 @@ class RelayManagementCard extends HookConsumerWidget { // Load local relays once - they only change on app restart final localRelaysFuture = useMemoized( - () => ref.read(secureStorageServiceProvider).getAppCatalogRelays(), + () async => (await ref.read(settingsServiceProvider).load()).appCatalogRelays, ); final localRelaysSnapshot = useFuture(localRelaysFuture); final localRelays = localRelaysSnapshot.data?.toList()?..sort(); @@ -154,15 +154,16 @@ class RelayManagementCard extends HookConsumerWidget { isApplying.value = true; try { - final secureStorage = ref.read(secureStorageServiceProvider); + final settingsService = ref.read(settingsServiceProvider); final relaysToSave = displayRelays.toSet(); - // Save to local secure storage - await secureStorage.setAppCatalogRelays(relaysToSave); + // Save to local settings + final updated = await settingsService.update( + (s) => s.copyWith(appCatalogRelays: relaysToSave)); - // Verify write succeeded by reading back - final verified = await secureStorage.getAppCatalogRelays(); - if (verified == null || !verified.containsAll(relaysToSave)) { + // Verify write succeeded + if (updated.appCatalogRelays == null || + !updated.appCatalogRelays!.containsAll(relaysToSave)) { throw StateError('Failed to persist relay configuration'); } diff --git a/lib/widgets/zap_widgets.dart b/lib/widgets/zap_widgets.dart index f818747..7a61f18 100644 --- a/lib/widgets/zap_widgets.dart +++ b/lib/widgets/zap_widgets.dart @@ -8,7 +8,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:models/models.dart'; import 'package:zapstore/router.dart'; import 'package:zapstore/services/notification_service.dart'; -import 'package:zapstore/services/secure_storage_service.dart'; +import 'package:zapstore/services/settings_service.dart'; import 'package:zapstore/utils/extensions.dart'; import 'package:zapstore/widgets/auth_widgets.dart'; import 'package:zapstore/widgets/common/base_dialog.dart'; @@ -242,9 +242,7 @@ class ZapAmountDialog extends HookConsumerWidget { ], ); final pubkey = ref.watch(Signer.activePubkeyProvider); - final secureStorage = ref.watch(secureStorageServiceProvider); - final hasNwc = ref.watch(hasNwcStringProvider); - final knownHasNwc = hasNwc.valueOrNull; + final settingsService = ref.watch(settingsServiceProvider); return BaseDialog( titleIcon: const Text('⚡️'), @@ -428,10 +426,9 @@ class ZapAmountDialog extends HookConsumerWidget { await signer.signIn(registerSigner: false); } - // Read NWC from secure storage - final nwcString = (knownHasNwc == false) - ? null - : await secureStorage.getNWCString(); + // Read NWC from settings + final settings = await settingsService.load(); + final nwcString = settings.nwcConnectionString; // Build zap request final latestMetadata = app.installable; @@ -621,7 +618,7 @@ class NWCZapDialog extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final controller = useTextEditingController(); - final secureStorage = ref.watch(secureStorageServiceProvider); + final settingsService = ref.watch(settingsServiceProvider); return BaseDialog( titleIcon: const Text('⚡️'), @@ -697,8 +694,9 @@ class NWCZapDialog extends HookConsumerWidget { unawaited(() async { try { - await secureStorage.setNWCString(nwcString.trim()); - refAsRef.invalidate(hasNwcStringProvider); + await settingsService.update( + (s) => s.copyWith(nwcConnectionString: nwcString.trim())); + refAsRef.invalidate(localSettingsProvider); await _executeZapPayment(signedZapRequest, nwcString, refAsRef); if (toastContext != null && toastContext.mounted) { toastContext.showInfo('⚡ Zap sent! $amount sats');