Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0952bda73b | ||
|
|
7da8181022 | ||
|
|
13de1456c8 | ||
|
|
b1777f32a2 |
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -487,6 +487,7 @@
|
||||
"cameraPermissionDenied": "カメラの許可が拒否されました",
|
||||
"paymentRequestNotSupported": "支払いリクエストはまだサポートされていません",
|
||||
"p2pkTitle": "P2PK鍵",
|
||||
"p2pkSettingsDescription": "ロックされたecashを受け取る",
|
||||
"p2pkExperimental": "P2PKは実験的機能です。注意してご使用ください。",
|
||||
"p2pkExperimentalShort": "実験的",
|
||||
"p2pkPrimaryKey": "プライマリ鍵",
|
||||
|
||||
@@ -487,6 +487,7 @@
|
||||
"cameraPermissionDenied": "카메라 권한이 거부되었습니다",
|
||||
"paymentRequestNotSupported": "결제 요청은 아직 지원되지 않습니다",
|
||||
"p2pkTitle": "P2PK 키",
|
||||
"p2pkSettingsDescription": "잠긴 ecash 받기",
|
||||
"p2pkExperimental": "P2PK는 실험적 기능입니다. 주의하여 사용하세요.",
|
||||
"p2pkExperimentalShort": "실험적",
|
||||
"p2pkPrimaryKey": "기본 키",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -487,6 +487,7 @@
|
||||
"cameraPermissionDenied": "Доступ к камере запрещён",
|
||||
"paymentRequestNotSupported": "Запросы на оплату пока не поддерживаются",
|
||||
"p2pkTitle": "Ключи P2PK",
|
||||
"p2pkSettingsDescription": "Получить заблокированный ecash",
|
||||
"p2pkExperimental": "P2PK экспериментальный. Используйте с осторожностью.",
|
||||
"p2pkExperimentalShort": "Экспериментальный",
|
||||
"p2pkPrimaryKey": "Основной ключ",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -487,6 +487,7 @@
|
||||
"cameraPermissionDenied": "相机权限被拒绝",
|
||||
"paymentRequestNotSupported": "付款请求尚不支持",
|
||||
"p2pkTitle": "P2PK密钥",
|
||||
"p2pkSettingsDescription": "接收锁定的ecash",
|
||||
"p2pkExperimental": "P2PK是实验性功能。请谨慎使用。",
|
||||
"p2pkExperimentalShort": "实验性",
|
||||
"p2pkPrimaryKey": "主密钥",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user