diff --git a/lib/router.dart b/lib/router.dart index 53e5e2f..8099d5a 100644 --- a/lib/router.dart +++ b/lib/router.dart @@ -1,5 +1,4 @@ -import 'dart:async'; - +import 'package:flutter/widgets.dart'; import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:models/models.dart'; @@ -15,6 +14,8 @@ import 'package:zapstore/services/package_manager/package_manager.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) { @@ -73,9 +74,8 @@ GoRoute _userRoute() { } final routerProvider = Provider((ref) { - String? previousPath; - - final router = GoRouter( + 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 57541b1..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 { @@ -31,11 +32,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, @@ -63,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( @@ -128,10 +136,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 +148,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 +184,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 +307,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 +316,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 +328,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 +369,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..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'; @@ -392,98 +395,116 @@ class ZapAmountDialog extends HookConsumerWidget { onPressed: selectedAmount.value > 0 ? () async { try { - final navigator = Navigator.of(context); + 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, - ); - await signer.signIn(registerSigner: false); + // Close dialog immediately on tap (no loading state) + if (context.mounted) { + rootNavigator.pop(true); } - // 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 + unawaited(() async { try { - await _executeZapPayment( - signedZapRequest, - nwcString, - ref.ref, + // 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, ); - if (context.mounted) { - context.showInfo('⚡ Zap sent! $amount sats'); - navigator.pop(true); + + if (nwcString != null && nwcString.isNotEmpty) { + await _executeZapPayment( + signedZapRequest, + nwcString, + ref.ref, + ); + if (toastContext != null && toastContext.mounted) { + toastContext.showInfo('⚡ Zap sent! $amount sats'); + } + } 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 (context.mounted) { - context.showError('Zap failed', description: '$e'); - navigator.pop(false); + if (toastContext != null && toastContext.mounted) { + toastContext.showError( + 'Zap failed', + description: '$e', + ); } } - } 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(), - ); - }, - ), - ], - ); - } - } + }()); } catch (e) { if (context.mounted) { context.showError('Zap failed', description: '$e'); @@ -580,25 +601,31 @@ 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) { final controller = useTextEditingController(); - final isLoading = useState(false); final secureStorage = ref.watch(secureStorageServiceProvider); return BaseDialog( titleIcon: const Text('⚡️'), titleIconColor: Colors.orange, - title: const BaseDialogTitle('Nostr Wallet Connect'), + title: const BaseDialogTitle('Zap with NWC'), maxWidth: double.maxFinite, content: BaseDialogContent( children: [ Text( - 'Enter your NWC connection string from your Lightning wallet:', + 'Enter your NWC connection string to zap $amount sats:', style: Theme.of(context).textTheme.bodyMedium, ), const SizedBox(height: 16), @@ -618,7 +645,6 @@ class NWCConnectionDialogInline extends HookConsumerWidget { ), ), maxLines: 3, - enabled: !isLoading.value, ), const SizedBox(height: 12), Text( @@ -629,60 +655,53 @@ class NWCConnectionDialogInline extends HookConsumerWidget { ), actions: [ BaseDialogAction( - onPressed: isLoading.value - ? null - : () => Navigator.pop(context, false), + onPressed: () => Navigator.pop(context), 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'), + 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'), ), ], );