Files
me.elcaju/lib/screens/7_melt/amount_screen.dart
T

619 lines
18 KiB
Dart

import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:lucide_icons/lucide_icons.dart';
import 'package:elcaju/l10n/app_localizations.dart';
import '../../core/constants/colors.dart';
import '../../core/constants/dimensions.dart';
import '../../core/utils/formatters.dart';
import '../../core/services/lnurl_service.dart';
import '../../widgets/common/gradient_background.dart';
import '../../widgets/common/glass_card.dart';
import '../../widgets/common/primary_button.dart';
import '../../widgets/common/secondary_button.dart';
import '../../widgets/common/bottom_sheet_container.dart';
import '../../widgets/common/numpad_widget.dart';
import '../../providers/wallet_provider.dart';
import '../../providers/price_provider.dart';
/// Pantalla para elegir monto en pagos LNURL/Lightning Address
class AmountScreen extends StatefulWidget {
/// Destino (LNURL o Lightning Address)
final String destination;
/// Tipo de destino
final LnInputType destinationType;
/// Parámetros LNURL ya resueltos
final LnurlPayParams params;
const AmountScreen({
super.key,
required this.destination,
required this.destinationType,
required this.params,
});
@override
State<AmountScreen> createState() => _AmountScreenState();
}
class _AmountScreenState extends State<AmountScreen> {
String _amount = '';
bool _isProcessing = false;
String? _errorMessage;
BigInt _availableBalance = BigInt.zero;
late String _activeUnit;
// Equivalente en la otra unidad (para mostrar)
String? _equivalentDisplay;
// Contador para evitar race conditions en _updateEquivalent
int _equivalentGeneration = 0;
@override
void initState() {
super.initState();
_activeUnit = context.read<WalletProvider>().activeUnit;
_loadBalance();
}
Future<void> _loadBalance() async {
final walletProvider = context.read<WalletProvider>();
final balance = await walletProvider.getBalance();
if (mounted) {
setState(() => _availableBalance = balance);
}
}
/// Obtiene el monto ingresado en la unidad base (centavos para USD, sats para sat)
BigInt get _amountInBaseUnit {
return UnitFormatter.parseRawDigits(_amount, _activeUnit);
}
/// Verifica si la unidad activa es fiat (necesita conversión)
bool get _isFiatUnit {
return _activeUnit.toLowerCase() == 'usd' || _activeUnit.toLowerCase() == 'eur';
}
/// Convierte el monto a sats para LNURL
Future<BigInt> _getAmountInSats() async {
if (!_isFiatUnit) {
// Ya está en sats
return _amountInBaseUnit;
}
// Convertir fiat a sats usando PriceProvider
final priceProvider = context.read<PriceProvider>();
return await priceProvider.fiatCentsToSats(_amountInBaseUnit, _activeUnit);
}
/// Actualiza el equivalente mostrado
Future<void> _updateEquivalent() async {
final gen = ++_equivalentGeneration;
if (_amount.isEmpty || _amountInBaseUnit == BigInt.zero) {
setState(() => _equivalentDisplay = null);
return;
}
try {
final priceProvider = context.read<PriceProvider>();
if (_isFiatUnit) {
// Mostrar equivalente en sats
final sats = await priceProvider.fiatCentsToSats(_amountInBaseUnit, _activeUnit);
if (gen != _equivalentGeneration) return; // Descartar si hay llamada más reciente
setState(() {
_equivalentDisplay = '≈ ${UnitFormatter.formatBalance(sats, 'sat')} sat';
});
} else {
// Mostrar equivalente en USD si hay precio
if (priceProvider.hasPrice) {
final usdCents = await priceProvider.satsToFiatCents(_amountInBaseUnit, 'usd');
if (gen != _equivalentGeneration) return; // Descartar si hay llamada más reciente
setState(() {
_equivalentDisplay = '≈ ${UnitFormatter.formatBalance(usdCents, 'usd')} USD';
});
}
}
} catch (_) {
if (gen != _equivalentGeneration) return;
setState(() => _equivalentDisplay = null);
}
}
bool get _isAmountValid => _amountInBaseUnit > BigInt.zero;
bool get _canPay {
return _isAmountValid && !_isProcessing && _amountInBaseUnit <= _availableBalance;
}
String get _destinationLabel {
return widget.destinationType == LnInputType.lightningAddress
? 'Lightning Address'
: 'LNURL';
}
@override
Widget build(BuildContext context) {
final l10n = L10n.of(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.send,
style: const TextStyle(
fontFamily: 'Inter',
fontWeight: FontWeight.w600,
color: Colors.white,
),
),
),
body: SafeArea(
child: Column(
children: [
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.all(AppDimensions.paddingMedium),
child: Column(
children: [
// Destino
_buildDestinationCard(),
const SizedBox(height: AppDimensions.paddingLarge),
// Display del monto
_buildAmountDisplay(),
const SizedBox(height: AppDimensions.paddingSmall),
// Rango permitido (siempre en sats porque es LNURL)
_buildRangeInfo(),
const SizedBox(height: AppDimensions.paddingLarge),
// Teclado numérico
_buildNumpad(),
// Error message
if (_errorMessage != null) ...[
const SizedBox(height: AppDimensions.paddingMedium),
_buildErrorMessage(),
],
],
),
),
),
// Balance y botón pagar
Padding(
padding: const EdgeInsets.all(AppDimensions.paddingMedium),
child: Column(
children: [
_buildBalanceInfo(),
const SizedBox(height: AppDimensions.paddingMedium),
_buildPayButton(),
],
),
),
],
),
),
),
);
}
Widget _buildDestinationCard() {
return GlassCard(
padding: const EdgeInsets.all(AppDimensions.paddingMedium),
child: Row(
children: [
Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: AppColors.primaryAction.withValues(alpha: 0.2),
shape: BoxShape.circle,
),
child: Icon(
widget.destinationType == LnInputType.lightningAddress
? LucideIcons.atSign
: LucideIcons.link,
color: AppColors.primaryAction,
size: 20,
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
_destinationLabel,
style: TextStyle(
fontFamily: 'Inter',
fontSize: 12,
color: AppColors.textSecondary.withValues(alpha: 0.7),
),
),
const SizedBox(height: 2),
Text(
'Payment to ${widget.destination}',
style: const TextStyle(
fontFamily: 'Inter',
fontSize: 14,
fontWeight: FontWeight.w500,
color: Colors.white,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
],
),
);
}
Widget _buildAmountDisplay() {
final displayAmount = UnitFormatter.formatRawDigitsForDisplay(_amount, _activeUnit);
final unitLabel = UnitFormatter.getUnitLabel(_activeUnit);
return Column(
children: [
Text(
displayAmount,
style: TextStyle(
fontFamily: 'Inter',
fontSize: 56,
fontWeight: FontWeight.bold,
color: _isAmountValid || _amount.isEmpty
? Colors.white
: AppColors.error,
),
),
const SizedBox(height: 4),
Text(
unitLabel,
style: const TextStyle(
fontFamily: 'Inter',
fontSize: 18,
color: AppColors.textSecondary,
),
),
// Mostrar equivalente en la otra unidad
if (_equivalentDisplay != null) ...[
const SizedBox(height: 4),
Text(
_equivalentDisplay!,
style: TextStyle(
fontFamily: 'Inter',
fontSize: 14,
color: AppColors.textSecondary.withValues(alpha: 0.7),
),
),
],
],
);
}
Widget _buildRangeInfo() {
// LNURL min/max siempre en sats
final min = widget.params.minSats;
final max = widget.params.maxSats;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.05),
borderRadius: BorderRadius.circular(20),
),
child: Text(
'Min: $min • Max: $max sat',
style: TextStyle(
fontFamily: 'Inter',
fontSize: 13,
color: AppColors.textSecondary.withValues(alpha: 0.7),
),
),
);
}
Widget _buildNumpad() {
return NumpadWidget(
value: _amount,
onChanged: (newValue) {
setState(() {
_errorMessage = null;
_amount = newValue;
});
// Actualizar equivalente de forma asíncrona
_updateEquivalent();
},
);
}
Widget _buildBalanceInfo() {
final unitLabel = UnitFormatter.getUnitLabel(_activeUnit);
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
'${L10n.of(context)!.available} ',
style: TextStyle(
fontFamily: 'Inter',
fontSize: 14,
color: AppColors.textSecondary.withValues(alpha: 0.7),
),
),
Text(
'${UnitFormatter.formatBalance(_availableBalance, _activeUnit)} $unitLabel',
style: const TextStyle(
fontFamily: 'Inter',
fontSize: 14,
fontWeight: FontWeight.w600,
color: AppColors.primaryAction,
),
),
],
);
}
Widget _buildPayButton() {
final l10n = L10n.of(context)!;
return PrimaryButton(
text: _isProcessing ? l10n.paying : l10n.payInvoice,
onPressed: _canPay ? _processPayment : null,
);
}
Widget _buildErrorMessage() {
return Container(
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),
),
),
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,
),
),
),
],
),
);
}
Future<void> _processPayment() async {
if (!_canPay) return;
final walletProvider = context.read<WalletProvider>();
setState(() {
_isProcessing = true;
_errorMessage = null;
});
try {
// 1. Convertir monto a sats para LNURL
final amountSats = await _getAmountInSats();
// Validar contra límites LNURL
if (!widget.params.isAmountValid(amountSats)) {
setState(() {
_isProcessing = false;
_errorMessage = L10n.of(context)!.amountOutOfRange;
});
return;
}
// 2. Obtener invoice desde LNURL callback
final invoiceResult = await LnurlService.fetchInvoice(
widget.params.callback,
amountSats,
);
if (!mounted) return;
// 3. Obtener quote del mint (en la unidad del mint)
final quote = await walletProvider.getMeltQuote(invoiceResult.invoice);
if (!mounted) return;
final total = quote.amount + quote.feeReserve;
// 4a. Verificar que el fee no supere el monto (operación inviable)
if (quote.amount <= quote.feeReserve) {
setState(() {
_isProcessing = false;
_errorMessage = L10n.of(context)!.feeExceedsAmount;
});
return;
}
// 4b. Verificar balance suficiente
if (total > _availableBalance) {
setState(() {
_isProcessing = false;
_errorMessage = L10n.of(context)!.insufficientBalance;
});
return;
}
// 5. Mostrar confirmación (montos del mint, en activeUnit)
final confirmed = await _showConfirmation(quote.amount, quote.feeReserve, total);
if (!confirmed) {
if (mounted) setState(() => _isProcessing = false);
return;
}
if (!mounted) return;
// 6. Ejecutar pago
final totalPaid = await walletProvider.melt(quote);
if (!mounted) return;
// 7. Mostrar éxito y volver
final l10n = L10n.of(context)!;
final unitLabel = UnitFormatter.getUnitLabel(_activeUnit);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(l10n.sent('-${UnitFormatter.formatBalance(totalPaid, _activeUnit)}', unitLabel)),
backgroundColor: AppColors.success,
duration: const Duration(seconds: 3),
),
);
// Volver a home (pop dos veces: AmountScreen y MeltScreen)
Navigator.pop(context);
Navigator.pop(context);
} catch (e) {
if (mounted) {
setState(() {
_isProcessing = false;
_errorMessage = _parseError(e.toString());
});
}
}
}
String _parseError(String error) {
final lower = error.toLowerCase();
final l10n = L10n.of(context)!;
if (lower.contains('insufficient') || lower.contains('not enough')) {
return l10n.insufficientBalance;
} else if (lower.contains('expired')) {
return l10n.invoiceExpired;
} else if (lower.contains('min') || lower.contains('max')) {
return l10n.amountOutOfRange;
} else if (lower.contains('precio') || lower.contains('price')) {
return 'No se pudo obtener el precio de BTC';
}
return error.replaceFirst('Exception: ', '');
}
Future<bool> _showConfirmation(BigInt amount, BigInt fee, BigInt total) async {
final unitLabel = UnitFormatter.getUnitLabel(_activeUnit);
final l10n = L10n.of(context)!;
final result = await showModalBottomSheet<bool>(
context: context,
backgroundColor: Colors.transparent,
builder: (context) => BottomSheetContainer(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const BottomSheetHandle(),
// Icono
Container(
width: 64,
height: 64,
decoration: BoxDecoration(
color: AppColors.primaryAction.withValues(alpha: 0.2),
shape: BoxShape.circle,
),
child: const Icon(
LucideIcons.zap,
color: AppColors.primaryAction,
size: 32,
),
),
const SizedBox(height: AppDimensions.paddingMedium),
// Título
Text(
l10n.confirmPayment,
style: const TextStyle(
fontFamily: 'Inter',
fontSize: 20,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
const SizedBox(height: AppDimensions.paddingSmall),
// Monto (en la unidad del mint)
Text(
'${UnitFormatter.formatBalance(amount, _activeUnit)} $unitLabel',
style: const TextStyle(
fontFamily: 'Inter',
fontSize: 28,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
Text(
'+ ~${UnitFormatter.formatBalance(fee, _activeUnit)} $unitLabel fee',
style: TextStyle(
fontFamily: 'Inter',
fontSize: 14,
color: AppColors.textSecondary.withValues(alpha: 0.7),
),
),
const SizedBox(height: AppDimensions.paddingSmall),
Text(
'Total: ${UnitFormatter.formatBalance(total, _activeUnit)} $unitLabel',
style: const TextStyle(
fontFamily: 'Inter',
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppColors.primaryAction,
),
),
const SizedBox(height: AppDimensions.paddingLarge),
// Botones
Row(
children: [
Expanded(
child: SecondaryButton(
text: l10n.cancel,
onPressed: () => Navigator.pop(context, false),
height: 52,
),
),
const SizedBox(width: AppDimensions.paddingMedium),
Expanded(
child: PrimaryButton(
text: l10n.pay,
onPressed: () => Navigator.pop(context, true),
height: 52,
),
),
],
),
],
),
),
);
return result ?? false;
}
}