From b51b0081e53a3fe6eaf00e9cca219b7c088a5ab4 Mon Sep 17 00:00:00 2001 From: Henrique Velloso Date: Fri, 23 Jan 2026 16:47:53 -0300 Subject: [PATCH 1/2] Fix NWC button crash and improve zap flow UX - Fix silent crash when tapping "Setup NWC" button due to invalid BuildContext after dialog pop - Rename "Setup NWC" to "Zap with NWC" and execute zap inline after entering NWC string - Dismiss toast immediately when action button is tapped - Block dialog dismissal during zap execution to preserve context --- lib/services/notification_service.dart | 56 +- lib/widgets/common/base_dialog.dart | 2 + lib/widgets/zap_widgets.dart | 728 +++++++++++++------------ 3 files changed, 415 insertions(+), 371 deletions(-) diff --git a/lib/services/notification_service.dart b/lib/services/notification_service.dart index 57541b1..f6456b5 100644 --- a/lib/services/notification_service.dart +++ b/lib/services/notification_service.dart @@ -31,11 +31,16 @@ extension ContextX on BuildContext { final allActions = <(String, Future Function())>[ ...actions, if (description != null) - ('Copy', () async { - await Clipboard.setData(ClipboardData(text: '$title\n\n$description')); - }), + ( + 'Copy', + () async { + await Clipboard.setData( + ClipboardData(text: '$title\n\n$description'), + ); + }, + ), ]; - + _showCustomToast( context: this, title: title, @@ -128,10 +133,7 @@ class _ToastOverlayState extends State<_ToastOverlay> _slideAnimation = Tween( begin: const Offset(0, -1), end: Offset.zero, - ).animate(CurvedAnimation( - parent: _controller, - curve: Curves.easeOutCubic, - )); + ).animate(CurvedAnimation(parent: _controller, curve: Curves.easeOutCubic)); _controller.forward(); _startAutoClose(); @@ -143,8 +145,8 @@ class _ToastOverlayState extends State<_ToastOverlay> final duration = hasActions ? const Duration(seconds: 10) : widget.type == _ToastType.error - ? const Duration(seconds: 8) - : const Duration(seconds: 6); + ? const Duration(seconds: 8) + : const Duration(seconds: 6); Future.delayed(duration, () { if (mounted && !_isHovered) { @@ -179,7 +181,9 @@ class _ToastOverlayState extends State<_ToastOverlay> final borderColor = isError ? const Color(0xFFB91C1C).withValues(alpha: 0.8) // Darker red border - : const Color(0xFF0369A1).withValues(alpha: 0.8); // Even darker blue border (sky-700) + : const Color( + 0xFF0369A1, + ).withValues(alpha: 0.8); // Even darker blue border (sky-700) final accentColor = Colors.white; @@ -300,7 +304,7 @@ class _ToastContent extends StatelessWidget { @override Widget build(BuildContext context) { final hasExtraContent = description != null || actions.isNotEmpty; - + // Use Stack to position X at top-right always return Stack( children: [ @@ -309,8 +313,8 @@ class _ToastContent extends StatelessWidget { padding: const EdgeInsets.only(right: 36), // Space for X button child: Row( // Center align when no description, top align when there's extra content - crossAxisAlignment: hasExtraContent - ? CrossAxisAlignment.start + crossAxisAlignment: hasExtraContent + ? CrossAxisAlignment.start : CrossAxisAlignment.center, children: [ // Icon @@ -321,11 +325,7 @@ class _ToastContent extends StatelessWidget { color: iconBgColor, borderRadius: BorderRadius.circular(10), ), - child: Icon( - icon, - color: accentColor, - size: 20, - ), + child: Icon(icon, color: accentColor, size: 20), ), const SizedBox(width: 14), // Text content @@ -366,14 +366,16 @@ class _ToastContent extends StatelessWidget { spacing: 8, runSpacing: 8, children: actions - .map((action) => _ToastActionButton( - label: action.$1, - onPressed: () async { - await action.$2(); - onDismiss(); - }, - accentColor: accentColor, - )) + .map( + (action) => _ToastActionButton( + label: action.$1, + onPressed: () { + onDismiss(); + action.$2(); + }, + accentColor: accentColor, + ), + ) .toList(), ), ], diff --git a/lib/widgets/common/base_dialog.dart b/lib/widgets/common/base_dialog.dart index 9857df9..daf86ed 100644 --- a/lib/widgets/common/base_dialog.dart +++ b/lib/widgets/common/base_dialog.dart @@ -5,10 +5,12 @@ import 'package:flutter/material.dart'; Future showBaseDialog({ required BuildContext context, required Widget dialog, + bool barrierDismissible = true, }) { return showDialog( context: context, barrierColor: Colors.black.withValues(alpha: 0.25), + barrierDismissible: barrierDismissible, builder: (context) => dialog, ); } diff --git a/lib/widgets/zap_widgets.dart b/lib/widgets/zap_widgets.dart index f57b125..128ad55 100644 --- a/lib/widgets/zap_widgets.dart +++ b/lib/widgets/zap_widgets.dart @@ -202,6 +202,7 @@ Future showZapDialog( ) async { final result = await showBaseDialog( context: context, + barrierDismissible: false, dialog: ZapAmountDialog( app: app, isRelaySigned: app.isRelaySigned, @@ -229,6 +230,7 @@ class ZapAmountDialog extends HookConsumerWidget { final selectedAmount = useState(2100); final commentController = useTextEditingController(); final customAmount = useState(null); + final isLoading = useState(false); final quickAmounts = useMemoized( () => [ (label: '🤙 2100', value: 2100), @@ -243,281 +245,304 @@ class ZapAmountDialog extends HookConsumerWidget { final hasNwc = ref.watch(hasNwcStringProvider); final knownHasNwc = hasNwc.valueOrNull; - return BaseDialog( - titleIcon: const Text('⚡️'), - titleIconColor: Colors.orange, - title: Text( - isRelaySigned ? 'Zap the relay' : 'Zap ${app.name}', - style: Theme.of(context).textTheme.headlineSmall, - ), - content: Padding( - padding: EdgeInsets.only( - bottom: MediaQuery.of(context).viewInsets.bottom, + // Prevent closing dialog during zap execution + return PopScope( + canPop: !isLoading.value, + child: BaseDialog( + titleIcon: const Text('⚡️'), + titleIconColor: Colors.orange, + title: Text( + isRelaySigned ? 'Zap the relay' : 'Zap ${app.name}', + style: Theme.of(context).textTheme.headlineSmall, ), - child: BaseDialogContent( - children: [ - const SizedBox(height: 4), + content: Padding( + padding: EdgeInsets.only( + bottom: MediaQuery.of(context).viewInsets.bottom, + ), + child: BaseDialogContent( + children: [ + const SizedBox(height: 4), - // Relay-signed app explanation - if (isRelaySigned) ...[ - Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surfaceContainerHighest, - borderRadius: BorderRadius.circular(8), - ), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.only(top: 2), - child: Icon( - Icons.info_outline, - size: 20, - color: Theme.of(context).colorScheme.primary, - ), - ), - const SizedBox(width: 12), - Expanded( - child: Text.rich( - TextSpan( - style: Theme.of(context).textTheme.bodyMedium, - children: [ - const TextSpan(text: 'This app was indexed by '), - if (author != null) ...[ - WidgetSpan( - alignment: PlaceholderAlignment.middle, - child: ProfileAvatar( - profile: author, - radius: 9, - ), - ), - const WidgetSpan( - alignment: PlaceholderAlignment.middle, - child: SizedBox(width: 4), - ), - TextSpan( - text: author!.nameOrNpub, - style: Theme.of(context).textTheme.bodyMedium - ?.copyWith(fontWeight: FontWeight.bold), - ), - ] else - TextSpan( - text: 'a relay', - style: Theme.of(context).textTheme.bodyMedium - ?.copyWith(fontWeight: FontWeight.bold), - ), - const TextSpan( - text: - ', not directly published by a developer. Your zap will help support the service.\nIf you know the developer, ask them to self-publish to earn sats!', - ), - ], + // Relay-signed app explanation + if (isRelaySigned) ...[ + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Theme.of( + context, + ).colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(8), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(top: 2), + child: Icon( + Icons.info_outline, + size: 20, + color: Theme.of(context).colorScheme.primary, ), ), - ), - ], + const SizedBox(width: 12), + Expanded( + child: Text.rich( + TextSpan( + style: Theme.of(context).textTheme.bodyMedium, + children: [ + const TextSpan(text: 'This app was indexed by '), + if (author != null) ...[ + WidgetSpan( + alignment: PlaceholderAlignment.middle, + child: ProfileAvatar( + profile: author, + radius: 9, + ), + ), + const WidgetSpan( + alignment: PlaceholderAlignment.middle, + child: SizedBox(width: 4), + ), + TextSpan( + text: author!.nameOrNpub, + style: Theme.of(context).textTheme.bodyMedium + ?.copyWith(fontWeight: FontWeight.bold), + ), + ] else + TextSpan( + text: 'a relay', + style: Theme.of(context).textTheme.bodyMedium + ?.copyWith(fontWeight: FontWeight.bold), + ), + const TextSpan( + text: + ', not directly published by a developer. Your zap will help support the service.\nIf you know the developer, ask them to self-publish to earn sats!', + ), + ], + ), + ), + ), + ], + ), ), - ), - const SizedBox(height: 16), - ], + const SizedBox(height: 16), + ], - if (pubkey == null) ...[ - const SignInPrompt( - message: - 'Zapping anonymously. Sign in to zap with your identity.', - ), - const SizedBox(height: 12), - ], + if (pubkey == null) ...[ + const SignInPrompt( + message: + 'Zapping anonymously. Sign in to zap with your identity.', + ), + const SizedBox(height: 12), + ], - // Quick amount buttons (always visible) - Wrap( - spacing: 8, - runSpacing: 8, - children: [ - ...quickAmounts.map((q) { - final isSelected = selectedAmount.value == q.value; - return FilterChip( - label: Text(q.label), - selected: isSelected, + // Quick amount buttons (always visible) + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + ...quickAmounts.map((q) { + final isSelected = selectedAmount.value == q.value; + return FilterChip( + label: Text(q.label), + selected: isSelected, + showCheckmark: false, + side: BorderSide.none, + onSelected: (selected) { + selectedAmount.value = q.value; + }, + ); + }), + FilterChip( + label: Text( + customAmount.value == null + ? '💎 Custom' + : '💎 ${formatSatsCompact(customAmount.value!)}', + ), + selected: + customAmount.value != null && + selectedAmount.value == customAmount.value, showCheckmark: false, side: BorderSide.none, - onSelected: (selected) { - selectedAmount.value = q.value; + onSelected: (selected) async { + final amount = await showBaseDialog( + context: context, + dialog: const CustomAmountDialog(), + ); + if (amount != null && amount > 0) { + customAmount.value = amount; + selectedAmount.value = amount; + } }, - ); - }), - FilterChip( - label: Text( - customAmount.value == null - ? '💎 Custom' - : '💎 ${formatSatsCompact(customAmount.value!)}', ), - selected: - customAmount.value != null && - selectedAmount.value == customAmount.value, - showCheckmark: false, - side: BorderSide.none, - onSelected: (selected) async { - final amount = await showBaseDialog( - context: context, - dialog: const CustomAmountDialog(), - ); - if (amount != null && amount > 0) { - customAmount.value = amount; - selectedAmount.value = amount; - } - }, - ), - ], - ), - - const SizedBox(height: 12), - TextField( - controller: commentController, - decoration: const InputDecoration( - labelText: 'Add a comment (optional)', - border: OutlineInputBorder(), + ], ), - maxLines: 2, - ), - ], + + const SizedBox(height: 12), + TextField( + controller: commentController, + decoration: const InputDecoration( + labelText: 'Add a comment (optional)', + border: OutlineInputBorder(), + ), + maxLines: 2, + ), + ], + ), ), - ), - actions: [ - BaseDialogAction( - onPressed: () => Navigator.pop(context, false), - child: const Text('Cancel'), - ), - AsyncButtonBuilder( - onPressed: selectedAmount.value > 0 - ? () async { - try { - final navigator = Navigator.of(context); - - // Prepare signer (ephemeral if needed) - var signer = ref.read(Signer.activeSignerProvider); - if (signer == null) { - signer = Bip340PrivateKeySigner( - Utils.generateRandomHex64(), - ref.ref, + actions: [ + BaseDialogAction( + onPressed: isLoading.value + ? null + : () => Navigator.pop(context, false), + child: const Text('Cancel'), + ), + AsyncButtonBuilder( + onPressed: selectedAmount.value > 0 + ? () async { + try { + final rootNavigator = Navigator.of( + context, + rootNavigator: true, ); - await signer.signIn(registerSigner: false); - } - // Read NWC from secure storage - final nwcString = (knownHasNwc == false) - ? null - : await secureStorage.getNWCString(); - - // Build zap request - final latestMetadata = app.latestFileMetadata; - final author = app.author.value; - - if (latestMetadata == null || author == null) { - throw Exception( - 'App or author not ready. Please try again.', - ); - } - - final socialRelays = await ref - .read(storageNotifierProvider.notifier) - .resolveRelays('social'); - - final zapRequest = PartialZapRequest(); - zapRequest.amount = selectedAmount.value * 1000; // msats - final c = commentController.text.trim(); - if (c.isNotEmpty) zapRequest.comment = c; - zapRequest.linkProfileByPubkey(author.pubkey); - zapRequest.linkModel(app); - zapRequest.linkModel(latestMetadata); - zapRequest.relays = socialRelays; - - final signedZapRequest = await zapRequest.signWith(signer); - - if (nwcString != null && nwcString.isNotEmpty) { - final amount = selectedAmount.value; - - // Execute payment and wait for result - try { - await _executeZapPayment( - signedZapRequest, - nwcString, + // Prepare signer (ephemeral if needed) + var signer = ref.read(Signer.activeSignerProvider); + if (signer == null) { + signer = Bip340PrivateKeySigner( + Utils.generateRandomHex64(), ref.ref, ); - if (context.mounted) { - context.showInfo('⚡ Zap sent! $amount sats'); - navigator.pop(true); - } - } catch (e) { - if (context.mounted) { - context.showError('Zap failed', description: '$e'); - navigator.pop(false); - } + await signer.signIn(registerSigner: false); } - } else { - final invoice = await signedZapRequest.getInvoice(); - if (context.mounted) { - navigator.pop(true); - context.showInfo( - 'Lightning invoice ready', - description: - 'Copy the invoice and pay with your Lightning wallet. Setup NWC to zap directly from the app.', - actions: [ - ( - 'Copy Invoice', - () async { - await Clipboard.setData( - ClipboardData(text: invoice), - ); - }, - ), - ( - 'Setup NWC', - () async { - await showBaseDialog( - context: context, - dialog: const NWCConnectionDialogInline(), - ); - }, - ), - ], + + // Read NWC from secure storage + final nwcString = (knownHasNwc == false) + ? null + : await secureStorage.getNWCString(); + + // Build zap request + final latestMetadata = app.latestFileMetadata; + final author = app.author.value; + + if (latestMetadata == null || author == null) { + throw Exception( + 'App or author not ready. Please try again.', ); } - } - } catch (e) { - if (context.mounted) { - context.showError('Zap failed', description: '$e'); - Navigator.of(context).pop(false); + + final socialRelays = await ref + .read(storageNotifierProvider.notifier) + .resolveRelays('social'); + + final zapRequest = PartialZapRequest(); + zapRequest.amount = selectedAmount.value * 1000; // msats + final c = commentController.text.trim(); + if (c.isNotEmpty) zapRequest.comment = c; + zapRequest.linkProfileByPubkey(author.pubkey); + zapRequest.linkModel(app); + zapRequest.linkModel(latestMetadata); + zapRequest.relays = socialRelays; + + final signedZapRequest = await zapRequest.signWith( + signer, + ); + + if (nwcString != null && nwcString.isNotEmpty) { + final amount = selectedAmount.value; + + // Execute payment and wait for result + isLoading.value = true; + try { + await _executeZapPayment( + signedZapRequest, + nwcString, + ref.ref, + ); + if (context.mounted) { + context.showInfo('⚡ Zap sent! $amount sats'); + rootNavigator.pop(true); + } + } catch (e) { + if (context.mounted) { + context.showError('Zap failed', description: '$e'); + rootNavigator.pop(false); + } + } finally { + isLoading.value = false; + } + } else { + final invoice = await signedZapRequest.getInvoice(); + if (context.mounted) { + rootNavigator.pop(true); + context.showInfo( + 'Lightning invoice ready', + description: + 'Copy the invoice and pay with your Lightning wallet. Setup NWC to zap directly from the app.', + actions: [ + ( + 'Copy Invoice', + () async { + await Clipboard.setData( + ClipboardData(text: invoice), + ); + }, + ), + ( + 'Zap with NWC', + () async { + final navContext = rootNavigator.context; + if (navContext.mounted) { + await showBaseDialog( + context: navContext, + barrierDismissible: false, + dialog: NWCZapDialog( + signedZapRequest: signedZapRequest, + amount: selectedAmount.value, + ), + ); + } + }, + ), + ], + ); + } + } + } catch (e) { + if (context.mounted) { + context.showError('Zap failed', description: '$e'); + Navigator.of(context).pop(false); + } } } - } - : null, - builder: (context, child, callback, state) { - return FilledButton( - onPressed: state.maybeWhen( - loading: () => null, - orElse: () => callback, - ), - child: state.maybeWhen( - loading: () => Row( - mainAxisSize: MainAxisSize.min, - children: const [ - SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator(strokeWidth: 2), - ), - ], + : null, + builder: (context, child, callback, state) { + return FilledButton( + onPressed: state.maybeWhen( + loading: () => null, + orElse: () => callback, ), - orElse: () => Text( - 'Send ${formatSatsWithSeparators(selectedAmount.value)} sats', + child: state.maybeWhen( + loading: () => Row( + mainAxisSize: MainAxisSize.min, + children: const [ + SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ], + ), + orElse: () => Text( + 'Send ${formatSatsWithSeparators(selectedAmount.value)} sats', + ), ), - ), - ); - }, - child: const SizedBox.shrink(), - ), - ], + ); + }, + child: const SizedBox.shrink(), + ), + ], + ), ); } } @@ -580,9 +605,16 @@ class CustomAmountDialog extends HookWidget { } } -/// Inline NWC connection dialog used in zap flow -class NWCConnectionDialogInline extends HookConsumerWidget { - const NWCConnectionDialogInline({super.key}); +/// Inline NWC dialog that also executes the zap after connecting +class NWCZapDialog extends HookConsumerWidget { + const NWCZapDialog({ + super.key, + required this.signedZapRequest, + required this.amount, + }); + + final ZapRequest signedZapRequest; + final int amount; @override Widget build(BuildContext context, WidgetRef ref) { @@ -590,101 +622,109 @@ class NWCConnectionDialogInline extends HookConsumerWidget { final isLoading = useState(false); final secureStorage = ref.watch(secureStorageServiceProvider); - return BaseDialog( - titleIcon: const Text('⚡️'), - titleIconColor: Colors.orange, - title: const BaseDialogTitle('Nostr Wallet Connect'), - maxWidth: double.maxFinite, - content: BaseDialogContent( - children: [ - Text( - 'Enter your NWC connection string from your Lightning wallet:', - style: Theme.of(context).textTheme.bodyMedium, - ), - const SizedBox(height: 16), - TextField( - controller: controller, - decoration: InputDecoration( - labelText: 'NWC Connection String', - hintText: 'nostr+walletconnect://...', - border: const OutlineInputBorder(), - suffixIcon: IconButton( - icon: const Icon(Icons.paste), - onPressed: () async { - final data = await Clipboard.getData('text/plain'); - final t = data?.text; - if (t != null) controller.text = t; - }, - ), + // Prevent closing dialog during loading (back button + tap outside) + return PopScope( + canPop: !isLoading.value, + child: BaseDialog( + titleIcon: const Text('⚡️'), + titleIconColor: Colors.orange, + title: const BaseDialogTitle('Zap with NWC'), + maxWidth: double.maxFinite, + content: BaseDialogContent( + children: [ + Text( + 'Enter your NWC connection string to zap $amount sats:', + style: Theme.of(context).textTheme.bodyMedium, ), - maxLines: 3, - enabled: !isLoading.value, + const SizedBox(height: 16), + TextField( + controller: controller, + decoration: InputDecoration( + labelText: 'NWC Connection String', + hintText: 'nostr+walletconnect://...', + border: const OutlineInputBorder(), + suffixIcon: IconButton( + icon: const Icon(Icons.paste), + onPressed: () async { + final data = await Clipboard.getData('text/plain'); + final t = data?.text; + if (t != null) controller.text = t; + }, + ), + ), + maxLines: 3, + enabled: !isLoading.value, + ), + const SizedBox(height: 12), + Text( + 'Alby, Coinos, and others support NWC.', + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ), + actions: [ + BaseDialogAction( + onPressed: isLoading.value ? null : () => Navigator.pop(context), + child: const Text('Cancel'), ), - const SizedBox(height: 12), - Text( - 'Alby, Coinos, and others support NWC.', - style: Theme.of(context).textTheme.bodySmall, + FilledButton( + onPressed: isLoading.value + ? null + : () async { + final nwcString = controller.text; + if (nwcString.trim().isEmpty) { + context.showError( + 'Missing connection string', + description: + 'Get a NWC connection string from your Lightning wallet (e.g., Alby, Zeus, Coinos).', + ); + return; + } + if (!nwcString.trim().startsWith( + 'nostr+walletconnect://', + )) { + context.showError( + 'Invalid NWC format', + description: + 'Connection string should start with nostr+walletconnect://', + ); + return; + } + isLoading.value = true; + try { + // Save NWC for future use + await secureStorage.setNWCString(nwcString.trim()); + ref.invalidate(hasNwcStringProvider); + + // Execute the zap + await _executeZapPayment( + signedZapRequest, + nwcString, + ref.ref, + ); + + if (context.mounted) { + Navigator.pop(context); + context.showInfo('⚡ Zap sent! $amount sats'); + } + } catch (e) { + if (context.mounted) { + context.showError('Zap failed', description: '$e'); + } + } finally { + isLoading.value = false; + } + }, + child: isLoading.value + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : Text('Zap $amount sats'), ), ], ), - actions: [ - BaseDialogAction( - onPressed: isLoading.value - ? null - : () => Navigator.pop(context, false), - child: const Text('Cancel'), - ), - FilledButton( - onPressed: isLoading.value - ? null - : () async { - final nwcString = controller.text; - if (nwcString.trim().isEmpty) { - context.showError( - 'Missing connection string', - description: - 'Get a NWC connection string from your Lightning wallet (e.g., Alby, Zeus, Coinos).', - ); - return; - } - if (!nwcString.trim().startsWith('nostr+walletconnect://')) { - context.showError( - 'Invalid NWC format', - description: - 'Connection string should start with nostr+walletconnect://', - ); - return; - } - isLoading.value = true; - try { - await secureStorage.setNWCString(nwcString.trim()); - if (context.mounted) { - Navigator.pop(context, true); - context.showInfo( - '⚡ Lightning wallet connected successfully!', - ); - } - } catch (e) { - if (context.mounted) { - context.showError( - 'Wallet connection failed', - description: - 'Could not connect to the wallet. Verify the connection string and try again.\n\n$e', - ); - } - } finally { - isLoading.value = false; - } - }, - child: isLoading.value - ? const SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : const Text('Connect'), - ), - ], ); } } From fe0d5a100d94e1e54c4bb66befd5fe33f997380d Mon Sep 17 00:00:00 2001 From: Henrique Velloso Date: Tue, 27 Jan 2026 16:21:52 -0300 Subject: [PATCH 2/2] =?UTF-8?q?Fix=20zap=20dialogs:=20close-on-tap=20and?= =?UTF-8?q?=20safe=20async=20toasts=20-=20Use=20root=20navigator=20overlay?= =?UTF-8?q?=20(rootNavigatorKey.currentState=3F.overlay)=20for=20toast=20i?= =?UTF-8?q?nsertion=20to=20avoid=20disposed=20BuildContext=20crashes=20-?= =?UTF-8?q?=20Make=20=E2=80=9CZap=E2=80=9D=20/=20=E2=80=9CZap=20with=20NWC?= =?UTF-8?q?=E2=80=9D=20close=20dialogs=20immediately=20and=20run=20zap=20i?= =?UTF-8?q?n=20background,=20showing=20success/error=20toast=20when=20done?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/router.dart | 4 + lib/services/notification_service.dart | 5 +- lib/widgets/zap_widgets.dart | 723 ++++++++++++------------- 3 files changed, 359 insertions(+), 373 deletions(-) diff --git a/lib/router.dart b/lib/router.dart index 27169c9..dcf9be8 100644 --- a/lib/router.dart +++ b/lib/router.dart @@ -1,3 +1,4 @@ +import 'package:flutter/widgets.dart'; import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:models/models.dart'; @@ -12,6 +13,8 @@ import 'package:zapstore/screens/profile_screen.dart'; /// Root paths for each navigation branch (used for back navigation handling) const kBranchRoots = ['/search', '/updates', '/profile']; +final rootNavigatorKey = GlobalKey(); + typedef _ResolvedRoute = ({String identifier, String? author}); _ResolvedRoute _resolveNaddrRouteId(String rawId) { @@ -71,6 +74,7 @@ GoRoute _userRoute() { final routerProvider = Provider((ref) { return GoRouter( + navigatorKey: rootNavigatorKey, initialLocation: '/search', routes: [ // Top-level route for market:// intents diff --git a/lib/services/notification_service.dart b/lib/services/notification_service.dart index f6456b5..f52f48c 100644 --- a/lib/services/notification_service.dart +++ b/lib/services/notification_service.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:zapstore/router.dart'; import 'package:zapstore/theme.dart'; extension ContextX on BuildContext { @@ -68,7 +69,9 @@ void _showCustomToast({ _currentToast?.remove(); _currentToast = null; - final overlay = Overlay.of(context); + final overlay = + rootNavigatorKey.currentState?.overlay ?? Overlay.maybeOf(context); + if (overlay == null) return; late OverlayEntry entry; entry = OverlayEntry( diff --git a/lib/widgets/zap_widgets.dart b/lib/widgets/zap_widgets.dart index 128ad55..29ea330 100644 --- a/lib/widgets/zap_widgets.dart +++ b/lib/widgets/zap_widgets.dart @@ -1,9 +1,12 @@ +import 'dart:async'; + import 'package:async_button_builder/async_button_builder.dart'; 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/router.dart'; import 'package:zapstore/services/notification_service.dart'; import 'package:zapstore/services/secure_storage_service.dart'; import 'package:zapstore/utils/extensions.dart'; @@ -202,7 +205,6 @@ Future showZapDialog( ) async { final result = await showBaseDialog( context: context, - barrierDismissible: false, dialog: ZapAmountDialog( app: app, isRelaySigned: app.isRelaySigned, @@ -230,7 +232,6 @@ class ZapAmountDialog extends HookConsumerWidget { final selectedAmount = useState(2100); final commentController = useTextEditingController(); final customAmount = useState(null); - final isLoading = useState(false); final quickAmounts = useMemoized( () => [ (label: '🤙 2100', value: 2100), @@ -245,304 +246,299 @@ class ZapAmountDialog extends HookConsumerWidget { final hasNwc = ref.watch(hasNwcStringProvider); final knownHasNwc = hasNwc.valueOrNull; - // Prevent closing dialog during zap execution - return PopScope( - canPop: !isLoading.value, - child: BaseDialog( - titleIcon: const Text('⚡️'), - titleIconColor: Colors.orange, - title: Text( - isRelaySigned ? 'Zap the relay' : 'Zap ${app.name}', - style: Theme.of(context).textTheme.headlineSmall, + return BaseDialog( + titleIcon: const Text('⚡️'), + titleIconColor: Colors.orange, + title: Text( + isRelaySigned ? 'Zap the relay' : 'Zap ${app.name}', + style: Theme.of(context).textTheme.headlineSmall, + ), + content: Padding( + padding: EdgeInsets.only( + bottom: MediaQuery.of(context).viewInsets.bottom, ), - content: Padding( - padding: EdgeInsets.only( - bottom: MediaQuery.of(context).viewInsets.bottom, - ), - child: BaseDialogContent( - children: [ - const SizedBox(height: 4), + child: BaseDialogContent( + children: [ + const SizedBox(height: 4), - // Relay-signed app explanation - if (isRelaySigned) ...[ - Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: Theme.of( - context, - ).colorScheme.surfaceContainerHighest, - borderRadius: BorderRadius.circular(8), - ), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.only(top: 2), - child: Icon( - Icons.info_outline, - size: 20, - color: Theme.of(context).colorScheme.primary, - ), - ), - const SizedBox(width: 12), - Expanded( - child: Text.rich( - TextSpan( - style: Theme.of(context).textTheme.bodyMedium, - children: [ - const TextSpan(text: 'This app was indexed by '), - if (author != null) ...[ - WidgetSpan( - alignment: PlaceholderAlignment.middle, - child: ProfileAvatar( - profile: author, - radius: 9, - ), - ), - const WidgetSpan( - alignment: PlaceholderAlignment.middle, - child: SizedBox(width: 4), - ), - TextSpan( - text: author!.nameOrNpub, - style: Theme.of(context).textTheme.bodyMedium - ?.copyWith(fontWeight: FontWeight.bold), - ), - ] else - TextSpan( - text: 'a relay', - style: Theme.of(context).textTheme.bodyMedium - ?.copyWith(fontWeight: FontWeight.bold), - ), - const TextSpan( - text: - ', not directly published by a developer. Your zap will help support the service.\nIf you know the developer, ask them to self-publish to earn sats!', - ), - ], - ), - ), - ), - ], - ), + // Relay-signed app explanation + if (isRelaySigned) ...[ + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(8), ), - const SizedBox(height: 16), - ], - - if (pubkey == null) ...[ - const SignInPrompt( - message: - 'Zapping anonymously. Sign in to zap with your identity.', - ), - const SizedBox(height: 12), - ], - - // Quick amount buttons (always visible) - Wrap( - spacing: 8, - runSpacing: 8, - children: [ - ...quickAmounts.map((q) { - final isSelected = selectedAmount.value == q.value; - return FilterChip( - label: Text(q.label), - selected: isSelected, - showCheckmark: false, - side: BorderSide.none, - onSelected: (selected) { - selectedAmount.value = q.value; - }, - ); - }), - FilterChip( - label: Text( - customAmount.value == null - ? '💎 Custom' - : '💎 ${formatSatsCompact(customAmount.value!)}', + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(top: 2), + child: Icon( + Icons.info_outline, + size: 20, + color: Theme.of(context).colorScheme.primary, + ), ), - selected: - customAmount.value != null && - selectedAmount.value == customAmount.value, + const SizedBox(width: 12), + Expanded( + child: Text.rich( + TextSpan( + style: Theme.of(context).textTheme.bodyMedium, + children: [ + const TextSpan(text: 'This app was indexed by '), + if (author != null) ...[ + WidgetSpan( + alignment: PlaceholderAlignment.middle, + child: ProfileAvatar( + profile: author, + radius: 9, + ), + ), + const WidgetSpan( + alignment: PlaceholderAlignment.middle, + child: SizedBox(width: 4), + ), + TextSpan( + text: author!.nameOrNpub, + style: Theme.of(context).textTheme.bodyMedium + ?.copyWith(fontWeight: FontWeight.bold), + ), + ] else + TextSpan( + text: 'a relay', + style: Theme.of(context).textTheme.bodyMedium + ?.copyWith(fontWeight: FontWeight.bold), + ), + const TextSpan( + text: + ', not directly published by a developer. Your zap will help support the service.\nIf you know the developer, ask them to self-publish to earn sats!', + ), + ], + ), + ), + ), + ], + ), + ), + const SizedBox(height: 16), + ], + + if (pubkey == null) ...[ + const SignInPrompt( + message: + 'Zapping anonymously. Sign in to zap with your identity.', + ), + const SizedBox(height: 12), + ], + + // Quick amount buttons (always visible) + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + ...quickAmounts.map((q) { + final isSelected = selectedAmount.value == q.value; + return FilterChip( + label: Text(q.label), + selected: isSelected, showCheckmark: false, side: BorderSide.none, - onSelected: (selected) async { - final amount = await showBaseDialog( - context: context, - dialog: const CustomAmountDialog(), - ); - if (amount != null && amount > 0) { - customAmount.value = amount; - selectedAmount.value = amount; - } + onSelected: (selected) { + selectedAmount.value = q.value; }, + ); + }), + FilterChip( + label: Text( + customAmount.value == null + ? '💎 Custom' + : '💎 ${formatSatsCompact(customAmount.value!)}', ), - ], - ), - - const SizedBox(height: 12), - TextField( - controller: commentController, - decoration: const InputDecoration( - labelText: 'Add a comment (optional)', - border: OutlineInputBorder(), + selected: + customAmount.value != null && + selectedAmount.value == customAmount.value, + showCheckmark: false, + side: BorderSide.none, + onSelected: (selected) async { + final amount = await showBaseDialog( + context: context, + dialog: const CustomAmountDialog(), + ); + if (amount != null && amount > 0) { + customAmount.value = amount; + selectedAmount.value = amount; + } + }, ), - maxLines: 2, + ], + ), + + const SizedBox(height: 12), + TextField( + controller: commentController, + decoration: const InputDecoration( + labelText: 'Add a comment (optional)', + border: OutlineInputBorder(), ), - ], - ), + maxLines: 2, + ), + ], ), - actions: [ - BaseDialogAction( - onPressed: isLoading.value - ? null - : () => Navigator.pop(context, false), - child: const Text('Cancel'), - ), - AsyncButtonBuilder( - onPressed: selectedAmount.value > 0 - ? () async { - try { - final rootNavigator = Navigator.of( - context, - rootNavigator: true, - ); + ), + actions: [ + BaseDialogAction( + onPressed: () => Navigator.pop(context, false), + child: const Text('Cancel'), + ), + AsyncButtonBuilder( + onPressed: selectedAmount.value > 0 + ? () async { + try { + final rootNavigator = Navigator.of( + context, + rootNavigator: true, + ); + final toastContext = + rootNavigatorKey.currentState?.overlay?.context; + final amount = selectedAmount.value; + final comment = commentController.text.trim(); - // Prepare signer (ephemeral if needed) - var signer = ref.read(Signer.activeSignerProvider); - if (signer == null) { - signer = Bip340PrivateKeySigner( - Utils.generateRandomHex64(), - ref.ref, + // Close dialog immediately on tap (no loading state) + if (context.mounted) { + rootNavigator.pop(true); + } + + unawaited(() async { + try { + // Prepare signer (ephemeral if needed) + var signer = ref.read(Signer.activeSignerProvider); + if (signer == null) { + signer = Bip340PrivateKeySigner( + Utils.generateRandomHex64(), + ref.ref, + ); + await signer.signIn(registerSigner: false); + } + + // Read NWC from secure storage + final nwcString = (knownHasNwc == false) + ? null + : await secureStorage.getNWCString(); + + // Build zap request + final latestMetadata = app.latestFileMetadata; + final author = app.author.value; + + if (latestMetadata == null || author == null) { + throw Exception( + 'App or author not ready. Please try again.', + ); + } + + final socialRelays = await ref + .read(storageNotifierProvider.notifier) + .resolveRelays('social'); + + final zapRequest = PartialZapRequest(); + zapRequest.amount = amount * 1000; // msats + if (comment.isNotEmpty) zapRequest.comment = comment; + zapRequest.linkProfileByPubkey(author.pubkey); + zapRequest.linkModel(app); + zapRequest.linkModel(latestMetadata); + zapRequest.relays = socialRelays; + + final signedZapRequest = await zapRequest.signWith( + signer, ); - await signer.signIn(registerSigner: false); - } - // Read NWC from secure storage - final nwcString = (knownHasNwc == false) - ? null - : await secureStorage.getNWCString(); - - // Build zap request - final latestMetadata = app.latestFileMetadata; - final author = app.author.value; - - if (latestMetadata == null || author == null) { - throw Exception( - 'App or author not ready. Please try again.', - ); - } - - final socialRelays = await ref - .read(storageNotifierProvider.notifier) - .resolveRelays('social'); - - final zapRequest = PartialZapRequest(); - zapRequest.amount = selectedAmount.value * 1000; // msats - final c = commentController.text.trim(); - if (c.isNotEmpty) zapRequest.comment = c; - zapRequest.linkProfileByPubkey(author.pubkey); - zapRequest.linkModel(app); - zapRequest.linkModel(latestMetadata); - zapRequest.relays = socialRelays; - - final signedZapRequest = await zapRequest.signWith( - signer, - ); - - if (nwcString != null && nwcString.isNotEmpty) { - final amount = selectedAmount.value; - - // Execute payment and wait for result - isLoading.value = true; - try { + if (nwcString != null && nwcString.isNotEmpty) { await _executeZapPayment( signedZapRequest, nwcString, ref.ref, ); - if (context.mounted) { - context.showInfo('⚡ Zap sent! $amount sats'); - rootNavigator.pop(true); + if (toastContext != null && toastContext.mounted) { + toastContext.showInfo('⚡ Zap sent! $amount sats'); } - } catch (e) { - if (context.mounted) { - context.showError('Zap failed', description: '$e'); - rootNavigator.pop(false); - } - } finally { - isLoading.value = false; - } - } else { - final invoice = await signedZapRequest.getInvoice(); - if (context.mounted) { - rootNavigator.pop(true); - context.showInfo( - 'Lightning invoice ready', - description: - 'Copy the invoice and pay with your Lightning wallet. Setup NWC to zap directly from the app.', - actions: [ - ( - 'Copy Invoice', - () async { - await Clipboard.setData( - ClipboardData(text: invoice), - ); - }, - ), - ( - 'Zap with NWC', - () async { - final navContext = rootNavigator.context; - if (navContext.mounted) { - await showBaseDialog( - context: navContext, - barrierDismissible: false, - dialog: NWCZapDialog( - signedZapRequest: signedZapRequest, - amount: selectedAmount.value, - ), + } else { + final invoice = await signedZapRequest.getInvoice(); + if (toastContext != null && toastContext.mounted) { + toastContext.showInfo( + 'Lightning invoice ready', + description: + 'Copy the invoice and pay with your Lightning wallet. Setup NWC to zap directly from the app.', + actions: [ + ( + 'Copy Invoice', + () async { + await Clipboard.setData( + ClipboardData(text: invoice), ); - } - }, - ), - ], + }, + ), + ( + 'Zap with NWC', + () async { + final navContext = rootNavigator.context; + if (navContext.mounted) { + await showBaseDialog( + context: navContext, + dialog: NWCZapDialog( + signedZapRequest: signedZapRequest, + amount: amount, + ), + ); + } + }, + ), + ], + ); + } + } + } catch (e) { + if (toastContext != null && toastContext.mounted) { + toastContext.showError( + 'Zap failed', + description: '$e', ); } } - } catch (e) { - if (context.mounted) { - context.showError('Zap failed', description: '$e'); - Navigator.of(context).pop(false); - } + }()); + } catch (e) { + if (context.mounted) { + context.showError('Zap failed', description: '$e'); + Navigator.of(context).pop(false); } } - : null, - builder: (context, child, callback, state) { - return FilledButton( - onPressed: state.maybeWhen( - loading: () => null, - orElse: () => callback, + } + : null, + builder: (context, child, callback, state) { + return FilledButton( + onPressed: state.maybeWhen( + loading: () => null, + orElse: () => callback, + ), + child: state.maybeWhen( + loading: () => Row( + mainAxisSize: MainAxisSize.min, + children: const [ + SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ], ), - child: state.maybeWhen( - loading: () => Row( - mainAxisSize: MainAxisSize.min, - children: const [ - SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator(strokeWidth: 2), - ), - ], - ), - orElse: () => Text( - 'Send ${formatSatsWithSeparators(selectedAmount.value)} sats', - ), + orElse: () => Text( + 'Send ${formatSatsWithSeparators(selectedAmount.value)} sats', ), - ); - }, - child: const SizedBox.shrink(), - ), - ], - ), + ), + ); + }, + child: const SizedBox.shrink(), + ), + ], ); } } @@ -619,112 +615,95 @@ class NWCZapDialog extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final controller = useTextEditingController(); - final isLoading = useState(false); final secureStorage = ref.watch(secureStorageServiceProvider); - // Prevent closing dialog during loading (back button + tap outside) - return PopScope( - canPop: !isLoading.value, - child: BaseDialog( - titleIcon: const Text('⚡️'), - titleIconColor: Colors.orange, - title: const BaseDialogTitle('Zap with NWC'), - maxWidth: double.maxFinite, - content: BaseDialogContent( - children: [ - Text( - 'Enter your NWC connection string to zap $amount sats:', - style: Theme.of(context).textTheme.bodyMedium, - ), - const SizedBox(height: 16), - TextField( - controller: controller, - decoration: InputDecoration( - labelText: 'NWC Connection String', - hintText: 'nostr+walletconnect://...', - border: const OutlineInputBorder(), - suffixIcon: IconButton( - icon: const Icon(Icons.paste), - onPressed: () async { - final data = await Clipboard.getData('text/plain'); - final t = data?.text; - if (t != null) controller.text = t; - }, - ), - ), - maxLines: 3, - enabled: !isLoading.value, - ), - const SizedBox(height: 12), - Text( - 'Alby, Coinos, and others support NWC.', - style: Theme.of(context).textTheme.bodySmall, - ), - ], - ), - actions: [ - BaseDialogAction( - onPressed: isLoading.value ? null : () => Navigator.pop(context), - child: const Text('Cancel'), + return BaseDialog( + titleIcon: const Text('⚡️'), + titleIconColor: Colors.orange, + title: const BaseDialogTitle('Zap with NWC'), + maxWidth: double.maxFinite, + content: BaseDialogContent( + children: [ + Text( + 'Enter your NWC connection string to zap $amount sats:', + style: Theme.of(context).textTheme.bodyMedium, ), - FilledButton( - onPressed: isLoading.value - ? null - : () async { - final nwcString = controller.text; - if (nwcString.trim().isEmpty) { - context.showError( - 'Missing connection string', - description: - 'Get a NWC connection string from your Lightning wallet (e.g., Alby, Zeus, Coinos).', - ); - return; - } - if (!nwcString.trim().startsWith( - 'nostr+walletconnect://', - )) { - context.showError( - 'Invalid NWC format', - description: - 'Connection string should start with nostr+walletconnect://', - ); - return; - } - isLoading.value = true; - try { - // Save NWC for future use - await secureStorage.setNWCString(nwcString.trim()); - ref.invalidate(hasNwcStringProvider); - - // Execute the zap - await _executeZapPayment( - signedZapRequest, - nwcString, - ref.ref, - ); - - if (context.mounted) { - Navigator.pop(context); - context.showInfo('⚡ Zap sent! $amount sats'); - } - } catch (e) { - if (context.mounted) { - context.showError('Zap failed', description: '$e'); - } - } finally { - isLoading.value = false; - } - }, - child: isLoading.value - ? const SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : Text('Zap $amount sats'), + const SizedBox(height: 16), + TextField( + controller: controller, + decoration: InputDecoration( + labelText: 'NWC Connection String', + hintText: 'nostr+walletconnect://...', + border: const OutlineInputBorder(), + suffixIcon: IconButton( + icon: const Icon(Icons.paste), + onPressed: () async { + final data = await Clipboard.getData('text/plain'); + final t = data?.text; + if (t != null) controller.text = t; + }, + ), + ), + maxLines: 3, + ), + const SizedBox(height: 12), + Text( + 'Alby, Coinos, and others support NWC.', + style: Theme.of(context).textTheme.bodySmall, ), ], ), + actions: [ + BaseDialogAction( + onPressed: () => Navigator.pop(context), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () async { + final nwcString = controller.text; + if (nwcString.trim().isEmpty) { + context.showError( + 'Missing connection string', + description: + 'Get a NWC connection string from your Lightning wallet (e.g., Alby, Zeus, Coinos).', + ); + return; + } + if (!nwcString.trim().startsWith('nostr+walletconnect://')) { + context.showError( + 'Invalid NWC format', + description: + 'Connection string should start with nostr+walletconnect://', + ); + return; + } + + final toastContext = + rootNavigatorKey.currentState?.overlay?.context; + + // Close dialog immediately on tap (no loading state) + if (context.mounted) { + Navigator.pop(context); + } + + unawaited(() async { + try { + await secureStorage.setNWCString(nwcString.trim()); + ref.invalidate(hasNwcStringProvider); + await _executeZapPayment(signedZapRequest, nwcString, ref.ref); + if (toastContext != null && toastContext.mounted) { + toastContext.showInfo('⚡ Zap sent! $amount sats'); + } + } catch (e) { + if (toastContext != null && toastContext.mounted) { + toastContext.showError('Zap failed', description: '$e'); + } + } + }()); + }, + child: Text('Zap $amount sats'), + ), + ], ); } }