Files
me.elcaju/lib/screens/4_receive/receive_screen.dart
T

1218 lines
38 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter/services.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/utils/incoming_data_parser.dart' hide TokenInfo;
import '../../core/utils/nostr_utils.dart';
import '../../core/utils/peanut_codec.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 '../../providers/wallet_provider.dart';
import '../../providers/p2pk_provider.dart';
import '../10_scanner/scan_screen.dart';
/// Pantalla para recibir tokens Cashu
class ReceiveScreen extends StatefulWidget {
/// Token inicial (pre-cargado desde QR scanner o deep link)
final String? initialToken;
const ReceiveScreen({super.key, this.initialToken});
@override
State<ReceiveScreen> createState() => _ReceiveScreenState();
}
class _ReceiveScreenState extends State<ReceiveScreen> {
final TextEditingController _tokenController = TextEditingController();
// Estado del token
bool _isValidToken = false;
bool _isProcessing = false;
bool _showSuccess = false;
BigInt _receivedAmount = BigInt.zero;
String? _receivedUnit; // Se asigna al reclamar desde walletProvider.activeUnit
TokenInfo? _tokenInfo;
String? _errorMessage;
// Estado P2PK
bool _isP2PKLocked = false;
bool _isLockedToUs = false;
String? _lockedToPubkeyHex;
String? _matchingKeyLabel; // Label de nuestra clave si coincide
final TextEditingController _manualKeyController = TextEditingController();
bool _showManualKey = false; // Toggle para mostrar/ocultar nsec
String? _manualKeyError;
// NFC
NfcState _nfcState = NfcState.unsupported;
bool _nfcReading = false;
@override
void initState() {
super.initState();
_checkNfc();
// Pre-cargar token inicial si existe
if (widget.initialToken != null && widget.initialToken!.isNotEmpty) {
WidgetsBinding.instance.addPostFrameCallback((_) {
_tokenController.text = widget.initialToken!;
_onTokenChanged(widget.initialToken!);
});
}
}
@override
void dispose() {
if (_nfcReading) NfcService.stopRead();
_tokenController.dispose();
_manualKeyController.dispose();
super.dispose();
}
Future<void> _checkNfc() async {
final state = await NfcService.checkState();
if (mounted) {
setState(() => _nfcState = state);
}
}
@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)!.receiveCashu,
style: const TextStyle(
fontFamily: 'Inter',
fontWeight: FontWeight.w600,
color: Colors.white,
),
),
),
body: SafeArea(
child: _showSuccess ? _buildSuccessView() : _buildReceiveForm(),
),
),
);
}
Widget _buildReceiveForm() {
final l10n = L10n.of(context)!;
return Column(
children: [
// Contenido scrolleable
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.all(AppDimensions.paddingMedium),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Instrucciones
Text(
l10n.pasteTheCashuToken,
style: TextStyle(
fontFamily: 'Inter',
fontSize: 16,
fontWeight: FontWeight.w500,
color: AppColors.textSecondary,
),
),
const SizedBox(height: AppDimensions.paddingSmall),
// Campo de texto para el token
_buildTokenInput(),
const SizedBox(height: AppDimensions.paddingMedium),
// Botones pegar y escanear
_buildActionButtons(),
const SizedBox(height: AppDimensions.paddingLarge),
// Preview del token (si es válido)
if (_isValidToken && _tokenInfo != null) _buildTokenPreview(),
// Indicador P2PK (si el token está bloqueado)
if (_isValidToken && _isP2PKLocked) ...[
const SizedBox(height: AppDimensions.paddingMedium),
_buildP2PKIndicator(),
],
// Mensaje de error (si hay)
if (_errorMessage != null) _buildErrorMessage(),
],
),
),
),
// Botón único "Recibir" (fijo abajo)
Padding(
padding: const EdgeInsets.all(AppDimensions.paddingMedium),
child: _buildReceiveButton(),
),
],
);
}
Widget _buildSuccessView() {
return Padding(
padding: const EdgeInsets.all(AppDimensions.paddingMedium),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Spacer(),
// Icono de éxito
Container(
width: 100,
height: 100,
decoration: BoxDecoration(
color: AppColors.success.withValues(alpha: 0.2),
shape: BoxShape.circle,
),
child: const Icon(
LucideIcons.checkCircle2,
color: AppColors.success,
size: 50,
),
),
const SizedBox(height: AppDimensions.paddingLarge),
// Monto recibido
Builder(
builder: (context) {
final unit = _receivedUnit ?? context.read<WalletProvider>().activeUnit;
return Text(
'+${UnitFormatter.formatBalance(_receivedAmount, unit)} ${UnitFormatter.getUnitLabel(unit)}',
style: const TextStyle(
fontFamily: 'Inter',
fontSize: 36,
fontWeight: FontWeight.bold,
color: Colors.white,
),
);
},
),
const SizedBox(height: AppDimensions.paddingSmall),
Text(
L10n.of(context)!.tokensReceived,
style: TextStyle(
fontFamily: 'Inter',
fontSize: 18,
color: AppColors.textSecondary,
),
),
const Spacer(),
// Botón volver (fijo abajo)
PrimaryButton(
text: L10n.of(context)!.backToHome,
onPressed: () => Navigator.pop(context),
),
],
),
);
}
Widget _buildTokenInput() {
return GlassCard(
padding: const EdgeInsets.all(AppDimensions.paddingSmall),
child: TextField(
controller: _tokenController,
maxLines: 5,
style: const TextStyle(
fontFamily: 'Inter',
fontSize: 14,
color: Colors.white,
),
decoration: InputDecoration(
hintText: 'cashuA...',
hintStyle: TextStyle(
fontFamily: 'Inter',
fontSize: 14,
color: Colors.white.withValues(alpha: 0.3),
),
border: InputBorder.none,
contentPadding: const EdgeInsets.all(AppDimensions.paddingSmall),
),
onChanged: _onTokenChanged,
),
);
}
Widget _buildActionButtons() {
return Row(
children: [
// Botón pegar (expandido)
Expanded(child: _buildPasteButton()),
const SizedBox(width: 12),
// Botón NFC (siempre visible)
_buildNfcButton(),
const SizedBox(width: 12),
// Botón escanear QR
_buildScanButton(),
],
);
}
Widget _buildPasteButton() {
return GestureDetector(
onTap: _pasteFromClipboard,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: AppDimensions.paddingMedium,
vertical: AppDimensions.paddingSmall + 4,
),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: Colors.white.withValues(alpha: 0.2),
width: 1,
),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
LucideIcons.clipboard,
color: AppColors.textSecondary,
size: 20,
),
const SizedBox(width: 8),
Text(
L10n.of(context)!.pasteFromClipboard,
style: TextStyle(
fontFamily: 'Inter',
fontSize: 16,
fontWeight: FontWeight.w500,
color: AppColors.textSecondary,
),
),
],
),
),
);
}
Widget _buildNfcButton() {
final isNfcEnabled = _nfcState == NfcState.enabled;
final isNfcUnsupported = _nfcState == NfcState.unsupported;
return Tooltip(
message: isNfcUnsupported
? L10n.of(context)!.nfcUnsupported
: isNfcEnabled
? L10n.of(context)!.nfcRead
: L10n.of(context)!.nfcDisabled,
child: GestureDetector(
onTap: _toggleNfcRead,
child: Opacity(
opacity: isNfcUnsupported ? 0.3 : 1.0,
child: Container(
padding: const EdgeInsets.all(AppDimensions.paddingSmall + 4),
decoration: BoxDecoration(
color: _nfcReading
? AppColors.primaryAction.withValues(alpha: 0.3)
: Colors.white.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: _nfcReading
? AppColors.primaryAction
: Colors.white.withValues(alpha: 0.2),
width: 1,
),
),
child: Icon(
LucideIcons.nfc,
color: _nfcReading
? AppColors.primaryAction
: isNfcEnabled
? AppColors.textSecondary
: AppColors.textSecondary.withValues(alpha: 0.5),
size: 24,
),
),
),
),
);
}
void _toggleNfcRead() async {
// Re-check state in case user toggled NFC in settings
final currentState = await NfcService.checkState();
if (mounted) setState(() => _nfcState = currentState);
if (currentState == NfcState.unsupported) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(L10n.of(context)!.nfcUnsupported),
backgroundColor: AppColors.textSecondary,
behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
),
);
return;
}
if (currentState == NfcState.disabled) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(L10n.of(context)!.nfcDisabled),
backgroundColor: AppColors.warning,
behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
),
);
return;
}
if (_nfcReading) {
NfcService.stopRead();
setState(() => _nfcReading = false);
return;
}
setState(() => _nfcReading = true);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(L10n.of(context)!.nfcHoldNear),
backgroundColor: AppColors.primaryAction,
behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
),
);
NfcService.startRead(
onTokenRead: (token) {
if (!mounted) return;
setState(() => _nfcReading = false);
_tokenController.text = token;
_onTokenChanged(token);
},
onError: (error) {
if (!mounted) return;
setState(() => _nfcReading = false);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(L10n.of(context)!.nfcReadError(error)),
backgroundColor: AppColors.error,
behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8)),
),
);
},
);
}
Widget _buildScanButton() {
return GestureDetector(
onTap: _openScanner,
child: Container(
padding: const EdgeInsets.all(AppDimensions.paddingSmall + 4),
decoration: BoxDecoration(
gradient: const LinearGradient(
colors: AppColors.buttonGradient,
),
borderRadius: BorderRadius.circular(12),
),
child: const Icon(
LucideIcons.scan,
color: Colors.white,
size: 24,
),
),
);
}
Future<void> _openScanner() async {
final result = await Navigator.push<String>(
context,
MaterialPageRoute(
builder: (context) => const ScanScreen(mode: ScanMode.cashuOnly),
),
);
if (result != null && result.isNotEmpty && mounted) {
_tokenController.text = result;
_onTokenChanged(result);
}
}
Widget _buildTokenPreview() {
final walletProvider = context.read<WalletProvider>();
final amount = _tokenInfo!.amount;
// Usar unidad detectada del token, o fallback a unidad activa
final tokenUnit = _tokenInfo!.unit ?? walletProvider.activeUnit;
final mintDisplay = UnitFormatter.getMintDisplayName(_tokenInfo!.mintUrl);
final unitDetected = _tokenInfo!.unit != null;
return GlassCard(
padding: const EdgeInsets.all(AppDimensions.paddingMedium),
child: Column(
children: [
// Estado válido
Row(
children: [
Container(
padding: const EdgeInsets.all(6),
decoration: BoxDecoration(
color: AppColors.success.withValues(alpha: 0.2),
shape: BoxShape.circle,
),
child: const Icon(
LucideIcons.check,
color: AppColors.success,
size: 16,
),
),
const SizedBox(width: 8),
Text(
L10n.of(context)!.validToken,
style: TextStyle(
fontFamily: 'Inter',
fontSize: 14,
fontWeight: FontWeight.w500,
color: AppColors.success,
),
),
],
),
const SizedBox(height: AppDimensions.paddingMedium),
// Monto con unidad detectada
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
L10n.of(context)!.amount,
style: TextStyle(
fontFamily: 'Inter',
fontSize: 14,
color: AppColors.textSecondary.withValues(alpha: 0.7),
),
),
Row(
children: [
Text(
'${UnitFormatter.formatBalance(amount, tokenUnit)} ${UnitFormatter.getUnitLabel(tokenUnit)}',
style: const TextStyle(
fontFamily: 'Inter',
fontSize: 18,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
// Indicador si la unidad fue detectada automáticamente
if (!unitDetected) ...[
const SizedBox(width: 4),
Icon(
LucideIcons.helpCircle,
color: AppColors.textSecondary.withValues(alpha: 0.5),
size: 14,
),
],
],
),
],
),
const SizedBox(height: 8),
// Mint
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
L10n.of(context)!.mint,
style: TextStyle(
fontFamily: 'Inter',
fontSize: 14,
color: AppColors.textSecondary.withValues(alpha: 0.7),
),
),
Flexible(
child: Text(
mintDisplay,
style: TextStyle(
fontFamily: 'Inter',
fontSize: 14,
color: AppColors.textSecondary,
),
overflow: TextOverflow.ellipsis,
),
),
],
),
],
),
);
}
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),
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 que muestra el estado P2PK del token
Widget _buildP2PKIndicator() {
final l10n = L10n.of(context)!;
// Convertir pubkey hex a npub para mostrar
String? npubDisplay;
if (_lockedToPubkeyHex != null) {
try {
npubDisplay = NostrUtils.hexToNpub(_lockedToPubkeyHex!);
} catch (_) {
npubDisplay = _lockedToPubkeyHex; // Fallback a hex si falla conversión
}
}
// Truncar npub para display: npub1abc...xyz
String truncatedNpub = '';
if (npubDisplay != null && npubDisplay.length > 20) {
truncatedNpub = '${npubDisplay.substring(0, 12)}...${npubDisplay.substring(npubDisplay.length - 6)}';
} else {
truncatedNpub = npubDisplay ?? '';
}
return GlassCard(
padding: const EdgeInsets.all(AppDimensions.paddingMedium),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Encabezado con icono y estado
Row(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: _isLockedToUs
? AppColors.success.withValues(alpha: 0.2)
: AppColors.warning.withValues(alpha: 0.2),
shape: BoxShape.circle,
),
child: Icon(
_isLockedToUs ? LucideIcons.unlock : LucideIcons.lock,
color: _isLockedToUs ? AppColors.success : AppColors.warning,
size: 20,
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
_isLockedToUs ? l10n.p2pkLockedToYou : l10n.p2pkLockedToOther,
style: TextStyle(
fontFamily: 'Inter',
fontSize: 14,
fontWeight: FontWeight.w600,
color: _isLockedToUs ? AppColors.success : AppColors.warning,
),
),
if (_isLockedToUs && _matchingKeyLabel != null)
Text(
_matchingKeyLabel!,
style: TextStyle(
fontFamily: 'Inter',
fontSize: 12,
color: AppColors.textSecondary,
),
),
],
),
),
// Badge experimental
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: AppColors.warning.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(8),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
LucideIcons.flaskConical,
size: 12,
color: AppColors.warning,
),
const SizedBox(width: 4),
Text(
l10n.p2pkExperimentalShort,
style: TextStyle(
fontFamily: 'Inter',
fontSize: 10,
fontWeight: FontWeight.w500,
color: AppColors.warning,
),
),
],
),
),
],
),
const SizedBox(height: 12),
// Pubkey (npub truncado) - tap para copiar
GestureDetector(
onTap: () {
if (npubDisplay != null) {
Clipboard.setData(ClipboardData(text: npubDisplay));
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(l10n.copied('npub')),
duration: const Duration(seconds: 2),
backgroundColor: AppColors.success,
),
);
}
},
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.05),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: Colors.white.withValues(alpha: 0.1),
),
),
child: Row(
children: [
Expanded(
child: Text(
truncatedNpub,
style: TextStyle(
fontFamily: 'monospace',
fontSize: 12,
color: AppColors.textSecondary,
),
),
),
Icon(
LucideIcons.copy,
size: 14,
color: AppColors.textSecondary.withValues(alpha: 0.7),
),
],
),
),
),
// Campo nsec manual (solo si NO es nuestra clave)
if (!_isLockedToUs) ...[
const SizedBox(height: 16),
// Mensaje de error si no puede desbloquear
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: AppColors.error.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: AppColors.error.withValues(alpha: 0.2),
),
),
child: Row(
children: [
Icon(
LucideIcons.alertTriangle,
size: 16,
color: AppColors.error,
),
const SizedBox(width: 8),
Expanded(
child: Text(
l10n.p2pkCannotUnlock,
style: TextStyle(
fontFamily: 'Inter',
fontSize: 12,
color: AppColors.error,
),
),
),
],
),
),
const SizedBox(height: 12),
// Campo para ingresar nsec manualmente
Text(
l10n.p2pkEnterPrivateKey,
style: TextStyle(
fontFamily: 'Inter',
fontSize: 14,
fontWeight: FontWeight.w500,
color: AppColors.textSecondary,
),
),
const SizedBox(height: 8),
Container(
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.05),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: _manualKeyError != null
? AppColors.error.withValues(alpha: 0.5)
: Colors.white.withValues(alpha: 0.1),
),
),
child: Row(
children: [
Expanded(
child: TextField(
controller: _manualKeyController,
obscureText: !_showManualKey,
style: const TextStyle(
fontFamily: 'monospace',
fontSize: 14,
color: Colors.white,
),
decoration: InputDecoration(
hintText: 'nsec1... o hex',
hintStyle: TextStyle(
fontFamily: 'monospace',
fontSize: 14,
color: Colors.white.withValues(alpha: 0.3),
),
border: InputBorder.none,
contentPadding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 14,
),
),
onChanged: (_) {
setState(() {
_manualKeyError = null;
});
},
),
),
// Botón mostrar/ocultar
IconButton(
icon: Icon(
_showManualKey ? LucideIcons.eyeOff : LucideIcons.eye,
color: AppColors.textSecondary,
size: 20,
),
onPressed: () {
setState(() {
_showManualKey = !_showManualKey;
});
},
),
// Botón pegar
IconButton(
icon: Icon(
LucideIcons.clipboard,
color: AppColors.textSecondary,
size: 20,
),
onPressed: () async {
final clipboardData = await Clipboard.getData(Clipboard.kTextPlain);
if (clipboardData?.text != null) {
_manualKeyController.text = clipboardData!.text!.trim();
setState(() {
_manualKeyError = null;
});
}
},
),
],
),
),
// Error de clave manual
if (_manualKeyError != null) ...[
const SizedBox(height: 8),
Text(
_manualKeyError!,
style: TextStyle(
fontFamily: 'Inter',
fontSize: 12,
color: AppColors.error,
),
),
],
],
],
),
);
}
Widget _buildReceiveButton() {
final l10n = L10n.of(context)!;
// Determinar si el botón debe estar habilitado
bool canReceive = _isValidToken && !_isProcessing;
// Si es P2PK y NO es nuestra clave, necesita nsec manual válido
if (_isP2PKLocked && !_isLockedToUs) {
final manualKey = _manualKeyController.text.trim();
canReceive = canReceive && manualKey.isNotEmpty;
}
return PrimaryButton(
text: _isProcessing ? l10n.claiming : l10n.receive,
onPressed: canReceive ? _receiveToken : null,
);
}
Future<void> _pasteFromClipboard() async {
final clipboardData = await Clipboard.getData(Clipboard.kTextPlain);
if (!mounted) return;
if (clipboardData?.text != null) {
var text = clipboardData!.text!.trim();
// Auto-decode peanut emoji format
if (PeanutCodec.isPeanut(text)) {
final decoded = PeanutCodec.decode(text);
if (decoded != null) {
text = decoded;
} else {
// Malformed peanut: clear field and show specific error
_tokenController.clear();
setState(() {
_isValidToken = false;
_tokenInfo = null;
_errorMessage = L10n.of(context)!.peanutDecodeError;
});
return;
}
}
_tokenController.text = text;
_onTokenChanged(text);
}
}
void _onTokenChanged(String value) {
final walletProvider = context.read<WalletProvider>();
final p2pkProvider = context.read<P2PKProvider>();
// Auto-decode peanut emoji format
var tokenValue = value.trim();
if (PeanutCodec.isPeanut(tokenValue)) {
final decoded = PeanutCodec.decode(tokenValue);
if (decoded != null) {
tokenValue = decoded;
// Update the text field with the decoded token
_tokenController.text = tokenValue;
_tokenController.selection = TextSelection.collapsed(
offset: tokenValue.length,
);
} else {
// Malformed peanut: show specific error
setState(() {
_isValidToken = false;
_tokenInfo = null;
_errorMessage = L10n.of(context)!.peanutDecodeError;
});
return;
}
}
setState(() {
_errorMessage = null;
_manualKeyError = null;
// Reset P2PK state
_isP2PKLocked = false;
_isLockedToUs = false;
_lockedToPubkeyHex = null;
_matchingKeyLabel = null;
if (tokenValue.isEmpty) {
_isValidToken = false;
_tokenInfo = null;
return;
}
// Parsear token real con cdk-flutter
final tokenInfo = walletProvider.parseToken(tokenValue);
if (tokenInfo != null) {
_isValidToken = true;
_tokenInfo = tokenInfo;
// Detectar P2PK
_isP2PKLocked = p2pkProvider.isTokenLocked(tokenValue);
if (_isP2PKLocked) {
_lockedToPubkeyHex = p2pkProvider.extractLockedPubkey(tokenValue);
_isLockedToUs = p2pkProvider.isTokenLockedToUs(tokenValue);
// Si es nuestra, buscar el label de la clave
if (_isLockedToUs && _lockedToPubkeyHex != null) {
_matchingKeyLabel = _findMatchingKeyLabel(p2pkProvider);
}
}
} else {
_isValidToken = false;
_tokenInfo = null;
_errorMessage = L10n.of(context)!.invalidToken;
}
});
}
/// Busca el label de la clave que coincide con el token P2PK
String? _findMatchingKeyLabel(P2PKProvider p2pkProvider) {
if (_lockedToPubkeyHex == null) return null;
// Normalizar a x-only para comparar
final lockedNormalized = _normalizeToXOnly(_lockedToPubkeyHex!);
for (final key in p2pkProvider.keys) {
final keyNormalized = _normalizeToXOnly(key.publicKey);
if (keyNormalized == lockedNormalized) {
return key.label;
}
}
return null;
}
/// Normaliza pubkey a x-only (64 chars) quitando prefijo SEC1 si existe
String _normalizeToXOnly(String pubkey) {
final lower = pubkey.toLowerCase();
if (lower.length == 66 && (lower.startsWith('02') || lower.startsWith('03'))) {
return lower.substring(2);
}
return lower;
}
/// Método principal: recibe token con detección automática de conectividad.
/// 1. Si el mint es desconocido y no hay conexión → rechazar
/// 2. Ping al mint (3s) → si OK → claim → si falla → guardar pendiente
Future<void> _receiveToken() async {
if (_isProcessing) return;
setState(() {
_isProcessing = true;
_errorMessage = null;
});
final l10n = L10n.of(context)!;
final walletProvider = context.read<WalletProvider>();
try {
final mintUrl = _tokenInfo?.mintUrl;
if (mintUrl == null) {
throw Exception(l10n.invalidToken);
}
// Verificar si el mint es conocido
final isKnownMint = walletProvider.mintUrls.contains(mintUrl);
// Verificar conectividad al mint
final canReach = await walletProvider.canReachMint(mintUrl);
// Si el mint es desconocido y no hay conexión → rechazar
if (!isKnownMint && !canReach) {
setState(() {
_errorMessage = l10n.unknownMintOffline;
_isProcessing = false;
});
return;
}
// Si no hay conexión → guardar como pendiente
if (!canReach) {
await _saveForLaterOffline();
return;
}
// Hay conexión → intentar reclamar
await _claimToken();
} catch (e) {
if (!mounted) return;
setState(() {
_errorMessage = l10n.claimError(e.toString());
_isProcessing = false;
});
}
}
/// Guarda el token como pendiente cuando no hay conexión.
Future<void> _saveForLaterOffline() async {
final l10n = L10n.of(context)!;
final walletProvider = context.read<WalletProvider>();
try {
final pending = await walletProvider.addPendingToken(
_tokenController.text.trim(),
);
if (pending != null) {
if (mounted) {
// Mostrar mensaje de sin conexión
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(l10n.noConnectionTokenSaved),
backgroundColor: AppColors.warning,
duration: const Duration(seconds: 3),
),
);
Navigator.pop(context);
}
} else {
// Límite alcanzado
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(l10n.pendingTokenLimitReached),
backgroundColor: AppColors.warning,
duration: const Duration(seconds: 3),
),
);
}
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(l10n.saveTokenError),
backgroundColor: AppColors.error,
),
);
}
} finally {
if (mounted) {
setState(() => _isProcessing = false);
}
}
}
/// Reclama el token directamente (cuando hay conexión).
Future<void> _claimToken() async {
final walletProvider = context.read<WalletProvider>();
final p2pkProvider = context.read<P2PKProvider>();
final l10n = L10n.of(context)!;
// Guardar unidad detectada del token ANTES de reclamar
final detectedUnit = _tokenInfo?.unit ?? walletProvider.activeUnit;
final token = _tokenController.text.trim();
try {
BigInt amountReceived;
if (_isP2PKLocked) {
// Token P2PK - necesita clave privada para desbloquear
String? privateKeyHex;
if (_isLockedToUs) {
// Es nuestra clave - obtener automáticamente
privateKeyHex = p2pkProvider.getPrivateKeyForToken(token);
if (privateKeyHex == null) {
throw Exception(l10n.p2pkCannotUnlock);
}
} else {
// No es nuestra - usar clave manual
final manualInput = _manualKeyController.text.trim();
// Intentar convertir nsec a hex
if (manualInput.startsWith('nsec1')) {
privateKeyHex = NostrUtils.nsecToHex(manualInput);
} else if (RegExp(r'^[0-9a-fA-F]{64}$').hasMatch(manualInput)) {
// Ya es hex de 64 caracteres
privateKeyHex = manualInput.toLowerCase();
}
if (privateKeyHex == null) {
setState(() {
_manualKeyError = l10n.p2pkInvalidPrivateKey;
_isProcessing = false;
});
return;
}
}
// Reclamar token P2PK
amountReceived = await walletProvider.receiveP2pkToken(
token,
[privateKeyHex],
);
} else {
// Token normal - flujo existente
amountReceived = await walletProvider.receiveToken(token);
}
if (mounted) {
setState(() {
_receivedAmount = amountReceived;
_receivedUnit = detectedUnit;
_showSuccess = true;
_isProcessing = false;
});
// Confetti se dispara globalmente desde WalletProvider.receiveToken
}
} catch (e) {
if (!mounted) return;
// Mensajes de error más amigables
final errorStr = e.toString().toLowerCase();
String errorMessage;
if (errorStr.contains('already spent') || errorStr.contains('token already')) {
errorMessage = l10n.tokenAlreadyClaimed;
} else if (errorStr.contains('unknown mint') || errorStr.contains('mint not found')) {
errorMessage = l10n.unknownMint;
} else if (errorStr.contains('cannot unlock') || errorStr.contains('p2pk')) {
errorMessage = l10n.p2pkCannotUnlock;
} else {
errorMessage = l10n.claimError(e.toString());
}
setState(() {
_errorMessage = errorMessage;
_isProcessing = false;
});
}
}
}