Files
me.elcaju/lib/screens/12_request/request_screen.dart
T

909 lines
28 KiB
Dart

import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import 'package:qr_flutter/qr_flutter.dart';
import 'package:lucide_icons/lucide_icons.dart';
import 'package:share_plus/share_plus.dart';
import '../../src/rust/api/wallet.dart';
import '../../src/rust/api/payment_request.dart';
import 'package:elcaju/l10n/app_localizations.dart';
import '../../core/constants/colors.dart';
import '../../core/constants/dimensions.dart';
import '../../core/utils/formatters.dart';
// TODO: re-enable once CDK fixes NIP-17 (cashubtc/cdk#1807)
// import '../../core/utils/bip321_builder.dart';
import '../../core/services/nfc_service.dart';
import '../../widgets/common/gradient_background.dart';
import '../../widgets/common/glass_card.dart';
import '../../widgets/common/primary_button.dart';
import '../../widgets/common/numpad_widget.dart';
import '../../providers/wallet_provider.dart';
enum RequestStatus { input, generating, waiting, received, error }
enum QrMode { universal, cashu, lightning }
/// Pantalla para solicitar pagos via Payment Request unificado.
/// Genera creqB (Cashu/Nostr) + Lightning invoice en paralelo.
class RequestScreen extends StatefulWidget {
const RequestScreen({super.key});
@override
State<RequestScreen> createState() => _RequestScreenState();
}
class _RequestScreenState extends State<RequestScreen> {
final TextEditingController _descriptionController = TextEditingController();
// Input state
String _amountValue = '';
late String _activeUnit;
// Request state
RequestStatus _status = RequestStatus.input;
String? _errorMessage;
// Payment data
String? _creqB;
String? _bolt11;
// TODO: re-enable QrMode.cashu / universal once CDK fixes NIP-17 sender pubkey bug
// See: https://github.com/cashubtc/cdk/issues/1807
// QrMode _activeMode = QrMode.lightning;
bool _paymentHandled = false;
// Listeners
StreamSubscription<NostrPaymentEvent>? _nostrSubscription;
StreamSubscription<MintQuote>? _mintSubscription;
// NFC
NfcState _nfcState = NfcState.unsupported;
bool _nfcEmulating = false;
// Success
BigInt _receivedAmount = BigInt.zero;
late final WalletProvider _walletProvider;
@override
void initState() {
super.initState();
_walletProvider = context.read<WalletProvider>();
_activeUnit = _walletProvider.activeUnit;
_checkNfc();
}
@override
void dispose() {
_descriptionController.dispose();
// TODO: re-enable once CDK fixes NIP-17 (cashubtc/cdk#1807)
// No-op while Nostr is disabled (_nostrSubscription is always null, _creqB is always null)
_nostrSubscription?.cancel();
_mintSubscription?.cancel();
if (_nfcEmulating) NfcService.stopEmulating();
if (!_paymentHandled && _creqB != null) {
_walletProvider.removePendingNostrRequest();
}
super.dispose();
}
Future<void> _checkNfc() async {
final state = await NfcService.checkState();
if (mounted) setState(() => _nfcState = state);
}
String get _unitLabel => UnitFormatter.getUnitLabel(_activeUnit);
BigInt get _amount => UnitFormatter.parseRawDigits(_amountValue, _activeUnit);
bool get _isValidAmount => _amount > BigInt.zero;
// ─── Build ───
@override
Widget build(BuildContext context) {
return GradientBackground(
child: Scaffold(
backgroundColor: Colors.transparent,
appBar: AppBar(
backgroundColor: Colors.transparent,
elevation: 0,
leading: IconButton(
icon: const Icon(LucideIcons.arrowLeft, color: Colors.white),
onPressed: () => Navigator.pop(context),
),
title: Text(
L10n.of(context)!.requestPayment,
style: const TextStyle(
fontFamily: 'Inter',
fontWeight: FontWeight.w600,
color: Colors.white,
),
),
actions: [
if (_status == RequestStatus.waiting)
IconButton(
icon: const Icon(LucideIcons.share2, color: Colors.white),
onPressed: _shareRequest,
),
],
),
body: SafeArea(child: _buildBody()),
),
);
}
Widget _buildBody() {
switch (_status) {
case RequestStatus.input:
return _buildInputView();
case RequestStatus.generating:
return _buildGeneratingView();
case RequestStatus.waiting:
return _buildWaitingView();
case RequestStatus.received:
return _buildSuccessView();
case RequestStatus.error:
return _buildErrorView();
}
}
// ─── Input View ───
Widget _buildInputView() {
return Column(
children: [
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.all(AppDimensions.paddingMedium),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildAmountSection(),
const SizedBox(height: AppDimensions.paddingLarge),
_buildDescriptionSection(),
const SizedBox(height: AppDimensions.paddingMedium),
if (_errorMessage != null) _buildErrorMessage(),
],
),
),
),
Padding(
padding: const EdgeInsets.all(AppDimensions.paddingMedium),
child: PrimaryButton(
text: L10n.of(context)!.generateRequest,
onPressed: _isValidAmount ? _generateRequest : null,
),
),
],
);
}
Widget _buildAmountSection() {
final displayAmount =
UnitFormatter.formatRawDigitsForDisplay(_amountValue, _activeUnit);
return Column(
children: [
Text(
displayAmount,
style: TextStyle(
fontFamily: 'Inter',
fontSize: 48,
fontWeight: FontWeight.bold,
color: _isValidAmount || _amountValue.isEmpty
? Colors.white
: AppColors.error,
),
),
const SizedBox(height: 4),
Text(
_unitLabel,
style: TextStyle(
fontFamily: 'Inter',
fontSize: 18,
color: AppColors.textSecondary,
),
),
const SizedBox(height: AppDimensions.paddingMedium),
NumpadWidget(
value: _amountValue,
onChanged: (v) => setState(() {
_errorMessage = null;
_amountValue = v;
}),
),
],
);
}
Widget _buildDescriptionSection() {
final l10n = L10n.of(context)!;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.descriptionOptional,
style: TextStyle(
fontFamily: 'Inter',
fontSize: 16,
fontWeight: FontWeight.w500,
color: AppColors.textSecondary,
),
),
const SizedBox(height: AppDimensions.paddingSmall),
GlassCard(
padding: const EdgeInsets.all(AppDimensions.paddingSmall),
child: TextField(
controller: _descriptionController,
maxLines: 2,
maxLength: 100,
style: const TextStyle(
fontFamily: 'Inter',
fontSize: 16,
color: Colors.white,
),
decoration: InputDecoration(
hintText: l10n.requestDescriptionHint,
hintStyle: TextStyle(
fontFamily: 'Inter',
fontSize: 16,
color: Colors.white.withValues(alpha: 0.3),
),
border: InputBorder.none,
counterStyle: TextStyle(
fontFamily: 'Inter',
fontSize: 12,
color: AppColors.textSecondary.withValues(alpha: 0.5),
),
),
),
),
],
);
}
Widget _buildErrorMessage() {
return Container(
margin: const EdgeInsets.only(bottom: AppDimensions.paddingMedium),
padding: const EdgeInsets.all(AppDimensions.paddingMedium),
decoration: BoxDecoration(
color: AppColors.error.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: AppColors.error.withValues(alpha: 0.3),
width: 1,
),
),
child: Row(
children: [
Icon(LucideIcons.alertCircle, color: AppColors.error, size: 20),
const SizedBox(width: 8),
Expanded(
child: Text(
_errorMessage!,
style: TextStyle(
fontFamily: 'Inter',
fontSize: 14,
color: AppColors.error,
),
),
),
],
),
);
}
// ─── Generating View ───
Widget _buildGeneratingView() {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const CircularProgressIndicator(color: AppColors.primaryAction),
const SizedBox(height: AppDimensions.paddingMedium),
Text(
L10n.of(context)!.generatingRequest,
style: const TextStyle(
fontFamily: 'Inter',
fontSize: 16,
color: AppColors.textSecondary,
),
),
],
),
);
}
// ─── Waiting View (QR + sub-toggle + actions) ───
Widget _buildWaitingView() {
final l10n = L10n.of(context)!;
final formattedAmount = UnitFormatter.formatBalance(_amount, _activeUnit);
return Column(
children: [
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.all(AppDimensions.paddingMedium),
child: Column(
children: [
// Amount header
Text(
'$formattedAmount $_unitLabel',
style: const TextStyle(
fontFamily: 'Inter',
fontSize: 28,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
const SizedBox(height: AppDimensions.paddingLarge),
// QR Code with logo overlay
Center(
child: Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
),
child: Stack(
alignment: Alignment.center,
children: [
QrImageView(
data: _getActiveQrContent(),
version: QrVersions.auto,
size: 260,
backgroundColor: Colors.white,
// TODO: use QrErrorCorrectLevel.M for universal mode once CDK fixes NIP-17 (cashubtc/cdk#1807)
errorCorrectionLevel: QrErrorCorrectLevel.H,
),
// TODO: re-enable Cashu logo overlay once CDK fixes NIP-17 (cashubtc/cdk#1807)
// if (_activeMode == QrMode.cashu)
// Container(
// width: 48, height: 48,
// decoration: BoxDecoration(
// color: Colors.white,
// borderRadius: BorderRadius.circular(10),
// border: Border.all(color: Colors.white, width: 4),
// ),
// child: ClipRRect(
// borderRadius: BorderRadius.circular(6),
// child: Image.asset('assets/img/cashu.png', fit: BoxFit.contain),
// ),
// ),
Container(
width: 48,
height: 48,
decoration: BoxDecoration(
color: AppColors.bitcoinOrange,
borderRadius: BorderRadius.circular(10),
border: Border.all(color: Colors.white, width: 4),
),
child: const Icon(
LucideIcons.zap,
color: Colors.white,
size: 28,
),
),
],
),
),
),
const SizedBox(height: AppDimensions.paddingMedium),
// Sub-toggle: Universal / Cashu / Lightning
_buildModeToggle(),
const SizedBox(height: AppDimensions.paddingMedium),
// Action buttons: Copy, NFC, Share
_buildActionButtons(),
const SizedBox(height: AppDimensions.paddingMedium),
// Status indicator
_buildStatusIndicator(),
],
),
),
),
// Cancel button
Padding(
padding: const EdgeInsets.all(AppDimensions.paddingMedium),
child: GestureDetector(
onTap: () => Navigator.pop(context),
child: Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(vertical: 16),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(16),
),
child: Center(
child: Text(
l10n.cancel,
style: const TextStyle(
fontFamily: 'Inter',
fontSize: 16,
fontWeight: FontWeight.w600,
color: Colors.white,
),
),
),
),
),
),
],
);
}
Widget _buildModeToggle() {
// TODO: re-enable Universal/Cashu tabs once CDK fixes NIP-17 (cashubtc/cdk#1807)
// Hidden while only Lightning is available (single button toggle is a no-op)
return const SizedBox.shrink(
// child: Container(
// padding: const EdgeInsets.all(4),
// decoration: BoxDecoration(
// color: Colors.white.withValues(alpha: 0.1),
// borderRadius: BorderRadius.circular(12),
// ),
// child: Row(
// children: [
// _buildToggleButton(label: L10n.of(context)!.universal, mode: QrMode.universal, enabled: _bolt11 != null),
// _buildToggleButton(label: 'Cashu', mode: QrMode.cashu, enabled: true),
// _buildToggleButton(label: 'Lightning', mode: QrMode.lightning, enabled: _bolt11 != null),
// ],
// ),
// ),
);
}
// TODO: re-enable once CDK fixes NIP-17 (cashubtc/cdk#1807)
// Widget _buildToggleButton({
// required String label,
// required QrMode mode,
// required bool enabled,
// }) {
// final isActive = _activeMode == mode;
// return Expanded(
// child: GestureDetector(
// onTap: enabled
// ? () {
// setState(() => _activeMode = mode);
// _updateNfcPayload();
// }
// : null,
// child: Container(
// padding: const EdgeInsets.symmetric(vertical: 10),
// decoration: BoxDecoration(
// gradient: isActive
// ? const LinearGradient(colors: AppColors.buttonGradient)
// : null,
// borderRadius: BorderRadius.circular(10),
// ),
// child: Center(
// child: Text(
// label,
// style: TextStyle(
// fontFamily: 'Inter',
// fontSize: 13,
// fontWeight: isActive ? FontWeight.w600 : FontWeight.w400,
// color: enabled
// ? (isActive ? Colors.white : AppColors.textSecondary)
// : Colors.white.withValues(alpha: 0.3),
// ),
// ),
// ),
// ),
// ),
// );
// }
Widget _buildActionButtons() {
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_buildActionButton(
icon: LucideIcons.copy,
label: L10n.of(context)!.copy,
onTap: _copyRequest,
),
if (_nfcState == NfcState.enabled) ...[
const SizedBox(width: 16),
_buildActionButton(
icon: _nfcEmulating ? LucideIcons.wifiOff : LucideIcons.wifi,
label: 'NFC',
onTap: _toggleNfc,
active: _nfcEmulating,
),
],
const SizedBox(width: 16),
_buildActionButton(
icon: LucideIcons.share2,
label: L10n.of(context)!.share,
onTap: _shareRequest,
),
],
);
}
Widget _buildActionButton({
required IconData icon,
required String label,
required VoidCallback onTap,
bool active = false,
}) {
return GestureDetector(
onTap: onTap,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
decoration: BoxDecoration(
color: active
? AppColors.primaryAction.withValues(alpha: 0.2)
: Colors.white.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(12),
border: active
? Border.all(color: AppColors.primaryAction.withValues(alpha: 0.5))
: null,
),
child: Row(
children: [
Icon(icon, color: active ? AppColors.primaryAction : AppColors.textSecondary, size: 18),
const SizedBox(width: 6),
Text(
label,
style: TextStyle(
fontFamily: 'Inter',
fontSize: 14,
color: active ? AppColors.primaryAction : AppColors.textSecondary,
),
),
],
),
),
);
}
Widget _buildStatusIndicator() {
return GlassCard(
padding: const EdgeInsets.all(AppDimensions.paddingMedium),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(AppColors.textSecondary),
),
),
const SizedBox(width: 12),
Text(
L10n.of(context)!.waitingForPayment,
style: TextStyle(
fontFamily: 'Inter',
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppColors.textSecondary,
),
),
],
),
);
}
// ─── Success View ───
Widget _buildSuccessView() {
final l10n = L10n.of(context)!;
final formattedAmount =
'+${UnitFormatter.formatBalance(_receivedAmount, _activeUnit)}';
final unitLabel = UnitFormatter.getUnitLabel(_activeUnit);
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
width: 80,
height: 80,
decoration: BoxDecoration(
color: AppColors.success.withValues(alpha: 0.2),
shape: BoxShape.circle,
),
child: const Icon(
LucideIcons.checkCircle2,
color: AppColors.success,
size: 40,
),
),
const SizedBox(height: AppDimensions.paddingMedium),
Text(
'$formattedAmount $unitLabel',
style: const TextStyle(
fontFamily: 'Inter',
fontSize: 32,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
const SizedBox(height: AppDimensions.paddingSmall),
Text(
l10n.requestPaymentReceived,
style: TextStyle(
fontFamily: 'Inter',
fontSize: 16,
color: AppColors.textSecondary,
),
),
const SizedBox(height: AppDimensions.paddingLarge * 2),
PrimaryButton(
text: l10n.backToHome,
onPressed: () =>
Navigator.popUntil(context, (route) => route.isFirst),
),
],
),
);
}
// ─── Error View ───
Widget _buildErrorView() {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
width: 80,
height: 80,
decoration: BoxDecoration(
color: AppColors.error.withValues(alpha: 0.2),
shape: BoxShape.circle,
),
child: const Icon(
LucideIcons.alertCircle,
color: AppColors.error,
size: 40,
),
),
const SizedBox(height: AppDimensions.paddingMedium),
Text(
L10n.of(context)!.error,
style: const TextStyle(
fontFamily: 'Inter',
fontSize: 20,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
const SizedBox(height: AppDimensions.paddingSmall),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 32),
child: Text(
_errorMessage ?? L10n.of(context)!.unknownError,
style: TextStyle(
fontFamily: 'Inter',
fontSize: 14,
color: AppColors.textSecondary.withValues(alpha: 0.7),
),
textAlign: TextAlign.center,
),
),
const SizedBox(height: AppDimensions.paddingLarge),
PrimaryButton(
text: L10n.of(context)!.back,
onPressed: () => setState(() {
_status = RequestStatus.input;
_errorMessage = null;
}),
),
],
),
);
}
// ─── Logic ───
// TODO: re-enable once CDK fixes NIP-17 (cashubtc/cdk#1807)
// static const List<String> _defaultNostrRelays = [
// 'wss://relay.damus.io',
// 'wss://relay.primal.net',
// 'wss://nos.lol',
// ];
Future<void> _generateRequest() async {
final walletProvider = context.read<WalletProvider>();
final wallet = walletProvider.activeWallet;
// Clean up previous request state — await to ensure Rust locks are released
await _nostrSubscription?.cancel();
_nostrSubscription = null;
await _mintSubscription?.cancel();
_mintSubscription = null;
if (_nfcEmulating) {
NfcService.stopEmulating();
_nfcEmulating = false;
}
_paymentHandled = false;
_creqB = null;
_bolt11 = null;
// TODO: re-enable Cashu/Nostr payment request once CDK fixes NIP-17 sender pubkey bug
// See: https://github.com/cashubtc/cdk/issues/1807
// _activeMode = QrMode.lightning;
setState(() => _status = RequestStatus.generating);
if (wallet == null) {
setState(() {
_status = RequestStatus.error;
_errorMessage = 'No active wallet';
});
return;
}
try {
final description = _descriptionController.text.isNotEmpty
? _descriptionController.text
: null;
// TODO: re-enable once CDK fixes NIP-17 (cashubtc/cdk#1807)
// // 1. Create payment request (creqB + Nostr keys)
// final request = await wallet.createPaymentRequest(
// params: CreateRequestParams(
// amount: _amount,
// unit: _activeUnit,
// description: description,
// nostrRelays: _defaultNostrRelays,
// ),
// );
//
// _creqB = request.creqB;
//
// // Persist handle for recovery if app is killed
// await walletProvider.savePendingNostrRequest(
// request.listenerHandle.toPersisted(),
// );
//
// // 2. Start Nostr listener
// _nostrSubscription = wallet
// .waitForNostrPayment(handle: request.listenerHandle)
// .listen(_onNostrEvent);
// Lightning invoice generation via wallet provider
// (walletProvider.mintTokens handles pending invoice persistence and metadata)
final mintStream = await walletProvider.mintTokens(
_amount,
description,
);
if (!mounted) return;
_mintSubscription = mintStream.listen(_onMintEvent);
} catch (e) {
if (!mounted) return;
setState(() {
_status = RequestStatus.error;
_errorMessage = e.toString();
});
}
}
// TODO: re-enable once CDK fixes NIP-17 (cashubtc/cdk#1807)
// void _onNostrEvent(NostrPaymentEvent event) {
// if (!mounted || _paymentHandled) return;
// if (event.state == NostrPaymentState.received) {
// _paymentHandled = true;
// _mintSubscription?.cancel();
// _onPaymentSuccess(event.amount ?? _amount);
// }
// }
void _onMintEvent(MintQuote quote) {
if (!mounted) return;
switch (quote.state) {
case MintQuoteState.unpaid:
if (_bolt11 == null) {
setState(() {
_bolt11 = quote.request;
// TODO: switch to QrMode.universal once CDK fixes NIP-17 (cashubtc/cdk#1807)
// _activeMode = QrMode.lightning;
_status = RequestStatus.waiting;
});
_updateNfcPayload();
}
break;
case MintQuoteState.issued:
if (!_paymentHandled) {
_paymentHandled = true;
_nostrSubscription?.cancel();
_onPaymentSuccess(quote.amount ?? _amount);
}
break;
case MintQuoteState.error:
// No Nostr fallback while CDK NIP-17 bug is open (cashubtc/cdk#1807)
if (!_paymentHandled) {
if (_nfcEmulating) {
NfcService.stopEmulating();
_nfcEmulating = false;
}
setState(() {
_status = RequestStatus.error;
_errorMessage = quote.error ?? L10n.of(context)!.unknownError;
});
}
break;
default:
break;
}
}
Future<void> _onPaymentSuccess(BigInt amount) async {
if (_nfcEmulating) NfcService.stopEmulating();
setState(() {
_status = RequestStatus.received;
_receivedAmount = amount;
});
// Confetti se dispara globalmente desde WalletProvider._saveMintMetadata
// TODO: re-enable once CDK fixes NIP-17 (cashubtc/cdk#1807)
// Only remove pending Nostr request if this screen created one.
// Disabled while Nostr payment requests are disabled to avoid
// deleting a recovery record from a previous session.
// await walletProvider.removePendingNostrRequest();
}
// ─── QR Content ───
String _getActiveQrContent() {
// TODO: re-enable once CDK fixes NIP-17 (cashubtc/cdk#1807)
// case QrMode.universal:
// return buildUnifiedUri(creqB: _creqB!, bolt11: _bolt11);
// case QrMode.cashu:
// return _creqB!.toUpperCase();
return _bolt11?.toUpperCase() ?? '';
}
// ─── Actions ───
Future<void> _copyRequest() async {
final content = _getActiveQrContent();
await Clipboard.setData(ClipboardData(text: content));
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(L10n.of(context)!.copiedToClipboard),
backgroundColor: AppColors.success,
duration: const Duration(seconds: 2),
),
);
}
}
void _toggleNfc() {
if (_nfcEmulating) {
NfcService.stopEmulating();
setState(() => _nfcEmulating = false);
} else {
NfcService.startEmulating(_getActiveQrContent());
setState(() => _nfcEmulating = true);
}
}
void _updateNfcPayload() {
if (_nfcEmulating) {
NfcService.startEmulating(_getActiveQrContent());
}
}
void _shareRequest() async {
final content = _getActiveQrContent();
final formattedAmount =
UnitFormatter.formatBalance(_amount, _activeUnit);
final unitLabel = UnitFormatter.getUnitLabel(_activeUnit);
await SharePlus.instance.share(
ShareParams(
text: '$formattedAmount $unitLabel\n\n$content',
subject: 'Payment Request - $formattedAmount $unitLabel',
),
);
}
}