1218 lines
38 KiB
Dart
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;
|
|
});
|
|
}
|
|
}
|
|
}
|