From fe0d5a100d94e1e54c4bb66befd5fe33f997380d Mon Sep 17 00:00:00 2001 From: Henrique Velloso Date: Tue, 27 Jan 2026 16:21:52 -0300 Subject: [PATCH] =?UTF-8?q?Fix=20zap=20dialogs:=20close-on-tap=20and=20saf?= =?UTF-8?q?e=20async=20toasts=20-=20Use=20root=20navigator=20overlay=20(ro?= =?UTF-8?q?otNavigatorKey.currentState=3F.overlay)=20for=20toast=20inserti?= =?UTF-8?q?on=20to=20avoid=20disposed=20BuildContext=20crashes=20-=20Make?= =?UTF-8?q?=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'), + ), + ], ); } }