Files
zapstore/lib/widgets/zap_widgets.dart
T
Henrique Velloso fe0d5a100d Fix zap dialogs: close-on-tap and safe async toasts
- Use root navigator overlay (rootNavigatorKey.currentState?.overlay) for toast insertion to avoid disposed BuildContext crashes
- Make “Zap” / “Zap with NWC” close dialogs immediately and run zap in background, showing success/error toast when done
2026-01-27 16:21:52 -03:00

710 lines
24 KiB
Dart

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';
import 'package:zapstore/widgets/auth_widgets.dart';
import 'package:zapstore/widgets/common/base_dialog.dart';
import 'package:zapstore/widgets/common/profile_avatar.dart';
/// Zap button for apps - shows a button to zap the app or relay
class ZapButton extends HookConsumerWidget {
const ZapButton({super.key, required this.app, required this.author});
final App app;
final Profile? author;
@override
Widget build(BuildContext context, WidgetRef ref) {
// Check if author (relay or developer) has lightning address
final hasLud16 = author?.lud16?.trim().isNotEmpty ?? false;
final canZap = author != null && hasLud16;
// Determine button text based on app signing
final buttonText = app.isRelaySigned ? 'Zap' : 'Zap this app';
return SizedBox(
height: 36,
child: FilledButton(
onPressed: canZap ? () => _showZapDialog(context, ref, app) : null,
style: FilledButton.styleFrom(
backgroundColor: Theme.of(context).brightness == Brightness.dark
? const Color(0xFF1F2937)
: const Color(0xFF111827),
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(horizontal: 16),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Text(
'⚡️',
style: TextStyle(color: Colors.orange, fontSize: 16),
),
const SizedBox(width: 8),
Flexible(
child: Text(
buttonText,
style: Theme.of(context).textTheme.labelLarge?.copyWith(
color: Colors.white,
fontWeight: FontWeight.bold,
),
overflow: TextOverflow.ellipsis,
),
),
],
),
),
);
}
Future<bool> _showZapDialog(
BuildContext context,
WidgetRef ref,
App app,
) async {
return showZapDialog(context, ref, app, author);
}
}
/// Horizontal list of zappers with their profile avatars and amounts
class ZappersHorizontalList extends StatelessWidget {
const ZappersHorizontalList({
super.key,
required this.zaps,
this.profilesMap,
});
final List<Zap> zaps;
/// Optional map of pubkey -> Profile. If not provided, falls back to
/// relationship-loaded profiles (legacy behavior).
final Map<String, Profile>? profilesMap;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final color = theme.textTheme.bodySmall?.color?.withValues(alpha: 0.8);
// Group sats per zapper using metadata['author'] (extracted from description tag)
final Map<String, int> satsPerPubkey = <String, int>{};
final Map<String, Profile> profileByPubkey = <String, Profile>{};
for (final zap in zaps) {
// The zapper's pubkey is in event.metadata['author'], extracted from description
final zapperPubkey = zap.event.metadata['author'] as String?;
if (zapperPubkey == null) continue;
// Get profile from provided map or fall back to author relationship
Profile? chosenProfile;
if (profilesMap != null) {
chosenProfile = profilesMap![zapperPubkey];
} else {
// Legacy: try to get from author relationship (which now points to zapper)
chosenProfile = zap.author.value;
}
satsPerPubkey[zapperPubkey] =
(satsPerPubkey[zapperPubkey] ?? 0) + zap.amount;
if (chosenProfile != null) {
profileByPubkey[zapperPubkey] = chosenProfile;
}
}
final entries = satsPerPubkey.entries.toList()
..sort((a, b) => b.value.compareTo(a.value));
if (entries.isEmpty) return const SizedBox.shrink();
final totalSats = satsPerPubkey.values.fold<int>(0, (sum, v) => sum + v);
return SizedBox(
height: 44,
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Text(
'⚡️${formatSatsCompact(totalSats)}',
style: theme.textTheme.titleLarge?.copyWith(
color: Colors.white,
),
),
const SizedBox(width: 6),
Text(
'sats by',
style: theme.textTheme.bodyMedium?.copyWith(color: color),
),
const SizedBox(width: 12),
],
),
for (final e in entries) ...[
Row(
mainAxisSize: MainAxisSize.min,
children: [
ProfileAvatar(profile: profileByPubkey[e.key], radius: 12),
const SizedBox(width: 6),
Text(
formatSatsCompact(e.value),
style: theme.textTheme.labelSmall?.copyWith(
color: color,
fontWeight: FontWeight.bold,
),
),
],
),
const SizedBox(width: 12),
],
],
),
),
);
}
}
/// Format sats with compact notation (k, m)
String formatSatsCompact(int sats) {
if (sats >= 1000000) {
final value = sats / 1000000;
return value % 1 == 0
? '${value.toInt()}m'
: '${value.toStringAsFixed(1)}m';
}
if (sats >= 1000) {
final value = sats / 1000;
return value % 1 == 0
? '${value.toInt()}k'
: '${value.toStringAsFixed(1)}k';
}
return sats.toString();
}
/// Format sats with thousand separators (commas)
String formatSatsWithSeparators(int sats) {
final s = sats.toString();
final reg = RegExp(r'\B(?=(\d{3})+(?!\d))');
return s.replaceAllMapped(reg, (m) => ',');
}
/// Show zap dialog for an app
Future<bool> showZapDialog(
BuildContext context,
WidgetRef ref,
App app,
Profile? author,
) async {
final result = await showBaseDialog<bool>(
context: context,
dialog: ZapAmountDialog(
app: app,
isRelaySigned: app.isRelaySigned,
author: author,
),
);
return result ?? false;
}
/// Dialog for selecting zap amount and sending zap
class ZapAmountDialog extends HookConsumerWidget {
const ZapAmountDialog({
super.key,
required this.app,
required this.isRelaySigned,
required this.author,
});
final App app;
final bool isRelaySigned;
final Profile? author;
@override
Widget build(BuildContext context, WidgetRef ref) {
final selectedAmount = useState<int>(2100);
final commentController = useTextEditingController();
final customAmount = useState<int?>(null);
final quickAmounts = useMemoized(
() => [
(label: '🤙 2100', value: 2100),
(label: '💜 10k', value: 10_000),
(label: '🤩 21k', value: 21_000),
(label: '🏃 42k', value: 42_000),
(label: '💯 100k', value: 100_000),
],
);
final pubkey = ref.watch(Signer.activePubkeyProvider);
final secureStorage = ref.watch(secureStorageServiceProvider);
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,
),
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!',
),
],
),
),
),
],
),
),
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!)}',
),
selected:
customAmount.value != null &&
selectedAmount.value == customAmount.value,
showCheckmark: false,
side: BorderSide.none,
onSelected: (selected) async {
final amount = await showBaseDialog<int>(
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,
),
],
),
),
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();
// 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,
);
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 (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);
}
}
}
: 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),
),
],
),
orElse: () => Text(
'Send ${formatSatsWithSeparators(selectedAmount.value)} sats',
),
),
);
},
child: const SizedBox.shrink(),
),
],
);
}
}
/// Execute zap payment using NWC connection string from secure storage
Future<PayInvoiceResult> _executeZapPayment(
ZapRequest signedZapRequest,
String nwcString,
Ref ref,
) async {
final lightningInvoice = await signedZapRequest.getInvoice();
final command = PayInvoiceCommand(invoice: lightningInvoice);
final result = await command.execute(
connectionUri: nwcString,
ref: ref,
timeout: const Duration(seconds: 30),
);
return result;
}
/// Dialog for entering a custom zap amount
class CustomAmountDialog extends HookWidget {
const CustomAmountDialog({super.key});
@override
Widget build(BuildContext context) {
final controller = useTextEditingController();
final amount = useState<int?>(null);
return BaseDialog(
title: const BaseDialogTitle('Enter custom amount'),
content: BaseDialogContent(
children: [
TextField(
controller: controller,
decoration: const InputDecoration(
labelText: 'Amount in sats',
border: OutlineInputBorder(),
),
keyboardType: TextInputType.number,
onChanged: (value) => amount.value = int.tryParse(value),
),
],
),
actions: [
BaseDialogAction(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
),
FilledButton(
onPressed: (amount.value != null && amount.value! > 0)
? () => Navigator.pop(context, amount.value)
: null,
child: const Text('Done'),
),
],
);
}
}
/// 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 secureStorage = ref.watch(secureStorageServiceProvider);
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,
),
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'),
),
],
);
}
}