Improve settings storage, save installed apps and show private stacks

This commit is contained in:
franzap
2026-04-10 22:24:49 -03:00
parent a9712b3c16
commit cb470bf67d
14 changed files with 643 additions and 543 deletions
+11 -11
View File
@@ -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<void>((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<void>((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<void> _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<void> _recordAppOpened() async {
final secureStorage = SecureStorageService();
await secureStorage.setLastAppOpenedTime(DateTime.now());
await SettingsService().update((s) => s.copyWith(lastAppOpened: DateTime.now()));
}
}
+174 -38
View File
@@ -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<Set<String>?>(null);
final decryptError = useState<String?>(null);
useEffect(() {
if (!isEncrypted) return null;
Future<void> 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<String>().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<App> 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?) ...[
+52 -143
View File
@@ -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<String> _toIdentifiers(Set<String> 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<String> 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<App>(
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<AppStack>(
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),
);
}),
],
);
}
}
+5 -61
View File
@@ -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});
+8 -8
View File
@@ -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<void> _showUpdateNotificationIfNeeded(
List<App> updates,
Map<String, Installable> 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<void> _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<void> _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
+8 -5
View File
@@ -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<void> 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<void> processDeletions({
subscriptionPrefix: subscriptionPrefix,
);
await secureStorage.setDeletionsSyncedUntil(DateTime.now());
await settingsService.update(
(s) => s.copyWith(deletionSyncedUntil: DateTime.now()));
if (deletionRequests.isEmpty) return;
-216
View File
@@ -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<String?> getNWCString() async {
final value = await _storage.read(key: _nwcKey);
return (value?.isNotEmpty == true) ? value : null;
}
/// Store an NWC connection string
Future<void> setNWCString(String connectionString) async {
await _storage.write(key: _nwcKey, value: connectionString);
}
/// Clear the stored NWC connection string
Future<void> clearNWCString() async {
await _storage.delete(key: _nwcKey);
}
/// Check if an NWC connection string is stored
Future<bool> 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<DateTime?> 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<void> 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<DateTime?> 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<void> 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<Set<String>?> 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<String>.from(list.cast<String>());
} 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<DateTime?> 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<void> 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<void> setAppCatalogRelays(Set<String> relays) async {
await _storage.write(
key: _appCatalogRelaysKey,
value: jsonEncode(relays.toList()),
);
}
// =========================================================================
// Installed Apps Backup
// =========================================================================
static const _installedAppsBackupKey = 'installed_apps_backup_enabled';
Future<bool> isInstalledAppsBackupEnabled() async {
final value = await _storage.read(key: _installedAppsBackupKey);
return value == 'true';
}
Future<void> 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<void> persistPubkey(String pubkey) async {
await SecureStorageService._storage.write(key: _key, value: pubkey);
}
@override
Future<String?> loadPubkey() async {
return SecureStorageService._storage.read(key: _key);
}
@override
Future<void> clearPubkey() async {
await SecureStorageService._storage.delete(key: _key);
}
}
final secureStorageServiceProvider = Provider<SecureStorageService>(
(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<bool>((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<bool>((ref) async {
final secureStorage = ref.watch(secureStorageServiceProvider);
return secureStorage.isInstalledAppsBackupEnabled();
});
+191
View File
@@ -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<String>? 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<String, dynamic> json) {
return LocalSettings(
nwcConnectionString: json['nwc'] as String?,
appCatalogRelays: (json['relays'] as List?)?.cast<String>().toSet(),
lastAppOpened: _parseDateTime(json['lastAppOpened']),
seenUntil: _parseDateTime(json['seenUntil']),
deletionSyncedUntil: _parseDateTime(json['deletionSyncedUntil']),
installedAppsBackupEnabled: json['backupEnabled'] as bool? ?? false,
);
}
Map<String, dynamic> 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<String>? 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<LocalSettings> load() async {
final json = await _storage.read(key: _key);
if (json != null && json.isNotEmpty) {
try {
return LocalSettings.fromJson(jsonDecode(json) as Map<String, dynamic>);
} catch (_) {
return const LocalSettings();
}
}
// Migrate from legacy format if present
return _migrateFromLegacy();
}
Future<LocalSettings> _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<String>? relays;
if (relaysJson != null && relaysJson.isNotEmpty) {
try {
relays = Set<String>.from((jsonDecode(relaysJson) as List).cast<String>());
} 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<void> save(LocalSettings settings) async {
await _storage.write(key: _key, value: jsonEncode(settings.toJson()));
}
Future<LocalSettings> 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<void> persistPubkey(String pubkey) =>
_storage.write(key: _key, value: pubkey);
@override
Future<String?> loadPubkey() => _storage.read(key: _key);
@override
Future<void> clearPubkey() => _storage.delete(key: _key);
}
final settingsServiceProvider = Provider<SettingsService>(
(ref) => SettingsService(),
);
/// Current local settings. Invalidate after updates to refresh UI.
final localSettingsProvider = FutureProvider<LocalSettings>((ref) async {
return ref.watch(settingsServiceProvider).load();
});
+4 -6
View File
@@ -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<UpdatePollerState> {
),
processDeletions(
storage: storage,
secureStorage: ref.read(secureStorageServiceProvider),
settingsService: ref.read(settingsServiceProvider),
subscriptionPrefix: 'app-deletions-poll',
),
]);
@@ -195,10 +195,8 @@ class UpdatePollerNotifier extends StateNotifier<UpdatePollerState> {
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);
+27 -25
View File
@@ -25,17 +25,17 @@ Set<String> 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<String> 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<String> authors, Set<String> identifiers}) decomposeAddressableIds(
Iterable<String> addressableIds) {
Iterable<String> addressableIds,
) {
final authors = <String>{};
final identifiers = <String>{};
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>[])
app.id: app,
for (final app in previewAppsState?.models ?? <App>[]) 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(
+132
View File
@@ -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<int?>(null);
useEffect(() {
if (_isEncrypted) {
Future<void> 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<String>();
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,
),
],
),
),
),
);
}
}
+13 -11
View File
@@ -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);
+9 -8
View File
@@ -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');
}
+9 -11
View File
@@ -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');