Merge pull request #291 from zapstore/fix/275-setup-nwc-goes-nowhere

Fix NWC button crash and improve zap flow UX
This commit is contained in:
franzap
2026-01-27 19:34:15 +00:00
committed by GitHub
4 changed files with 200 additions and 174 deletions
+5 -5
View File
@@ -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<NavigatorState>();
typedef _ResolvedRoute = ({String identifier, String? author});
_ResolvedRoute _resolveNaddrRouteId(String rawId) {
@@ -73,9 +74,8 @@ GoRoute _userRoute() {
}
final routerProvider = Provider<GoRouter>((ref) {
String? previousPath;
final router = GoRouter(
return GoRouter(
navigatorKey: rootNavigatorKey,
initialLocation: '/search',
routes: [
// Top-level route for market:// intents
+33 -28
View File
@@ -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<void> 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<Offset>(
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(),
),
],
+2
View File
@@ -5,10 +5,12 @@ import 'package:flutter/material.dart';
Future<T?> showBaseDialog<T>({
required BuildContext context,
required Widget dialog,
bool barrierDismissible = true,
}) {
return showDialog<T>(
context: context,
barrierColor: Colors.black.withValues(alpha: 0.25),
barrierDismissible: barrierDismissible,
builder: (context) => dialog,
);
}
+160 -141
View File
@@ -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<void>(
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<bool>(
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'),
),
],
);