Files
me.elcaju/lib/screens/5_send/send_screen.dart
T

889 lines
26 KiB
Dart

import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:http/http.dart' as http;
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/utils/keyset_debug.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 '../../core/utils/nostr_utils.dart';
import '../../widgets/scanner/qr_scanner_widget.dart';
import 'share_token_screen.dart';
import 'offline_send_screen.dart';
/// Pantalla para enviar tokens Cashu
class SendScreen extends StatefulWidget {
const SendScreen({super.key});
@override
State<SendScreen> createState() => _SendScreenState();
}
class _SendScreenState extends State<SendScreen> {
final TextEditingController _memoController = TextEditingController();
final TextEditingController _pubkeyController = TextEditingController();
String _amountValue = '';
bool _isProcessing = false;
String? _errorMessage;
BigInt _availableBalance = BigInt.zero;
late String _activeUnit;
// P2PK
bool _useP2PK = false;
String? _pubkeyError;
@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;
});
}
}
@override
void dispose() {
_memoController.dispose();
_pubkeyController.dispose();
super.dispose();
}
/// Obtiene la etiqueta de la unidad para display
String get _unitLabel => UnitFormatter.getUnitLabel(_activeUnit);
/// Parsea los dígitos crudos a BigInt (ya son centavos para USD/EUR)
BigInt get _amount => UnitFormatter.parseRawDigits(_amountValue, _activeUnit);
bool get _isValidAmount => _amount > BigInt.zero && _amount <= _availableBalance;
@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)!.sendCashu,
style: const TextStyle(
fontFamily: 'Inter',
fontWeight: FontWeight.w600,
color: Colors.white,
),
),
actions: [
// Botón para modo offline (selección manual de proofs)
IconButton(
icon: const Icon(LucideIcons.coins, color: AppColors.primaryAction),
tooltip: L10n.of(context)!.selectNotesManually,
onPressed: _goToOfflineMode,
),
],
),
body: SafeArea(
child: Column(
children: [
// Contenido scrolleable
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.all(AppDimensions.paddingMedium),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Monto a enviar
_buildAmountSection(),
const SizedBox(height: AppDimensions.paddingLarge),
// Memo opcional
_buildMemoSection(),
const SizedBox(height: AppDimensions.paddingMedium),
// P2PK (bloquear a clave pública)
_buildP2PKSection(),
const SizedBox(height: AppDimensions.paddingMedium),
// Mensaje de error (si hay)
if (_errorMessage != null) _buildErrorMessage(),
],
),
),
),
// Botón crear token (fijo abajo)
Padding(
padding: const EdgeInsets.all(AppDimensions.paddingMedium),
child: _buildCreateButton(),
),
],
),
),
),
);
}
Widget _buildAmountSection() {
return Column(
children: [
// Display del monto
_buildAmountDisplay(),
const SizedBox(height: AppDimensions.paddingSmall),
// Balance disponible
_buildBalanceRow(),
const SizedBox(height: AppDimensions.paddingMedium),
// Teclado numérico
NumpadWidget(
value: _amountValue,
showMaxButton: true,
onMaxPressed: _setMaxAmount,
onChanged: (newValue) {
setState(() {
_errorMessage = null;
_amountValue = newValue;
});
},
),
],
);
}
Widget _buildAmountDisplay() {
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,
),
),
],
);
}
Widget _buildBalanceRow() {
final l10n = L10n.of(context)!;
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
'${l10n.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 _buildMemoSection() {
final l10n = L10n.of(context)!;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.memoOptional,
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: _memoController,
maxLines: 2,
maxLength: 100,
style: const TextStyle(
fontFamily: 'Inter',
fontSize: 16,
color: Colors.white,
),
decoration: InputDecoration(
hintText: l10n.memoPlaceholder,
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 _buildP2PKSection() {
final l10n = L10n.of(context)!;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
GlassCard(
padding: const EdgeInsets.symmetric(
horizontal: AppDimensions.paddingMedium,
vertical: AppDimensions.paddingSmall,
),
child: Row(
children: [
Icon(
LucideIcons.lock,
color: _useP2PK ? AppColors.primaryAction : AppColors.textSecondary,
size: 20,
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.p2pkLockToKey,
style: const TextStyle(
fontFamily: 'Inter',
fontSize: 14,
fontWeight: FontWeight.w500,
color: Colors.white,
),
),
Text(
l10n.p2pkLockDescription,
style: TextStyle(
fontFamily: 'Inter',
fontSize: 12,
color: AppColors.textSecondary.withValues(alpha: 0.7),
),
),
],
),
),
Switch(
value: _useP2PK,
onChanged: (value) => setState(() => _useP2PK = value),
activeColor: AppColors.primaryAction,
),
],
),
),
// Campo de pubkey cuando P2PK está activo
if (_useP2PK) ...[
const SizedBox(height: AppDimensions.paddingSmall),
GlassCard(
padding: const EdgeInsets.all(AppDimensions.paddingSmall),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextField(
controller: _pubkeyController,
onChanged: _validatePubkey,
maxLines: 1,
style: const TextStyle(
fontFamily: 'Inter',
fontSize: 14,
color: Colors.white,
),
decoration: InputDecoration(
hintText: l10n.p2pkReceiverPubkey,
hintStyle: TextStyle(
fontFamily: 'Inter',
fontSize: 14,
color: Colors.white.withValues(alpha: 0.3),
),
border: InputBorder.none,
suffixIcon: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: const Icon(LucideIcons.clipboard, size: 20),
color: AppColors.textSecondary,
onPressed: _pastePubkey,
),
IconButton(
icon: const Icon(LucideIcons.scanLine, size: 20),
color: AppColors.textSecondary,
tooltip: l10n.scanQrCode,
onPressed: _scanPubkey,
),
],
),
errorText: _pubkeyError,
errorStyle: TextStyle(
fontFamily: 'Inter',
fontSize: 12,
color: AppColors.error,
),
),
),
],
),
),
],
],
);
}
void _validatePubkey(String value) {
if (value.isEmpty) {
setState(() => _pubkeyError = null);
return;
}
final isValid = NostrUtils.isValidP2PKPubkey(value);
setState(() {
_pubkeyError = isValid ? null : L10n.of(context)!.p2pkInvalidPubkey;
});
}
Future<void> _pastePubkey() async {
final clipboardData = await Clipboard.getData(Clipboard.kTextPlain);
if (!mounted) return;
final text = clipboardData?.text?.trim();
if (text != null && text.isNotEmpty) {
_pubkeyController.text = text;
_validatePubkey(text);
}
}
Future<void> _scanPubkey() async {
final result = await Navigator.push<String>(
context,
MaterialPageRoute(
builder: (context) => Scaffold(
backgroundColor: Colors.black,
appBar: AppBar(
backgroundColor: Colors.transparent,
elevation: 0,
leading: IconButton(
icon: const Icon(LucideIcons.x, color: Colors.white),
onPressed: () => Navigator.pop(context),
),
title: Text(
L10n.of(context)!.scanQrCode,
style: const TextStyle(
fontFamily: 'Inter',
fontWeight: FontWeight.w600,
color: Colors.white,
),
),
centerTitle: true,
),
body: QrScannerWidget(
onDetect: (data) {
final trimmed = data.trim();
if (NostrUtils.isValidP2PKPubkey(trimmed)) {
Navigator.pop(context, trimmed);
} else {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(L10n.of(context)!.p2pkInvalidPubkey),
duration: const Duration(seconds: 2),
),
);
}
},
onError: (error) {
debugPrint('[P2PK Scan] $error');
Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(L10n.of(context)!.cameraPermissionDenied),
backgroundColor: AppColors.error,
),
);
},
),
),
),
);
if (!mounted || result == null) return;
_pubkeyController.text = result;
_validatePubkey(result);
}
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,
),
),
),
],
),
);
}
Widget _buildCreateButton() {
final l10n = L10n.of(context)!;
// Si P2PK está habilitado, también debe haber una pubkey válida
final isP2PKValid = !_useP2PK ||
(_pubkeyController.text.isNotEmpty && _pubkeyError == null);
final canCreate = _isValidAmount && !_isProcessing && isP2PKValid;
return PrimaryButton(
text: _isProcessing ? l10n.creatingToken : l10n.createToken,
onPressed: canCreate ? _showConfirmation : null,
);
}
void _setMaxAmount() {
setState(() {
// El balance ya está en la unidad base (centavos para USD/EUR, sats para SAT)
_amountValue = _availableBalance.toString();
_errorMessage = null;
});
}
/// Navegar al modo offline para seleccionar proofs manualmente.
void _goToOfflineMode() {
final walletProvider = context.read<WalletProvider>();
final mintUrl = walletProvider.activeMintUrl;
if (mintUrl == null) {
setState(() {
_errorMessage = L10n.of(context)!.noActiveMint;
});
return;
}
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => OfflineSendScreen(
mintUrl: mintUrl,
unit: _activeUnit,
),
),
);
}
void _showConfirmation() async {
// Verificar conectividad antes de mostrar confirmación
final walletProvider = context.read<WalletProvider>();
final mintUrl = walletProvider.activeMintUrl;
if (mintUrl == null) {
setState(() {
_errorMessage = L10n.of(context)!.noActiveMint;
});
return;
}
setState(() {
_isProcessing = true;
});
final isOnline = await _checkConnectivity(mintUrl);
if (!mounted) return;
setState(() {
_isProcessing = false;
});
if (!isOnline) {
// P2PK requiere conexión al mint
if (_useP2PK) {
setState(() {
_errorMessage = L10n.of(context)!.p2pkRequiresConnection;
});
return;
}
// Offline: verificar monto mínimo con ppk del keyset local (fallback 100)
var ppk = await KeysetDebug.getInputFeePpk(mintUrl, _activeUnit);
if (ppk < 0) ppk = 100;
final minReceiveFee = ppk > 0 ? BigInt.from((ppk + 999) ~/ 1000) : BigInt.zero;
if (!mounted) return;
if (_amount <= minReceiveFee) {
setState(() {
_errorMessage = L10n.of(context)!.feeExceedsAmount;
});
return;
}
// Offline: ir directo a selección de monedas
_goToOfflineModeWithMessage();
return;
}
// Online: verificar monto mínimo con ppk real del mint
final minReceiveFee = await _getMinReceiveFee(mintUrl);
if (!mounted) return;
if (_amount <= minReceiveFee) {
setState(() {
_errorMessage = L10n.of(context)!.feeExceedsAmount;
});
return;
}
// Online: mostrar modal de confirmación normal
showModalBottomSheet(
context: context,
backgroundColor: Colors.transparent,
isScrollControlled: true,
builder: (context) => _ConfirmationModal(
amount: _amount,
unit: _activeUnit,
memo: _memoController.text.isNotEmpty ? _memoController.text : null,
onConfirm: () {
Navigator.pop(context);
_createToken();
},
onCancel: () => Navigator.pop(context),
),
);
}
/// Calcula el fee mínimo que el receptor pagará al reclamar el token.
/// Consulta /v1/keysets del mint para obtener el ppk real.
/// Fallback: ppk=100 (estándar) → fee mínimo = 1 sat.
Future<BigInt> _getMinReceiveFee(String mintUrl) async {
try {
final response = await http.get(
Uri.parse('$mintUrl/v1/keysets'),
).timeout(const Duration(seconds: 3));
if (response.statusCode == 200) {
final data = json.decode(response.body) as Map<String, dynamic>;
final keysets = data['keysets'] as List<dynamic>? ?? [];
for (final ks in keysets) {
if (ks['active'] == true && ks['unit'] == _activeUnit) {
final ppk = (ks['input_fee_ppk'] as num?)?.toInt() ?? 0;
if (ppk > 0) {
return BigInt.from((ppk + 999) ~/ 1000); // ceil(ppk / 1000)
}
return BigInt.zero;
}
}
}
} catch (_) {}
// Fallback: ppk=100 estándar
return BigInt.one;
}
/// Verifica conectividad haciendo petición HTTP real al mint.
Future<bool> _checkConnectivity(String mintUrl) async {
try {
final response = await http.get(
Uri.parse('$mintUrl/v1/info'),
).timeout(const Duration(seconds: 3));
return response.statusCode == 200;
} catch (_) {
return false;
}
}
Future<void> _createToken() async {
final l10n = L10n.of(context)!;
setState(() {
_isProcessing = true;
_errorMessage = null;
});
try {
final walletProvider = context.read<WalletProvider>();
// Crear token real con cdk-flutter
final memo = _memoController.text.isNotEmpty ? _memoController.text : null;
final amount = _amount;
String token;
// P2PK: bloquear a clave pública si está habilitado
if (_useP2PK && _pubkeyController.text.isNotEmpty) {
// Usar normalizeToCompressedHex para obtener formato SEC1 (66 chars)
final pubkeyHex = NostrUtils.normalizeToCompressedHex(_pubkeyController.text);
if (pubkeyHex == null) {
throw Exception(l10n.p2pkInvalidPubkey);
}
token = await walletProvider.sendTokensP2pk(amount, pubkeyHex, memo);
} else {
debugPrint('[SendScreen] Sending normal token, amount: $amount');
token = await walletProvider.sendTokens(amount, memo);
}
if (mounted) {
Navigator.pushReplacement(
context,
MaterialPageRoute(
builder: (context) => ShareTokenScreen(
token: token,
amount: amount,
unit: _activeUnit,
memo: memo,
),
),
);
}
} catch (e, stackTrace) {
debugPrint('[SendScreen] ERROR creating token: $e');
debugPrint('[SendScreen] Stack trace: $stackTrace');
final errorStr = e.toString().toLowerCase();
// Detectar errores de red y redirigir a modo offline
if (_isNetworkError(errorStr)) {
if (mounted) {
setState(() {
_isProcessing = false;
});
_goToOfflineModeWithMessage();
}
return;
}
if (mounted) {
setState(() {
if (errorStr.contains('insufficient') || errorStr.contains('not enough')) {
_errorMessage = l10n.insufficientBalance;
} else {
_errorMessage = l10n.tokenCreationError(e.toString());
}
});
}
} finally {
if (mounted) {
setState(() {
_isProcessing = false;
});
}
}
}
/// Detecta si el error es de conexión/red.
bool _isNetworkError(String errorStr) {
return errorStr.contains('transport error') ||
errorStr.contains('network') ||
errorStr.contains('connection') ||
errorStr.contains('socket') ||
errorStr.contains('timeout') ||
errorStr.contains('unreachable') ||
errorStr.contains('no route') ||
errorStr.contains('error sending request');
}
/// Navegar al modo offline mostrando mensaje informativo.
void _goToOfflineModeWithMessage() {
final walletProvider = context.read<WalletProvider>();
final mintUrl = walletProvider.activeMintUrl;
if (mintUrl == null) {
setState(() {
_errorMessage = L10n.of(context)!.noActiveMint;
});
return;
}
// Mostrar snackbar informativo
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(L10n.of(context)!.offlineModeMessage),
backgroundColor: AppColors.primaryAction,
duration: const Duration(seconds: 2),
),
);
// Navegar a modo offline
Navigator.pushReplacement(
context,
MaterialPageRoute(
builder: (context) => OfflineSendScreen(
mintUrl: mintUrl,
unit: _activeUnit,
),
),
);
}
}
/// Modal de confirmacion
class _ConfirmationModal extends StatelessWidget {
final BigInt amount;
final String unit;
final String? memo;
final VoidCallback onConfirm;
final VoidCallback onCancel;
const _ConfirmationModal({
required this.amount,
required this.unit,
this.memo,
required this.onConfirm,
required this.onCancel,
});
@override
Widget build(BuildContext context) {
final l10n = L10n.of(context)!;
return 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.send,
color: AppColors.primaryAction,
size: 32,
),
),
const SizedBox(height: AppDimensions.paddingMedium),
// Titulo
Text(
l10n.confirmSend,
style: const TextStyle(
fontFamily: 'Inter',
fontSize: 20,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
const SizedBox(height: AppDimensions.paddingSmall),
// Monto
Text(
'${UnitFormatter.formatBalance(amount, unit)} ${UnitFormatter.getUnitLabel(unit)}',
style: const TextStyle(
fontFamily: 'Inter',
fontSize: 32,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
// Memo
if (memo != null && memo!.isNotEmpty) ...[
const SizedBox(height: AppDimensions.paddingSmall),
Text(
'"$memo"',
style: TextStyle(
fontFamily: 'Inter',
fontSize: 14,
fontStyle: FontStyle.italic,
color: AppColors.textSecondary.withValues(alpha: 0.7),
),
),
],
const SizedBox(height: AppDimensions.paddingLarge),
// Botones
Row(
children: [
Expanded(
child: SecondaryButton(
text: l10n.cancel,
onPressed: onCancel,
height: 52,
),
),
const SizedBox(width: AppDimensions.paddingMedium),
Expanded(
child: PrimaryButton(
text: l10n.confirm,
onPressed: onConfirm,
height: 52,
),
),
],
),
],
),
);
}
}