diff --git a/lib/screens/profile_screen.dart b/lib/screens/profile_screen.dart index 5fd27e1..1f57d9e 100644 --- a/lib/screens/profile_screen.dart +++ b/lib/screens/profile_screen.dart @@ -32,9 +32,6 @@ class ProfileScreen extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final signedInPubkey = ref.watch(Signer.activePubkeyProvider); - final isSignedIn = signedInPubkey != null; - return Scaffold( body: ListView( children: [ @@ -48,18 +45,15 @@ class ProfileScreen extends ConsumerWidget { const SizedBox(height: 24), // Settings Heading - if (isSignedIn) - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16.0), - child: Text('Settings', style: context.textTheme.headlineSmall), - ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: Text('Settings', style: context.textTheme.headlineSmall), + ), - if (isSignedIn) ...[ - const SizedBox(height: 16), + const SizedBox(height: 16), - // Lightning Wallet Section - const NWCConnectionCard(), - ], + // Lightning Wallet Section + const NWCConnectionCard(), const SizedBox(height: 24), diff --git a/lib/services/secure_storage_service.dart b/lib/services/secure_storage_service.dart new file mode 100644 index 0000000..32d8b1f --- /dev/null +++ b/lib/services/secure_storage_service.dart @@ -0,0 +1,42 @@ +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) +/// 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(); + + static const _storage = FlutterSecureStorage(); + + static const _nwcKey = 'nwc_connection_string'; + + /// 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; + } +} + +final secureStorageServiceProvider = Provider( + (ref) => SecureStorageService(), +); + diff --git a/lib/widgets/nwc_widgets.dart b/lib/widgets/nwc_widgets.dart index 6fca570..f286467 100644 --- a/lib/widgets/nwc_widgets.dart +++ b/lib/widgets/nwc_widgets.dart @@ -2,13 +2,13 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:models/models.dart'; import 'package:zapstore/services/notification_service.dart'; +import 'package:zapstore/services/secure_storage_service.dart'; import 'package:zapstore/theme.dart'; import 'package:zapstore/utils/extensions.dart'; /// NWC connection status states -enum NWCStatus { notSignedIn, checking, connected, disconnected, error } +enum NWCStatus { checking, connected, disconnected, error } /// Card showing NWC connection status and management class NWCConnectionCard extends HookConsumerWidget { @@ -16,38 +16,18 @@ class NWCConnectionCard extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final signer = ref.watch(Signer.activeSignerProvider); - final pubkey = ref.watch(Signer.activePubkeyProvider); - final isSignedIn = pubkey != null; + final secureStorage = ref.watch(secureStorageServiceProvider); + final nwcStatus = useState(NWCStatus.checking); - // Reactive NWC state via CustomData keyed by Signer.kNwcConnectionString - final nwcState = pubkey == null - ? null - : ref.watch( - query( - authors: {pubkey}, - tags: { - '#d': {Signer.kNwcConnectionString}, - }, - limit: 1, - source: const LocalSource(), - subscriptionPrefix: 'nwc-data', - ), - ); - - final NWCStatus nwcStatus = () { - if (signer == null || pubkey == null) return NWCStatus.notSignedIn; - final state = nwcState; - if (state == null) return NWCStatus.checking; - return switch (state) { - StorageLoading() => NWCStatus.checking, - StorageError() => NWCStatus.error, - StorageData(:final models) => - (models.isNotEmpty && models.first.content.trim().isNotEmpty) - ? NWCStatus.connected - : NWCStatus.disconnected, - }; - }(); + // Check NWC status on mount + useEffect(() { + secureStorage.hasNWCString().then((hasNwc) { + nwcStatus.value = hasNwc ? NWCStatus.connected : NWCStatus.disconnected; + }).catchError((_) { + nwcStatus.value = NWCStatus.error; + }); + return null; + }, []); return Card( child: Padding( @@ -73,29 +53,14 @@ class NWCConnectionCard extends HookConsumerWidget { const SizedBox(height: 12), Text( - isSignedIn - ? 'Use Nostr Wallet Connect (NWC) to zap the developers.' - : 'Connect a Lightning wallet to send zaps. Sign in required.', + 'Use Nostr Wallet Connect (NWC) to zap the developers.', style: context.textTheme.bodySmall, ), const SizedBox(height: 12), // Connection Status - switch (nwcStatus) { - NWCStatus.notSignedIn => Row( - children: [ - Icon( - Icons.warning, - color: Theme.of( - context, - ).colorScheme.onSurface.withValues(alpha: 0.6), - size: 16, - ), - const SizedBox(width: 8), - const Expanded(child: Text('Sign in to connect wallet')), - ], - ), + switch (nwcStatus.value) { NWCStatus.checking => Row( children: [ SizedBox( @@ -155,77 +120,77 @@ class NWCConnectionCard extends HookConsumerWidget { const SizedBox(height: 16), // Action Buttons - if (isSignedIn) - Row( - children: [ - Expanded( - child: TextButton.icon( - onPressed: nwcStatus != NWCStatus.notSignedIn - ? () => _showNWCDialog(context, ref, signer) - : null, - icon: Icon( - Icons.flash_on, - size: 18, - color: Theme.of(context).brightness == Brightness.dark - ? Colors.white - : Theme.of(context).colorScheme.primary, - ), - label: Text( - nwcStatus == NWCStatus.connected - ? 'Update Connection' - : 'Connect Wallet', - ), - style: TextButton.styleFrom( - backgroundColor: AppColors.darkPillBackground, - foregroundColor: Colors.white, - padding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 10, - ), + Row( + children: [ + Expanded( + child: TextButton.icon( + onPressed: () => _showNWCDialog(context, ref, nwcStatus), + icon: Icon( + Icons.flash_on, + size: 18, + color: Theme.of(context).brightness == Brightness.dark + ? Colors.white + : Theme.of(context).colorScheme.primary, + ), + label: Text( + nwcStatus.value == NWCStatus.connected + ? 'Update Connection' + : 'Connect Wallet', + ), + style: TextButton.styleFrom( + backgroundColor: AppColors.darkPillBackground, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 10, ), ), ), - if (nwcStatus == NWCStatus.connected) ...[ - const SizedBox(width: 8), - FilledButton( - onPressed: () => - _removeNWCConnection(context, ref, signer), - style: FilledButton.styleFrom( - backgroundColor: Theme.of(context).colorScheme.error, - foregroundColor: Colors.white, - padding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 10, - ), - minimumSize: const Size(0, 0), + ), + if (nwcStatus.value == NWCStatus.connected) ...[ + const SizedBox(width: 8), + FilledButton( + onPressed: () => + _removeNWCConnection(context, ref, nwcStatus), + style: FilledButton.styleFrom( + backgroundColor: Theme.of(context).colorScheme.error, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 10, ), - child: const Icon(Icons.delete_outline, size: 18), + minimumSize: const Size(0, 0), ), - ], + child: const Icon(Icons.delete_outline, size: 18), + ), ], - ), + ], + ), ], ), ), ); } - void _showNWCDialog(BuildContext context, WidgetRef ref, Signer? signer) { - if (signer == null) return; - - showDialog( + void _showNWCDialog( + BuildContext context, + WidgetRef ref, + ValueNotifier nwcStatus, + ) async { + final connected = await showDialog( context: context, - builder: (context) => NWCConnectionDialog(signer: signer), + builder: (context) => NWCConnectionDialog(ref: ref), ); + if (connected == true) { + nwcStatus.value = NWCStatus.connected; + } } void _removeNWCConnection( BuildContext context, WidgetRef ref, - Signer? signer, + ValueNotifier nwcStatus, ) async { - if (signer == null) return; - final confirmed = await showDialog( context: context, builder: (context) => AlertDialog( @@ -253,7 +218,9 @@ class NWCConnectionCard extends HookConsumerWidget { if (confirmed == true) { try { - await signer.setNWCString(''); + final secureStorage = ref.read(secureStorageServiceProvider); + await secureStorage.clearNWCString(); + nwcStatus.value = NWCStatus.disconnected; if (context.mounted) { context.showInfo('Wallet connection removed'); } @@ -268,9 +235,9 @@ class NWCConnectionCard extends HookConsumerWidget { /// Dialog for connecting/updating NWC connection class NWCConnectionDialog extends HookWidget { - final Signer signer; + final WidgetRef ref; - const NWCConnectionDialog({super.key, required this.signer}); + const NWCConnectionDialog({super.key, required this.ref}); @override Widget build(BuildContext context) { @@ -367,10 +334,11 @@ class NWCConnectionDialog extends HookWidget { isLoading.value = true; try { - await signer.setNWCString(nwcString.trim()); + final secureStorage = ref.read(secureStorageServiceProvider); + await secureStorage.setNWCString(nwcString.trim()); if (context.mounted) { - Navigator.pop(context); + Navigator.pop(context, true); context.showInfo('⚡ Lightning wallet connected successfully!'); } } catch (e) { @@ -382,4 +350,3 @@ class NWCConnectionDialog extends HookWidget { } } } - diff --git a/lib/widgets/zap_widgets.dart b/lib/widgets/zap_widgets.dart index 6d281aa..4f870c1 100644 --- a/lib/widgets/zap_widgets.dart +++ b/lib/widgets/zap_widgets.dart @@ -8,6 +8,7 @@ import 'package:models/models.dart'; import 'package:zapstore/main.dart'; import 'package:zapstore/services/notification_service.dart'; import 'package:zapstore/services/package_manager/package_manager.dart'; +import 'package:zapstore/services/secure_storage_service.dart'; import 'package:zapstore/utils/extensions.dart'; import 'package:zapstore/widgets/common/base_dialog.dart'; import 'package:zapstore/widgets/common/profile_avatar.dart'; @@ -217,21 +218,15 @@ class ZapAmountDialog extends HookConsumerWidget { ], ); final pubkey = ref.watch(Signer.activePubkeyProvider); - final signer = ref.watch(Signer.activeSignerProvider); + final secureStorage = ref.watch(secureStorageServiceProvider); final hasWalletConnection = useState(false); useEffect(() { - if (signer == null) { - hasWalletConnection.value = false; - return null; - } - signer - .getNWCString() - .then( - (value) => hasWalletConnection.value = (value?.isNotEmpty == true), - ) + secureStorage + .hasNWCString() + .then((hasNwc) => hasWalletConnection.value = hasNwc) .catchError((_) => hasWalletConnection.value = false); return null; - }, [signer]); + }, []); return BaseDialog( titleIcon: const Text('⚡️'), @@ -363,7 +358,7 @@ class ZapAmountDialog extends HookConsumerWidget { const SizedBox(width: 12), Expanded( child: Text( - 'Tap to sign in. You can zap anonymously or sign in for attribution.', + 'Zapping anonymously. Tap to sign in.', style: Theme.of(context).textTheme.bodyMedium, ), ), @@ -374,19 +369,17 @@ class ZapAmountDialog extends HookConsumerWidget { const SizedBox(height: 12), ], - if (pubkey != null && !hasWalletConnection.value) ...[ + if (!hasWalletConnection.value) ...[ InkWell( - onTap: signer == null - ? null - : () async { - final connected = await showBaseDialog( - context: context, - dialog: NWCConnectionDialogInline(signer: signer), - ); - if (connected == true) { - hasWalletConnection.value = true; - } - }, + onTap: () async { + final connected = await showBaseDialog( + context: context, + dialog: const NWCConnectionDialogInline(), + ); + if (connected == true) { + hasWalletConnection.value = true; + } + }, borderRadius: BorderRadius.circular(8), child: Padding( padding: const EdgeInsets.all(12), @@ -478,6 +471,8 @@ class ZapAmountDialog extends HookConsumerWidget { onPressed: selectedAmount.value > 0 ? () async { try { + final navigator = Navigator.of(context); + // Prepare signer (anonymous if needed) var signer = ref.read(Signer.activeSignerProvider); if (signer == null) { @@ -488,8 +483,8 @@ class ZapAmountDialog extends HookConsumerWidget { await signer.signIn(registerSigner: false); } - // Read NWC state (may be empty) - final nwcString = await signer.getNWCString(); + // Read NWC from secure storage + final nwcString = await secureStorage.getNWCString(); // Build zap request final latestMetadata = app.latestFileMetadata; @@ -516,29 +511,33 @@ class ZapAmountDialog extends HookConsumerWidget { final signedZapRequest = await zapRequest.signWith(signer); if (nwcString != null && nwcString.isNotEmpty) { - if (context.mounted) Navigator.of(context).pop(true); - // Continue payment in background (no await) + final amount = selectedAmount.value; + // Capture ScaffoldMessenger before popping (survives navigation) + final messenger = ScaffoldMessenger.maybeOf(context); + + // Optimistic UX: show success immediately, pop, fire payment + context.showInfo('⚡ Zap sent! $amount sats'); + navigator.pop(true); + + // Fire payment in background - errors shown via ScaffoldMessenger // ignore: unawaited_futures - signedZapRequest - .pay() - .then((_) { - if (context.mounted) { - context.showInfo( - '⚡ Zap successful! ${selectedAmount.value} sats sent', - ); - } - }) - .catchError((e) { - if (context.mounted) { - context.showError('Zap failed: $e'); - } + _executeZapPayment(signedZapRequest, nwcString, ref.ref) + .catchError((Object e, StackTrace st) { + debugPrint('Zap payment failed: $e\n$st'); + messenger?.showSnackBar( + SnackBar( + content: Text('Zap failed: $e'), + backgroundColor: Colors.red, + ), + ); + return Future.error(e, st); }); } else { final invoice = await signedZapRequest.getInvoice(); - if (context.mounted) Navigator.of(context).pop(true); await Clipboard.setData(ClipboardData(text: invoice)); if (context.mounted) { context.showInfo('Invoice copied to clipboard'); + navigator.pop(true); } } } catch (e) { @@ -579,6 +578,21 @@ class ZapAmountDialog extends HookConsumerWidget { } } +/// Execute zap payment using NWC connection string from secure storage +Future _executeZapPayment( + ZapRequest signedZapRequest, + String nwcString, + Ref ref, +) async { + final lightningInvoice = await signedZapRequest.getInvoice(); + final command = PayInvoiceCommand(invoice: lightningInvoice); + return await command.execute( + connectionUri: nwcString, + ref: ref, + timeout: const Duration(seconds: 30), + ); +} + /// Dialog for entering a custom zap amount class CustomAmountDialog extends HookWidget { const CustomAmountDialog({super.key}); @@ -620,15 +634,14 @@ class CustomAmountDialog extends HookWidget { } /// Inline NWC connection dialog used in zap flow -class NWCConnectionDialogInline extends HookWidget { - final Signer signer; - - const NWCConnectionDialogInline({super.key, required this.signer}); +class NWCConnectionDialogInline extends HookConsumerWidget { + const NWCConnectionDialogInline({super.key}); @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { final controller = useTextEditingController(); final isLoading = useState(false); + final secureStorage = ref.watch(secureStorageServiceProvider); return BaseDialog( titleIcon: const Text('⚡️'), @@ -693,7 +706,7 @@ class NWCConnectionDialogInline extends HookWidget { } isLoading.value = true; try { - await signer.setNWCString(nwcString.trim()); + await secureStorage.setNWCString(nwcString.trim()); if (context.mounted) { Navigator.pop(context, true); context.showInfo( diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc index fc949e0..57efdf4 100644 --- a/linux/flutter/generated_plugin_registrant.cc +++ b/linux/flutter/generated_plugin_registrant.cc @@ -6,11 +6,15 @@ #include "generated_plugin_registrant.h" +#include #include #include #include void fl_register_plugins(FlPluginRegistry* registry) { + g_autoptr(FlPluginRegistrar) flutter_secure_storage_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterSecureStorageLinuxPlugin"); + flutter_secure_storage_linux_plugin_register_with_registrar(flutter_secure_storage_linux_registrar); g_autoptr(FlPluginRegistrar) gtk_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "GtkPlugin"); gtk_plugin_register_with_registrar(gtk_registrar); diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index c34d078..9137173 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -3,6 +3,7 @@ # list(APPEND FLUTTER_PLUGIN_LIST + flutter_secure_storage_linux gtk sqlite3_flutter_libs url_launcher_linux diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index b57017e..8805b61 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -7,6 +7,7 @@ import Foundation import app_links import connectivity_plus +import flutter_secure_storage_darwin import path_provider_foundation import share_plus import shared_preferences_foundation @@ -17,6 +18,7 @@ import url_launcher_macos func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { AppLinksMacosPlugin.register(with: registry.registrar(forPlugin: "AppLinksMacosPlugin")) ConnectivityPlusPlugin.register(with: registry.registrar(forPlugin: "ConnectivityPlusPlugin")) + FlutterSecureStorageDarwinPlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStorageDarwinPlugin")) PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) diff --git a/pubspec.lock b/pubspec.lock index 7c87123..2a855ae 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -399,6 +399,54 @@ packages: url: "https://pub.dev" source: hosted version: "2.6.1" + flutter_secure_storage: + dependency: "direct main" + description: + name: flutter_secure_storage + sha256: da922f2aab2d733db7e011a6bcc4a825b844892d4edd6df83ff156b09a9b2e40 + url: "https://pub.dev" + source: hosted + version: "10.0.0" + flutter_secure_storage_darwin: + dependency: transitive + description: + name: flutter_secure_storage_darwin + sha256: "8878c25136a79def1668c75985e8e193d9d7d095453ec28730da0315dc69aee3" + url: "https://pub.dev" + source: hosted + version: "0.2.0" + flutter_secure_storage_linux: + dependency: transitive + description: + name: flutter_secure_storage_linux + sha256: "2b5c76dce569ab752d55a1cee6a2242bcc11fdba927078fb88c503f150767cda" + url: "https://pub.dev" + source: hosted + version: "3.0.0" + flutter_secure_storage_platform_interface: + dependency: transitive + description: + name: flutter_secure_storage_platform_interface + sha256: "8ceea1223bee3c6ac1a22dabd8feefc550e4729b3675de4b5900f55afcb435d6" + url: "https://pub.dev" + source: hosted + version: "2.0.1" + flutter_secure_storage_web: + dependency: transitive + description: + name: flutter_secure_storage_web + sha256: "6a1137df62b84b54261dca582c1c09ea72f4f9a4b2fcee21b025964132d5d0c3" + url: "https://pub.dev" + source: hosted + version: "2.1.0" + flutter_secure_storage_windows: + dependency: transitive + description: + name: flutter_secure_storage_windows + sha256: "3b7c8e068875dfd46719ff57c90d8c459c87f2302ed6b00ff006b3c9fcad1613" + url: "https://pub.dev" + source: hosted + version: "4.1.0" flutter_svg: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 536f083..d9af5e8 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -53,6 +53,7 @@ dependencies: background_downloader: ^9.2.6 connectivity_plus: ^7.0.0 app_links: ^6.4.1 + flutter_secure_storage: ^10.0.0 dependency_overrides: models: