Compare commits

...
15 changed files with 1311 additions and 22 deletions
+1
View File
@@ -487,6 +487,7 @@
"cameraPermissionDenied": "Kamera-Berechtigung verweigert",
"paymentRequestNotSupported": "Zahlungsanfragen werden noch nicht unterstützt",
"p2pkTitle": "P2PK-Schlüssel",
"p2pkSettingsDescription": "Gesperrtes ecash empfangen",
"p2pkExperimental": "P2PK ist experimentell. Mit Vorsicht verwenden.",
"p2pkExperimentalShort": "Experimentell",
"p2pkPrimaryKey": "Primärschlüssel",
+1
View File
@@ -368,6 +368,7 @@
"paymentRequestNotSupported": "Payment requests are not yet supported",
"p2pkTitle": "P2PK Keys",
"p2pkSettingsDescription": "Receive locked ecash",
"p2pkExperimental": "P2PK is experimental. Use with caution.",
"p2pkExperimentalShort": "Experimental",
"p2pkPrimaryKey": "Primary Key",
+1
View File
@@ -488,6 +488,7 @@
"paymentRequestNotSupported": "Los payment requests aún no están soportados",
"p2pkTitle": "Claves P2PK",
"p2pkSettingsDescription": "Recibir ecash bloqueado",
"p2pkExperimental": "P2PK es experimental. Úsala con precaución.",
"p2pkExperimentalShort": "Experimental",
"p2pkPrimaryKey": "Clave Principal",
+1
View File
@@ -487,6 +487,7 @@
"cameraPermissionDenied": "Permission de la caméra refusée",
"paymentRequestNotSupported": "Les demandes de paiement ne sont pas encore prises en charge",
"p2pkTitle": "Clés P2PK",
"p2pkSettingsDescription": "Recevoir ecash verrouillé",
"p2pkExperimental": "P2PK est expérimental. Utiliser avec prudence.",
"p2pkExperimentalShort": "Expérimental",
"p2pkPrimaryKey": "Clé Principale",
+1
View File
@@ -487,6 +487,7 @@
"cameraPermissionDenied": "Permesso fotocamera negato",
"paymentRequestNotSupported": "Le richieste di pagamento non sono ancora supportate",
"p2pkTitle": "Chiavi P2PK",
"p2pkSettingsDescription": "Ricevi ecash bloccato",
"p2pkExperimental": "P2PK è sperimentale. Usare con cautela.",
"p2pkExperimentalShort": "Sperimentale",
"p2pkPrimaryKey": "Chiave Principale",
+1
View File
@@ -487,6 +487,7 @@
"cameraPermissionDenied": "カメラの許可が拒否されました",
"paymentRequestNotSupported": "支払いリクエストはまだサポートされていません",
"p2pkTitle": "P2PK鍵",
"p2pkSettingsDescription": "ロックされたecashを受け取る",
"p2pkExperimental": "P2PKは実験的機能です。注意してご使用ください。",
"p2pkExperimentalShort": "実験的",
"p2pkPrimaryKey": "プライマリ鍵",
+1
View File
@@ -487,6 +487,7 @@
"cameraPermissionDenied": "카메라 권한이 거부되었습니다",
"paymentRequestNotSupported": "결제 요청은 아직 지원되지 않습니다",
"p2pkTitle": "P2PK 키",
"p2pkSettingsDescription": "잠긴 ecash 받기",
"p2pkExperimental": "P2PK는 실험적 기능입니다. 주의하여 사용하세요.",
"p2pkExperimentalShort": "실험적",
"p2pkPrimaryKey": "기본 키",
+1
View File
@@ -487,6 +487,7 @@
"cameraPermissionDenied": "Permissão de câmera negada",
"paymentRequestNotSupported": "Solicitações de pagamento ainda não são suportadas",
"p2pkTitle": "Chaves P2PK",
"p2pkSettingsDescription": "Receber ecash bloqueado",
"p2pkExperimental": "P2PK é experimental. Use com cautela.",
"p2pkExperimentalShort": "Experimental",
"p2pkPrimaryKey": "Chave Principal",
+1
View File
@@ -487,6 +487,7 @@
"cameraPermissionDenied": "Доступ к камере запрещён",
"paymentRequestNotSupported": "Запросы на оплату пока не поддерживаются",
"p2pkTitle": "Ключи P2PK",
"p2pkSettingsDescription": "Получить заблокированный ecash",
"p2pkExperimental": "P2PK экспериментальный. Используйте с осторожностью.",
"p2pkExperimentalShort": "Экспериментальный",
"p2pkPrimaryKey": "Основной ключ",
+1
View File
@@ -487,6 +487,7 @@
"cameraPermissionDenied": "Ruhusa ya kamera imekataliwa",
"paymentRequestNotSupported": "Maombi ya malipo bado hayatumiki",
"p2pkTitle": "Funguo za P2PK",
"p2pkSettingsDescription": "Pokea ecash iliyofungwa",
"p2pkExperimental": "P2PK ni ya majaribio. Tumia kwa uangalifu.",
"p2pkExperimentalShort": "Majaribio",
"p2pkPrimaryKey": "Ufunguo Mkuu",
+1
View File
@@ -487,6 +487,7 @@
"cameraPermissionDenied": "相机权限被拒绝",
"paymentRequestNotSupported": "付款请求尚不支持",
"p2pkTitle": "P2PK密钥",
"p2pkSettingsDescription": "接收锁定的ecash",
"p2pkExperimental": "P2PK是实验性功能。请谨慎使用。",
"p2pkExperimentalShort": "实验性",
"p2pkPrimaryKey": "主密钥",
+20 -15
View File
@@ -784,8 +784,9 @@ class WalletProvider extends ChangeNotifier {
/// Reclama un token Cashu detectando automáticamente su unidad.
/// Si el mint del token es diferente al activo, lo agrega.
/// NO cambia la unidad activa (comportamiento similar a cashu.me).
/// Si el token es P2PK, pasar la clave privada en p2pkPrivateKey.
/// Retorna monto recibido.
Future<BigInt> receiveToken(String encodedToken) async {
Future<BigInt> receiveToken(String encodedToken, {String? p2pkPrivateKey}) async {
// Parsear token (incluye detección de unidad)
final tokenInfo = parseToken(encodedToken);
if (tokenInfo == null) {
@@ -802,13 +803,13 @@ class WalletProvider extends ChangeNotifier {
// Intentar detectar unidad de nuevo ahora que tenemos keysets
final detectedUnit = detectTokenUnit(encodedToken);
if (detectedUnit != null) {
return await _receiveWithUnit(encodedToken, tokenMint, detectedUnit);
return await _receiveWithUnit(encodedToken, tokenMint, detectedUnit, p2pkPrivateKey);
}
}
// Si tenemos unidad detectada, usarla
if (tokenInfo.unit != null) {
return await _receiveWithUnit(encodedToken, tokenMint, tokenInfo.unit!);
return await _receiveWithUnit(encodedToken, tokenMint, tokenInfo.unit!, p2pkPrivateKey);
}
// Fallback: intentar con cada unidad del mint hasta que funcione
@@ -817,7 +818,7 @@ class WalletProvider extends ChangeNotifier {
for (final unit in units) {
try {
return await _receiveWithUnit(encodedToken, tokenMint, unit);
return await _receiveWithUnit(encodedToken, tokenMint, unit, p2pkPrivateKey);
} catch (e) {
lastError = e as Exception;
debugPrint('Receive falló con unidad $unit: $e');
@@ -835,10 +836,17 @@ class WalletProvider extends ChangeNotifier {
String encodedToken,
String mintUrl,
String unit,
String? p2pkPrivateKey,
) async {
final wallet = await getWallet(mintUrl, unit);
final token = Token.parse(encoded: encodedToken);
final amount = await wallet.receive(token: token);
// Si hay clave P2PK, usarla para desbloquear el token
final opts = p2pkPrivateKey != null
? ReceiveOptions(signingKeys: [p2pkPrivateKey])
: null;
final amount = await wallet.receive(token: token, opts: opts);
// Guardar metadata para la transacción recién creada
await _saveMetaForRecentReceive(wallet, encodedToken);
@@ -873,6 +881,7 @@ class WalletProvider extends ChangeNotifier {
}
/// Reclama un token P2PK (bloqueado a una clave pública).
/// Usa la unidad del token automáticamente (no requiere cambiar unidad activa).
Future<BigInt> receiveP2pkToken(
String encodedToken,
List<String> signingKeys,
@@ -882,20 +891,14 @@ class WalletProvider extends ChangeNotifier {
throw Exception('Token inválido');
}
// Cambiar al mint del token
// Agregar mint si no existe
if (!_mintUnits.containsKey(tokenInfo.mintUrl)) {
await addMint(tokenInfo.mintUrl);
}
_activeMintUrl = tokenInfo.mintUrl;
// Verificar si la unidad activa es soportada
final units = _mintUnits[tokenInfo.mintUrl]!;
if (!units.contains(_activeUnit)) {
_activeUnit = units.first;
}
final wallet = await getWallet(tokenInfo.mintUrl, _activeUnit);
// Usar la unidad del token (fallback a 'sat' si no especificada)
final tokenUnit = tokenInfo.unit ?? 'sat';
final wallet = await getWallet(tokenInfo.mintUrl, tokenUnit);
final token = Token.parse(encoded: encodedToken);
final amount = await wallet.receive(
@@ -994,6 +997,8 @@ class WalletProvider extends ChangeNotifier {
}
/// Envía tokens P2PK.
/// NOTA: Bug conocido en CDK - solo funciona el primer envío después de iniciar la app.
/// Ver project_context/P2PK_SEND_BUG.md
Future<String> sendTokensP2pk(
BigInt amount,
String pubkey,
+412 -7
View File
@@ -7,11 +7,13 @@ 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 '../../widgets/common/gradient_background.dart';
import '../../widgets/common/glass_card.dart';
import '../../widgets/common/primary_button.dart';
import '../../widgets/effects/cashu_confetti.dart';
import '../../providers/wallet_provider.dart';
import '../../providers/p2pk_provider.dart';
import '../10_scanner/scan_screen.dart';
/// Pantalla para recibir tokens Cashu
@@ -38,6 +40,15 @@ class _ReceiveScreenState extends State<ReceiveScreen> {
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;
@override
void initState() {
super.initState();
@@ -53,6 +64,7 @@ class _ReceiveScreenState extends State<ReceiveScreen> {
@override
void dispose() {
_tokenController.dispose();
_manualKeyController.dispose();
_confettiController.dispose();
super.dispose();
}
@@ -124,6 +136,12 @@ class _ReceiveScreenState extends State<ReceiveScreen> {
// 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(),
],
@@ -457,12 +475,312 @@ class _ReceiveScreenState extends State<ReceiveScreen> {
);
}
/// 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)!;
// Botón único "Recibir" - auto-detecta conectividad
// 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: _isValidToken && !_isProcessing ? _receiveToken : null,
onPressed: canReceive ? _receiveToken : null,
);
}
@@ -476,9 +794,17 @@ class _ReceiveScreenState extends State<ReceiveScreen> {
void _onTokenChanged(String value) {
final walletProvider = context.read<WalletProvider>();
final p2pkProvider = context.read<P2PKProvider>();
setState(() {
_errorMessage = null;
_manualKeyError = null;
// Reset P2PK state
_isP2PKLocked = false;
_isLockedToUs = false;
_lockedToPubkeyHex = null;
_matchingKeyLabel = null;
if (value.isEmpty) {
_isValidToken = false;
@@ -492,6 +818,18 @@ class _ReceiveScreenState extends State<ReceiveScreen> {
if (tokenInfo != null) {
_isValidToken = true;
_tokenInfo = tokenInfo;
// Detectar P2PK
_isP2PKLocked = p2pkProvider.isTokenLocked(value.trim());
if (_isP2PKLocked) {
_lockedToPubkeyHex = p2pkProvider.extractLockedPubkey(value.trim());
_isLockedToUs = p2pkProvider.isTokenLockedToUs(value.trim());
// Si es nuestra, buscar el label de la clave
if (_isLockedToUs && _lockedToPubkeyHex != null) {
_matchingKeyLabel = _findMatchingKeyLabel(p2pkProvider);
}
}
} else {
_isValidToken = false;
_tokenInfo = null;
@@ -500,6 +838,31 @@ class _ReceiveScreenState extends State<ReceiveScreen> {
});
}
/// 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
@@ -605,15 +968,56 @@ class _ReceiveScreenState extends State<ReceiveScreen> {
/// 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 {
// Reclamar token (usa unidad detectada internamente)
final amountReceived = await walletProvider.receiveToken(
_tokenController.text.trim(),
);
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(() {
@@ -628,7 +1032,6 @@ class _ReceiveScreenState extends State<ReceiveScreen> {
}
} catch (e) {
if (!mounted) return;
final l10n = L10n.of(context)!;
// Mensajes de error más amigables
final errorStr = e.toString().toLowerCase();
String errorMessage;
@@ -636,6 +1039,8 @@ class _ReceiveScreenState extends State<ReceiveScreen> {
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());
}
@@ -0,0 +1,772 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:lucide_icons/lucide_icons.dart';
import 'package:provider/provider.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:qr_flutter/qr_flutter.dart';
import '../../core/constants/colors.dart';
import '../../core/constants/dimensions.dart';
import '../../core/utils/nostr_utils.dart';
import '../../providers/p2pk_provider.dart';
import '../../models/p2pk_key.dart';
import '../../widgets/common/gradient_background.dart';
import '../../widgets/common/glass_card.dart';
/// Pantalla para gestionar claves P2PK
class P2PKKeysScreen extends StatefulWidget {
const P2PKKeysScreen({super.key});
@override
State<P2PKKeysScreen> createState() => _P2PKKeysScreenState();
}
class _P2PKKeysScreenState extends State<P2PKKeysScreen> {
@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.p2pkTitle,
style: const TextStyle(
fontFamily: 'Inter',
fontWeight: FontWeight.w600,
color: Colors.white,
),
),
),
body: SafeArea(
child: Consumer<P2PKProvider>(
builder: (context, p2pkProvider, child) {
if (!p2pkProvider.isInitialized) {
return const Center(
child: CircularProgressIndicator(
color: AppColors.primaryAction,
),
);
}
return Column(
children: [
// Contenido scrolleable
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.all(AppDimensions.paddingMedium),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Banner experimental
_buildExperimentalBanner(l10n),
const SizedBox(height: AppDimensions.paddingMedium),
// Clave principal
if (p2pkProvider.primaryKey != null) ...[
_buildSectionHeader(l10n.p2pkPrimaryKey),
const SizedBox(height: AppDimensions.paddingSmall),
_buildKeyCard(p2pkProvider.primaryKey!, p2pkProvider, l10n),
const SizedBox(height: AppDimensions.paddingLarge),
],
// Claves importadas
_buildSectionHeader(l10n.p2pkImportedKeys),
const SizedBox(height: AppDimensions.paddingSmall),
if (p2pkProvider.importedKeys.isEmpty)
_buildEmptyImportedState(l10n)
else
...p2pkProvider.importedKeys.map(
(key) => Padding(
padding: const EdgeInsets.only(
bottom: AppDimensions.paddingSmall,
),
child: _buildKeyCard(key, p2pkProvider, l10n),
),
),
],
),
),
),
// Botón importar (fijo abajo)
if (p2pkProvider.canImportMore)
Padding(
padding: const EdgeInsets.all(AppDimensions.paddingMedium),
child: _buildImportButton(p2pkProvider, l10n),
),
],
);
},
),
),
),
);
}
Widget _buildExperimentalBanner(L10n l10n) {
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: AppColors.warning.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: AppColors.warning.withValues(alpha: 0.3),
),
),
child: Row(
children: [
Icon(
LucideIcons.flaskConical,
color: AppColors.warning,
size: 20,
),
const SizedBox(width: 12),
Expanded(
child: Text(
l10n.p2pkExperimental,
style: TextStyle(
fontFamily: 'Inter',
fontSize: 13,
color: AppColors.warning,
),
),
),
],
),
);
}
Widget _buildSectionHeader(String text) {
return Text(
text,
style: TextStyle(
fontFamily: 'Inter',
fontSize: 12,
fontWeight: FontWeight.bold,
letterSpacing: 1.5,
color: AppColors.textSecondary.withValues(alpha: 0.6),
),
);
}
Widget _buildKeyCard(P2PKKey key, P2PKProvider provider, L10n l10n) {
String truncatedNpub;
try {
final npub = NostrUtils.hexToNpub(key.publicKey);
truncatedNpub = '${npub.substring(0, 12)}...${npub.substring(npub.length - 8)}';
} catch (_) {
truncatedNpub = key.publicKey.length > 16
? '${key.publicKey.substring(0, 16)}...'
: key.publicKey;
}
return GlassCard(
padding: const EdgeInsets.all(AppDimensions.paddingMedium),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header con label y badge
Row(
children: [
Expanded(
child: Text(
key.label,
style: const TextStyle(
fontFamily: 'Inter',
fontSize: 16,
fontWeight: FontWeight.w600,
color: Colors.white,
),
),
),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: key.isDerived
? AppColors.primaryAction.withValues(alpha: 0.2)
: AppColors.secondaryAction.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(6),
),
child: Text(
key.isDerived ? l10n.p2pkDerived : l10n.p2pkImported,
style: TextStyle(
fontFamily: 'Inter',
fontSize: 11,
fontWeight: FontWeight.w500,
color: key.isDerived
? AppColors.primaryAction
: AppColors.secondaryAction,
),
),
),
],
),
const SizedBox(height: 12),
// npub (tap to copy)
GestureDetector(
onTap: () {
final fullNpub = _safeNpub(key.publicKey);
Clipboard.setData(ClipboardData(text: fullNpub));
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: 10),
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: [
Icon(
LucideIcons.key,
color: AppColors.textSecondary,
size: 16,
),
const SizedBox(width: 10),
Expanded(
child: Text(
truncatedNpub,
style: TextStyle(
fontFamily: 'monospace',
fontSize: 13,
color: AppColors.textSecondary,
),
),
),
Icon(
LucideIcons.copy,
color: AppColors.textSecondary.withValues(alpha: 0.5),
size: 16,
),
],
),
),
),
const SizedBox(height: 12),
// Acciones
Row(
children: [
// Mostrar QR
Expanded(
child: GestureDetector(
onTap: () => _showQRDialog(key, l10n),
child: Container(
padding: const EdgeInsets.symmetric(vertical: 10),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(8),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
LucideIcons.qrCode,
color: AppColors.textSecondary,
size: 16,
),
const SizedBox(width: 8),
Text(
l10n.p2pkShowQR,
style: TextStyle(
fontFamily: 'Inter',
fontSize: 13,
color: AppColors.textSecondary,
),
),
],
),
),
),
),
// Eliminar (solo para importadas)
if (!key.isDerived) ...[
const SizedBox(width: 8),
GestureDetector(
onTap: () => _showDeleteDialog(key, provider, l10n),
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 10,
),
decoration: BoxDecoration(
color: AppColors.error.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(8),
),
child: Icon(
LucideIcons.trash2,
color: AppColors.error,
size: 16,
),
),
),
],
],
),
],
),
);
}
Widget _buildEmptyImportedState(L10n l10n) {
return Container(
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.03),
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: Colors.white.withValues(alpha: 0.1),
),
),
child: Column(
children: [
Icon(
LucideIcons.keyRound,
color: AppColors.textSecondary.withValues(alpha: 0.4),
size: 40,
),
const SizedBox(height: 12),
Text(
l10n.p2pkNoImportedKeys,
style: TextStyle(
fontFamily: 'Inter',
fontSize: 14,
color: AppColors.textSecondary.withValues(alpha: 0.6),
),
textAlign: TextAlign.center,
),
],
),
);
}
Widget _buildImportButton(P2PKProvider provider, L10n l10n) {
return GestureDetector(
onTap: () => _showImportDialog(provider, l10n),
child: Container(
padding: const EdgeInsets.symmetric(vertical: 16),
decoration: BoxDecoration(
gradient: const LinearGradient(
colors: [AppColors.primaryAction, Color(0xFFFF9100)],
),
borderRadius: BorderRadius.circular(16),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(LucideIcons.plus, color: Colors.white, size: 20),
const SizedBox(width: 8),
Text(
l10n.p2pkImportNsec,
style: const TextStyle(
fontFamily: 'Inter',
fontSize: 16,
fontWeight: FontWeight.w600,
color: Colors.white,
),
),
],
),
),
);
}
// ============ DIÁLOGOS ============
void _showQRDialog(P2PKKey key, L10n l10n) {
final npub = _safeNpub(key.publicKey);
showDialog(
context: context,
builder: (context) => AlertDialog(
backgroundColor: AppColors.deepVoidPurple,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)),
content: SizedBox(
width: 280,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
key.label,
style: const TextStyle(
fontFamily: 'Inter',
fontSize: 18,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
const SizedBox(height: 16),
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
),
child: QrImageView(
data: npub,
version: QrVersions.auto,
size: 200,
backgroundColor: Colors.white,
),
),
const SizedBox(height: 16),
GestureDetector(
onTap: () {
Clipboard.setData(ClipboardData(text: npub));
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: 16,
vertical: 10,
),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
LucideIcons.copy,
color: AppColors.textSecondary,
size: 16,
),
const SizedBox(width: 8),
Text(
l10n.p2pkCopy,
style: TextStyle(
fontFamily: 'Inter',
fontSize: 14,
color: AppColors.textSecondary,
),
),
],
),
),
),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(
l10n.close,
style: const TextStyle(
fontFamily: 'Inter',
color: AppColors.textSecondary,
),
),
),
],
),
);
}
void _showImportDialog(P2PKProvider provider, L10n l10n) {
final nsecController = TextEditingController();
final labelController = TextEditingController();
bool obscureNsec = true;
String? errorMessage;
showDialog(
context: context,
builder: (context) => StatefulBuilder(
builder: (context, setDialogState) {
final nsecBorder = OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: errorMessage != null
? const BorderSide(color: AppColors.error)
: BorderSide.none,
);
return AlertDialog(
backgroundColor: AppColors.deepVoidPurple,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(24),
),
title: Text(
l10n.p2pkImportNsec,
style: const TextStyle(
fontFamily: 'Inter',
fontSize: 20,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
content: SizedBox(
width: 300,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Label
TextField(
controller: labelController,
style: const TextStyle(
fontFamily: 'Inter',
fontSize: 14,
color: Colors.white,
),
decoration: InputDecoration(
hintText: l10n.p2pkEnterLabel,
hintStyle: TextStyle(
fontFamily: 'Inter',
fontSize: 14,
color: Colors.white.withValues(alpha: 0.3),
),
filled: true,
fillColor: Colors.white.withValues(alpha: 0.05),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
),
prefixIcon: Icon(
LucideIcons.tag,
color: AppColors.textSecondary,
size: 18,
),
),
),
const SizedBox(height: 12),
// nsec input
TextField(
controller: nsecController,
obscureText: obscureNsec,
style: const TextStyle(
fontFamily: 'monospace',
fontSize: 14,
color: Colors.white,
),
decoration: InputDecoration(
hintText: 'nsec1...',
hintStyle: TextStyle(
fontFamily: 'monospace',
fontSize: 14,
color: Colors.white.withValues(alpha: 0.3),
),
filled: true,
fillColor: Colors.white.withValues(alpha: 0.05),
border: nsecBorder,
enabledBorder: nsecBorder,
focusedBorder: nsecBorder,
prefixIcon: Icon(
LucideIcons.keyRound,
color: AppColors.textSecondary,
size: 18,
),
suffixIcon: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: Icon(
obscureNsec ? LucideIcons.eye : LucideIcons.eyeOff,
color: AppColors.textSecondary,
size: 18,
),
onPressed: () {
setDialogState(() => obscureNsec = !obscureNsec);
},
),
IconButton(
icon: Icon(
LucideIcons.clipboard,
color: AppColors.textSecondary,
size: 18,
),
onPressed: () async {
final data = await Clipboard.getData('text/plain');
if (data?.text != null) {
nsecController.text = data!.text!;
}
},
),
],
),
),
),
// Error message
if (errorMessage != null) ...[
const SizedBox(height: 8),
Text(
errorMessage!,
style: const TextStyle(
fontFamily: 'Inter',
fontSize: 12,
color: AppColors.error,
),
),
],
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(
l10n.cancel,
style: const TextStyle(
fontFamily: 'Inter',
color: AppColors.textSecondary,
),
),
),
TextButton(
onPressed: () async {
try {
await provider.importFromNsec(
nsecController.text.trim(),
labelController.text.trim(),
);
if (context.mounted) {
Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(l10n.success),
backgroundColor: AppColors.success,
),
);
}
} on P2PKException catch (e) {
setDialogState(() {
errorMessage = _getErrorMessage(e.code, l10n);
});
}
},
child: Text(
l10n.p2pkImport,
style: const TextStyle(
fontFamily: 'Inter',
fontWeight: FontWeight.w600,
color: AppColors.primaryAction,
),
),
),
],
);
},
),
);
}
void _showDeleteDialog(P2PKKey key, P2PKProvider provider, L10n l10n) {
showDialog(
context: context,
builder: (context) => AlertDialog(
backgroundColor: AppColors.deepVoidPurple,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)),
title: Text(
l10n.p2pkDeleteTitle,
style: const TextStyle(
fontFamily: 'Inter',
fontSize: 18,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
content: Text(
l10n.p2pkDeleteConfirm,
style: TextStyle(
fontFamily: 'Inter',
fontSize: 14,
color: AppColors.textSecondary,
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(
l10n.cancel,
style: const TextStyle(
fontFamily: 'Inter',
color: AppColors.textSecondary,
),
),
),
TextButton(
onPressed: () async {
try {
await provider.removeKey(key.id);
if (context.mounted) {
Navigator.pop(context);
}
} catch (e) {
if (context.mounted) {
Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(l10n.error),
backgroundColor: AppColors.error,
),
);
}
}
},
child: Text(
l10n.delete,
style: const TextStyle(
fontFamily: 'Inter',
fontWeight: FontWeight.w600,
color: AppColors.error,
),
),
),
],
),
);
}
/// Convierte hex a npub de forma segura, retornando el hex como fallback.
String _safeNpub(String hex) {
try {
return NostrUtils.hexToNpub(hex);
} catch (_) {
return hex;
}
}
String _getErrorMessage(P2PKErrorCode code, L10n l10n) {
switch (code) {
case P2PKErrorCode.maxKeysReached:
return l10n.p2pkErrorMaxKeysReached;
case P2PKErrorCode.invalidNsec:
return l10n.p2pkErrorInvalidNsec;
case P2PKErrorCode.keyAlreadyExists:
return l10n.p2pkErrorKeyAlreadyExists;
case P2PKErrorCode.keyNotFound:
return l10n.p2pkErrorKeyNotFound;
case P2PKErrorCode.cannotDeletePrimaryKey:
return l10n.p2pkErrorCannotDeletePrimary;
}
}
}
@@ -13,6 +13,7 @@ import '../../widgets/common/glass_card.dart';
import '../2_onboarding/backup_seed_screen.dart';
import 'mints_screen.dart';
import 'language_screen.dart';
import 'p2pk_keys_screen.dart';
/// Pantalla de configuración
class SettingsScreen extends StatefulWidget {
@@ -94,6 +95,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
subtitle: l10n.scanMintsWithSeed,
onTap: () => _showRecoverTokensDialog(context, settingsProvider),
),
_buildP2PKTile(l10n),
const SizedBox(height: AppDimensions.paddingLarge),
@@ -225,6 +227,100 @@ class _SettingsScreenState extends State<SettingsScreen> {
);
}
Widget _buildP2PKTile(L10n l10n) {
return GlassCard(
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => const P2PKKeysScreen(),
),
);
},
padding: const EdgeInsets.symmetric(
horizontal: AppDimensions.paddingMedium,
vertical: AppDimensions.paddingMedium,
),
child: Row(
children: [
Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(10),
),
child: const Icon(LucideIcons.keyRound, color: AppColors.textSecondary, size: 20),
),
const SizedBox(width: AppDimensions.paddingMedium),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Título con badge experimental
Row(
children: [
Text(
l10n.p2pkTitle,
style: const TextStyle(
fontFamily: 'Inter',
fontSize: 16,
fontWeight: FontWeight.w500,
color: Colors.white,
),
),
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: AppColors.error.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(4),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
LucideIcons.flaskConical,
color: AppColors.error,
size: 12,
),
const SizedBox(width: 4),
Text(
l10n.p2pkExperimentalShort,
style: const TextStyle(
fontFamily: 'Inter',
fontSize: 10,
fontWeight: FontWeight.w600,
color: AppColors.error,
),
),
],
),
),
],
),
// Descripción normal
Text(
l10n.p2pkSettingsDescription,
style: TextStyle(
fontFamily: 'Inter',
fontSize: 14,
color: AppColors.textSecondary.withValues(alpha: 0.7),
),
),
],
),
),
Icon(
LucideIcons.chevronRight,
color: AppColors.textSecondary.withValues(alpha: 0.5),
size: 20,
),
],
),
);
}
Widget _buildInfoTile({
required IconData icon,
required String title,