Compare commits
40
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d4e5e1ce70 | ||
|
|
9b3d72fae3 | ||
|
|
3e86de397d | ||
|
|
8bd95dd9a4 | ||
|
|
20c513b5fc | ||
|
|
aeb26158ef | ||
|
|
796f488c15 | ||
|
|
a64a09adeb | ||
|
|
905981c3c3 | ||
|
|
a4a1b929a0 | ||
|
|
c6209b9c9f | ||
|
|
73a4afe3c2 | ||
|
|
c650ec2159 | ||
|
|
72691dbafb | ||
|
|
bdafde7512 | ||
|
|
b9b24de611 | ||
|
|
10114445cd | ||
|
|
aef0eb414e | ||
|
|
9bf4aa1e81 | ||
|
|
ccbc143eb9 | ||
|
|
b33270a719 | ||
|
|
ae06d83cea | ||
|
|
9de89fe9c1 | ||
|
|
7ee12dc044 | ||
|
|
66abf3b3c2 | ||
|
|
9ec585d9a2 | ||
|
|
a25fb9d7c1 | ||
|
|
9e3622435b | ||
|
|
b6c5b42cf2 | ||
|
|
835f5205c7 | ||
|
|
0629c00c90 | ||
|
|
f49e6a9c59 | ||
|
|
a0821429dd | ||
|
|
2e0f360ff0 | ||
|
|
cbddc5aff0 | ||
|
|
9fee4c7ef4 | ||
|
|
7035e2ef00 | ||
|
|
989fd19dc8 | ||
|
|
5dff3bcf24 | ||
|
|
f30d6bac2e |
@@ -1,4 +1,7 @@
|
||||
# ElCaju 🥜
|
||||
## ElCaju 🥜
|
||||
|
||||
[](https://deepwiki.com/Forte11Cuba/elcaju)
|
||||
*Ask questions about this project using DeepWiki AI*
|
||||
|
||||
<p align="center">
|
||||
<img src="assets/img/elcajucubano.png" alt="ElCaju Logo" width="200"/>
|
||||
|
||||
@@ -2,6 +2,11 @@
|
||||
<!-- Permisos de red requeridos para conectar con mints Cashu -->
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
|
||||
<!-- Permiso de cámara para escanear QR codes -->
|
||||
<uses-permission android:name="android.permission.CAMERA"/>
|
||||
<!-- Cámara opcional: la app funciona sin ella (pegando desde portapapeles) -->
|
||||
<uses-feature android:name="android.hardware.camera" android:required="false"/>
|
||||
<uses-feature android:name="android.hardware.camera.autofocus" android:required="false"/>
|
||||
|
||||
<application
|
||||
android:label="ElCaju"
|
||||
|
||||
@@ -0,0 +1,318 @@
|
||||
import 'dart:convert';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:bech32/bech32.dart';
|
||||
|
||||
/// Tipo de input detectado
|
||||
enum LnInputType {
|
||||
bolt11Invoice, // lnbc..., lntb..., lnbcrt...
|
||||
lnurl, // lnurl1...
|
||||
lightningAddress, // user@domain.com
|
||||
unknown,
|
||||
}
|
||||
|
||||
/// Parámetros de LNURL-pay
|
||||
class LnurlPayParams {
|
||||
final String callback;
|
||||
final BigInt minSendable; // millisats
|
||||
final BigInt maxSendable; // millisats
|
||||
final String? metadata;
|
||||
final String? description;
|
||||
final String? domain;
|
||||
|
||||
LnurlPayParams({
|
||||
required this.callback,
|
||||
required this.minSendable,
|
||||
required this.maxSendable,
|
||||
this.metadata,
|
||||
this.description,
|
||||
this.domain,
|
||||
});
|
||||
|
||||
/// Monto mínimo en sats
|
||||
BigInt get minSats => minSendable ~/ BigInt.from(1000);
|
||||
|
||||
/// Monto máximo en sats
|
||||
BigInt get maxSats => maxSendable ~/ BigInt.from(1000);
|
||||
|
||||
/// Verifica si un monto (en sats) está dentro del rango permitido
|
||||
bool isAmountValid(BigInt amountSats) {
|
||||
final amountMsats = amountSats * BigInt.from(1000);
|
||||
return amountMsats >= minSendable && amountMsats <= maxSendable;
|
||||
}
|
||||
}
|
||||
|
||||
/// Resultado de obtener invoice desde LNURL
|
||||
class LnurlInvoiceResult {
|
||||
final String invoice;
|
||||
final String? successAction;
|
||||
|
||||
LnurlInvoiceResult({
|
||||
required this.invoice,
|
||||
this.successAction,
|
||||
});
|
||||
}
|
||||
|
||||
/// Servicio para resolver LNURL y Lightning Address
|
||||
class LnurlService {
|
||||
static const _timeout = Duration(seconds: 10);
|
||||
|
||||
/// Detecta el tipo de input
|
||||
static LnInputType detectType(String input) {
|
||||
final trimmed = input.trim();
|
||||
final lower = trimmed.toLowerCase();
|
||||
|
||||
// Invoice BOLT11
|
||||
if (lower.startsWith('lnbc') ||
|
||||
lower.startsWith('lntb') ||
|
||||
lower.startsWith('lnbcrt') ||
|
||||
lower.startsWith('lightning:lnbc') ||
|
||||
lower.startsWith('lightning:lntb') ||
|
||||
lower.startsWith('lightning:lnbcrt')) {
|
||||
return LnInputType.bolt11Invoice;
|
||||
}
|
||||
|
||||
// LNURL
|
||||
if (lower.startsWith('lnurl1') ||
|
||||
lower.startsWith('lightning:lnurl1')) {
|
||||
return LnInputType.lnurl;
|
||||
}
|
||||
|
||||
// Lightning Address (user@domain.com)
|
||||
if (_isLightningAddress(trimmed)) {
|
||||
return LnInputType.lightningAddress;
|
||||
}
|
||||
|
||||
return LnInputType.unknown;
|
||||
}
|
||||
|
||||
/// Verifica si es una Lightning Address válida
|
||||
static bool _isLightningAddress(String input) {
|
||||
// Formato: user@domain.com
|
||||
if (!input.contains('@')) return false;
|
||||
|
||||
final parts = input.split('@');
|
||||
if (parts.length != 2) return false;
|
||||
|
||||
final user = parts[0];
|
||||
final domain = parts[1];
|
||||
|
||||
// Validación básica
|
||||
if (user.isEmpty || domain.isEmpty) return false;
|
||||
if (!domain.contains('.')) return false;
|
||||
|
||||
// No debe tener espacios
|
||||
if (user.contains(' ') || domain.contains(' ')) return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Limpia el input removiendo prefijos
|
||||
static String cleanInput(String input) {
|
||||
var cleaned = input.trim();
|
||||
|
||||
// Remover prefijo lightning:
|
||||
if (cleaned.toLowerCase().startsWith('lightning:')) {
|
||||
cleaned = cleaned.substring(10);
|
||||
}
|
||||
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
/// Decodifica LNURL (bech32) a URL
|
||||
static String? decodeLnurl(String lnurl) {
|
||||
try {
|
||||
final cleaned = cleanInput(lnurl).toLowerCase();
|
||||
final decoded = const Bech32Codec().decode(cleaned);
|
||||
|
||||
if (decoded.hrp != 'lnurl') return null;
|
||||
|
||||
// Convertir de 5-bit a 8-bit
|
||||
final bytes = _convertBits(decoded.data, 5, 8, false);
|
||||
return utf8.decode(bytes);
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Convierte bits (usado para decodificar bech32)
|
||||
static List<int> _convertBits(List<int> data, int fromBits, int toBits, bool pad) {
|
||||
var acc = 0;
|
||||
var bits = 0;
|
||||
final result = <int>[];
|
||||
final maxv = (1 << toBits) - 1;
|
||||
|
||||
for (final value in data) {
|
||||
acc = (acc << fromBits) | value;
|
||||
bits += fromBits;
|
||||
while (bits >= toBits) {
|
||||
bits -= toBits;
|
||||
result.add((acc >> bits) & maxv);
|
||||
}
|
||||
}
|
||||
|
||||
if (pad && bits > 0) {
|
||||
result.add((acc << (toBits - bits)) & maxv);
|
||||
} else if (!pad && (bits >= fromBits || ((acc << (toBits - bits)) & maxv) != 0)) {
|
||||
throw FormatException('Invalid padding in bech32 conversion');
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// Resuelve LNURL a parámetros de pago
|
||||
static Future<LnurlPayParams> resolveLnurl(String lnurl) async {
|
||||
final url = decodeLnurl(lnurl);
|
||||
if (url == null) {
|
||||
throw Exception('LNURL inválido');
|
||||
}
|
||||
|
||||
return _fetchLnurlPayParams(url);
|
||||
}
|
||||
|
||||
/// Resuelve Lightning Address a parámetros de pago
|
||||
static Future<LnurlPayParams> resolveLightningAddress(String address) async {
|
||||
final cleaned = cleanInput(address);
|
||||
final parts = cleaned.split('@');
|
||||
|
||||
if (parts.length != 2) {
|
||||
throw Exception('Lightning Address inválida');
|
||||
}
|
||||
|
||||
final user = parts[0];
|
||||
final domain = parts[1];
|
||||
|
||||
// Construir URL .well-known/lnurlp
|
||||
final url = 'https://$domain/.well-known/lnurlp/$user';
|
||||
|
||||
return _fetchLnurlPayParams(url, domain: domain);
|
||||
}
|
||||
|
||||
/// Obtiene parámetros LNURL-pay desde URL
|
||||
static Future<LnurlPayParams> _fetchLnurlPayParams(String url, {String? domain}) async {
|
||||
try {
|
||||
final response = await http.get(
|
||||
Uri.parse(url),
|
||||
headers: {'Accept': 'application/json'},
|
||||
).timeout(_timeout);
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
throw Exception('Error HTTP: ${response.statusCode}');
|
||||
}
|
||||
|
||||
final json = jsonDecode(response.body) as Map<String, dynamic>;
|
||||
|
||||
// Verificar errores
|
||||
if (json.containsKey('status') && json['status'] == 'ERROR') {
|
||||
throw Exception(json['reason'] ?? 'Error desconocido');
|
||||
}
|
||||
|
||||
// Verificar que es LNURL-pay (tag = payRequest)
|
||||
final tag = json['tag'] as String?;
|
||||
if (tag != null && tag != 'payRequest') {
|
||||
throw Exception('No es LNURL-pay (tag: $tag)');
|
||||
}
|
||||
|
||||
// Extraer parámetros
|
||||
final callback = json['callback'] as String?;
|
||||
if (callback == null || callback.isEmpty) {
|
||||
throw Exception('Respuesta LNURL-pay inválida: falta callback');
|
||||
}
|
||||
|
||||
final minRaw = json['minSendable'] as num?;
|
||||
final maxRaw = json['maxSendable'] as num?;
|
||||
if (minRaw == null || maxRaw == null) {
|
||||
throw Exception('Respuesta LNURL-pay inválida: falta minSendable/maxSendable');
|
||||
}
|
||||
final minSendable = BigInt.from(minRaw.toInt());
|
||||
final maxSendable = BigInt.from(maxRaw.toInt());
|
||||
final metadata = json['metadata'] as String?;
|
||||
|
||||
// Extraer descripción del metadata
|
||||
String? description;
|
||||
if (metadata != null) {
|
||||
try {
|
||||
final metadataList = jsonDecode(metadata) as List;
|
||||
for (final item in metadataList) {
|
||||
if (item is List && item.length >= 2 && item[0] == 'text/plain') {
|
||||
description = item[1] as String;
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
// Extraer dominio del callback si no se proporcionó
|
||||
final callbackUri = Uri.parse(callback);
|
||||
final effectiveDomain = domain ?? callbackUri.host;
|
||||
|
||||
return LnurlPayParams(
|
||||
callback: callback,
|
||||
minSendable: minSendable,
|
||||
maxSendable: maxSendable,
|
||||
metadata: metadata,
|
||||
description: description,
|
||||
domain: effectiveDomain,
|
||||
);
|
||||
} catch (e) {
|
||||
if (e is Exception) rethrow;
|
||||
throw Exception('Error resolviendo LNURL: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Obtiene invoice BOLT11 desde callback LNURL-pay
|
||||
static Future<LnurlInvoiceResult> fetchInvoice(
|
||||
String callback,
|
||||
BigInt amountSats,
|
||||
) async {
|
||||
try {
|
||||
// Convertir a millisats
|
||||
final amountMsats = amountSats * BigInt.from(1000);
|
||||
|
||||
// Construir URL con parámetros
|
||||
final uri = Uri.parse(callback);
|
||||
final queryParams = Map<String, String>.from(uri.queryParameters);
|
||||
queryParams['amount'] = amountMsats.toString();
|
||||
|
||||
final requestUri = uri.replace(queryParameters: queryParams);
|
||||
|
||||
final response = await http.get(
|
||||
requestUri,
|
||||
headers: {'Accept': 'application/json'},
|
||||
).timeout(_timeout);
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
throw Exception('Error HTTP: ${response.statusCode}');
|
||||
}
|
||||
|
||||
final json = jsonDecode(response.body) as Map<String, dynamic>;
|
||||
|
||||
// Verificar errores
|
||||
if (json.containsKey('status') && json['status'] == 'ERROR') {
|
||||
throw Exception(json['reason'] ?? 'Error obteniendo invoice');
|
||||
}
|
||||
|
||||
// Extraer invoice
|
||||
final pr = json['pr'] as String?;
|
||||
if (pr == null || pr.isEmpty) {
|
||||
throw Exception('No se recibió invoice');
|
||||
}
|
||||
|
||||
// Success action (opcional)
|
||||
String? successAction;
|
||||
if (json.containsKey('successAction')) {
|
||||
final sa = json['successAction'] as Map<String, dynamic>?;
|
||||
if (sa != null && sa['message'] != null) {
|
||||
successAction = sa['message'] as String;
|
||||
}
|
||||
}
|
||||
|
||||
return LnurlInvoiceResult(
|
||||
invoice: pr,
|
||||
successAction: successAction,
|
||||
);
|
||||
} catch (e) {
|
||||
if (e is Exception) rethrow;
|
||||
throw Exception('Error obteniendo invoice: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import 'dart:convert';
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
/// Servicio para obtener precios de Bitcoin usando Yadio API
|
||||
class PriceService {
|
||||
static const _baseUrl = 'https://api.yadio.io';
|
||||
static const _timeout = Duration(seconds: 10);
|
||||
|
||||
/// Obtiene el precio de BTC en una moneda fiat (USD, EUR, etc.)
|
||||
/// Retorna el precio de 1 BTC en la moneda especificada
|
||||
static Future<double> getBtcPrice(String currency) async {
|
||||
try {
|
||||
// /rate/{quote}/{base} - retorna cuánto del quote por 1 base
|
||||
// /rate/USD/BTC retorna cuántos USD por 1 BTC
|
||||
final response = await http.get(
|
||||
Uri.parse('$_baseUrl/rate/${currency.toUpperCase()}/BTC'),
|
||||
).timeout(_timeout);
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
throw Exception('Error HTTP: ${response.statusCode}');
|
||||
}
|
||||
|
||||
final json = jsonDecode(response.body) as Map<String, dynamic>;
|
||||
final rate = json['rate'];
|
||||
|
||||
if (rate == null) {
|
||||
throw Exception('Precio no disponible');
|
||||
}
|
||||
|
||||
return (rate as num).toDouble();
|
||||
} catch (e) {
|
||||
throw Exception('Error obteniendo precio BTC: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Convierte una cantidad de una moneda a otra
|
||||
/// Ejemplo: convert(2.50, 'USD', 'BTC') -> 0.0000244
|
||||
static Future<double> convert(double amount, String from, String to) async {
|
||||
try {
|
||||
final response = await http.get(
|
||||
Uri.parse('$_baseUrl/convert/$amount/${from.toUpperCase()}/${to.toUpperCase()}'),
|
||||
).timeout(_timeout);
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
throw Exception('Error HTTP: ${response.statusCode}');
|
||||
}
|
||||
|
||||
final json = jsonDecode(response.body) as Map<String, dynamic>;
|
||||
final result = json['result'];
|
||||
|
||||
if (result == null) {
|
||||
throw Exception('Conversión no disponible');
|
||||
}
|
||||
|
||||
return (result as num).toDouble();
|
||||
} catch (e) {
|
||||
throw Exception('Error en conversión: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Convierte cantidad en unidad fiat (centavos) a sats
|
||||
/// Ejemplo: 250 cents USD -> X sats
|
||||
static Future<BigInt> fiatCentsToSats(BigInt cents, String fiatCurrency) async {
|
||||
// Convertir centavos a unidad mayor (ej: 250 cents -> 2.50 USD)
|
||||
final fiatAmount = cents.toDouble() / 100;
|
||||
|
||||
// Obtener precio BTC en fiat
|
||||
final btcPrice = await getBtcPrice(fiatCurrency);
|
||||
|
||||
// Calcular BTC y luego sats
|
||||
final btcAmount = fiatAmount / btcPrice;
|
||||
final sats = (btcAmount * 100000000).round();
|
||||
|
||||
return BigInt.from(sats);
|
||||
}
|
||||
|
||||
/// Convierte sats a cantidad en unidad fiat (centavos)
|
||||
static Future<BigInt> satsToFiatCents(BigInt sats, String fiatCurrency) async {
|
||||
// Obtener precio BTC en fiat
|
||||
final btcPrice = await getBtcPrice(fiatCurrency);
|
||||
|
||||
// Calcular fiat amount
|
||||
final btcAmount = sats.toDouble() / 100000000;
|
||||
final fiatAmount = btcAmount * btcPrice;
|
||||
|
||||
// Retornar en centavos
|
||||
return BigInt.from((fiatAmount * 100).round());
|
||||
}
|
||||
}
|
||||
@@ -111,6 +111,42 @@ class UnitFormatter {
|
||||
}
|
||||
}
|
||||
|
||||
/// Formatea dígitos crudos para display con decimales fijos (estilo POS).
|
||||
/// Para USD/EUR (2 decimales): "1" → "0.01", "15" → "0.15", "150" → "1.50"
|
||||
/// Para SAT (0 decimales): "1" → "1", "15" → "15"
|
||||
static String formatRawDigitsForDisplay(String rawDigits, String unit) {
|
||||
final multiplier = getMultiplier(unit);
|
||||
|
||||
if (rawDigits.isEmpty) {
|
||||
return multiplier == 100 ? '0.00' : '0';
|
||||
}
|
||||
|
||||
if (multiplier == 100) {
|
||||
// USD/EUR: 2 decimales fijos
|
||||
final digits = rawDigits.padLeft(3, '0');
|
||||
final intPart = digits.substring(0, digits.length - 2);
|
||||
final decPart = digits.substring(digits.length - 2);
|
||||
|
||||
// Formatear parte entera con separador de miles
|
||||
final intValue = int.tryParse(intPart) ?? 0;
|
||||
final formattedInt = NumberFormat('#,##0').format(intValue);
|
||||
|
||||
return '$formattedInt.$decPart';
|
||||
} else {
|
||||
// SAT: sin decimales, con separador de miles
|
||||
final value = int.tryParse(rawDigits) ?? 0;
|
||||
return NumberFormat('#,###').format(value);
|
||||
}
|
||||
}
|
||||
|
||||
/// Convierte dígitos crudos del numpad a BigInt (centavos para USD/EUR, sats para SAT).
|
||||
/// "150" USD → BigInt(150) (ya son centavos)
|
||||
/// "150" SAT → BigInt(150)
|
||||
static BigInt parseRawDigits(String rawDigits, String unit) {
|
||||
if (rawDigits.isEmpty) return BigInt.zero;
|
||||
return BigInt.tryParse(rawDigits) ?? BigInt.zero;
|
||||
}
|
||||
|
||||
/// Obtiene el nombre del host de un mint URL.
|
||||
/// Ejemplo: 'https://mint.cubabitcoin.org' → 'cubabitcoin.org'
|
||||
static String getMintDisplayName(String mintUrl) {
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
// Parser para detectar el tipo de dato entrante (QR, clipboard, etc.)
|
||||
// Soporta: tokens Cashu (A/B), invoices Lightning, URLs de mint, payment requests
|
||||
|
||||
/// Modo de escaneo
|
||||
enum ScanMode {
|
||||
any, // Desde HomeScreen - detecta y navega automáticamente
|
||||
cashuOnly, // Desde ReceiveScreen - solo acepta tokens Cashu
|
||||
invoiceOnly, // Desde MeltScreen - solo acepta invoices Lightning
|
||||
}
|
||||
|
||||
/// Tipo de dato detectado
|
||||
enum IncomingDataType {
|
||||
cashuToken, // cashuA... / cashuB...
|
||||
lightningInvoice, // lnbc... / lntb... / lnbcrt...
|
||||
mintUrl, // https://...
|
||||
paymentRequest, // creqA... (post-MVP, Cashu payment request)
|
||||
unknown,
|
||||
}
|
||||
|
||||
/// Información de un token parseado (para preview)
|
||||
class TokenInfo {
|
||||
final BigInt amount;
|
||||
final String mintUrl;
|
||||
final String? unit;
|
||||
final String? memo;
|
||||
|
||||
TokenInfo({
|
||||
required this.amount,
|
||||
required this.mintUrl,
|
||||
this.unit,
|
||||
this.memo,
|
||||
});
|
||||
}
|
||||
|
||||
/// Resultado del parsing
|
||||
class ParsedData {
|
||||
final IncomingDataType type;
|
||||
final String raw;
|
||||
final TokenInfo? tokenInfo;
|
||||
final String? invoiceBolt11;
|
||||
final String? mintUrl;
|
||||
|
||||
ParsedData({
|
||||
required this.type,
|
||||
required this.raw,
|
||||
this.tokenInfo,
|
||||
this.invoiceBolt11,
|
||||
this.mintUrl,
|
||||
});
|
||||
|
||||
/// True si el tipo es conocido y puede ser procesado
|
||||
bool get isValid => type != IncomingDataType.unknown;
|
||||
}
|
||||
|
||||
/// Parser estático para detectar tipo de dato
|
||||
class IncomingDataParser {
|
||||
/// Detecta el tipo de dato y extrae información relevante
|
||||
static ParsedData parse(String data) {
|
||||
final trimmed = data.trim();
|
||||
final lower = trimmed.toLowerCase();
|
||||
|
||||
// Token Cashu (cashuA... o cashuB...)
|
||||
if (lower.startsWith('cashua') || lower.startsWith('cashub')) {
|
||||
return ParsedData(
|
||||
type: IncomingDataType.cashuToken,
|
||||
raw: trimmed,
|
||||
);
|
||||
}
|
||||
|
||||
// UR encoded token (ur:cashu/...)
|
||||
if (lower.startsWith('ur:cashu')) {
|
||||
return ParsedData(
|
||||
type: IncomingDataType.cashuToken,
|
||||
raw: trimmed,
|
||||
);
|
||||
}
|
||||
|
||||
// Invoice Lightning (lnbcrt..., lnbc..., lntb...)
|
||||
// Nota: lnbcrt debe ir primero porque lnbc es prefijo de lnbcrt
|
||||
if (lower.startsWith('lnbcrt') ||
|
||||
lower.startsWith('lnbc') ||
|
||||
lower.startsWith('lntb') ||
|
||||
lower.startsWith('lightning:')) {
|
||||
// Remover prefijo lightning: si existe
|
||||
final invoice = lower.startsWith('lightning:')
|
||||
? trimmed.substring(10)
|
||||
: trimmed;
|
||||
return ParsedData(
|
||||
type: IncomingDataType.lightningInvoice,
|
||||
raw: trimmed,
|
||||
invoiceBolt11: invoice,
|
||||
);
|
||||
}
|
||||
|
||||
// Payment Request (creqA...)
|
||||
if (lower.startsWith('creqa')) {
|
||||
return ParsedData(
|
||||
type: IncomingDataType.paymentRequest,
|
||||
raw: trimmed,
|
||||
);
|
||||
}
|
||||
|
||||
// URL potencial de mint (https://...)
|
||||
if (lower.startsWith('https://')) {
|
||||
try {
|
||||
final uri = Uri.parse(trimmed);
|
||||
final path = uri.path.toLowerCase();
|
||||
|
||||
// Solo clasificar como mint si tiene endpoints conocidos de Cashu
|
||||
// o es una URL limpia (sin path o solo /)
|
||||
final isMintEndpoint = path.contains('/v1/info') ||
|
||||
path.contains('/v1/keys') ||
|
||||
path.contains('/v1/mint') ||
|
||||
path.contains('/v1/melt');
|
||||
final isCleanUrl = path.isEmpty || path == '/';
|
||||
|
||||
if (isMintEndpoint || isCleanUrl) {
|
||||
// Normalizar: quitar trailing slash y paths de API
|
||||
String mintUrl = '${uri.scheme}://${uri.host}';
|
||||
if (uri.port != 443) mintUrl += ':${uri.port}';
|
||||
|
||||
return ParsedData(
|
||||
type: IncomingDataType.mintUrl,
|
||||
raw: trimmed,
|
||||
mintUrl: mintUrl,
|
||||
);
|
||||
}
|
||||
// URLs con paths no-mint (ej: /about, /login) → unknown
|
||||
} catch (_) {
|
||||
// URL malformada → unknown
|
||||
}
|
||||
}
|
||||
|
||||
// Tipo desconocido
|
||||
return ParsedData(
|
||||
type: IncomingDataType.unknown,
|
||||
raw: trimmed,
|
||||
);
|
||||
}
|
||||
|
||||
/// Verifica si el dato es un fragmento UR
|
||||
/// Detectamos cualquier UR (ur:bytes/, ur:cashu/, etc.)
|
||||
/// cashu.me y minibits usan ur:bytes/ para tokens Cashu
|
||||
static bool isUrFragment(String data) {
|
||||
return data.toLowerCase().startsWith('ur:');
|
||||
}
|
||||
|
||||
/// Extrae información del header UR (índice y total)
|
||||
/// Formato: ur:cashu/1-5/payload...
|
||||
/// Retorna (currentIndex, totalFragments) o null si no es válido
|
||||
static (int, int)? parseUrHeader(String data) {
|
||||
final lower = data.toLowerCase();
|
||||
if (!lower.startsWith('ur:')) return null;
|
||||
|
||||
// Buscar el patrón X-Y después del tipo
|
||||
final parts = data.split('/');
|
||||
if (parts.length < 2) return null;
|
||||
|
||||
// El segundo segmento debería ser "index-total" o solo el index si es único
|
||||
final indexPart = parts[1];
|
||||
|
||||
// Verificar si es formato multipart (X-Y)
|
||||
if (indexPart.contains('-')) {
|
||||
final indices = indexPart.split('-');
|
||||
if (indices.length == 2) {
|
||||
final current = int.tryParse(indices[0]);
|
||||
final total = int.tryParse(indices[1]);
|
||||
if (current != null && total != null) {
|
||||
return (current, total);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Verifica si un dato es válido para un modo específico
|
||||
static bool isValidForMode(ParsedData data, ScanMode mode) {
|
||||
switch (mode) {
|
||||
case ScanMode.any:
|
||||
return data.type != IncomingDataType.unknown;
|
||||
case ScanMode.cashuOnly:
|
||||
return data.type == IncomingDataType.cashuToken;
|
||||
case ScanMode.invoiceOnly:
|
||||
return data.type == IncomingDataType.lightningInvoice;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
/// Modelo de datos para tokens Cashu pendientes de reclamar.
|
||||
/// Almacena tokens que el usuario quiere guardar para reclamar después.
|
||||
class PendingToken {
|
||||
/// UUID único del token pendiente
|
||||
final String id;
|
||||
|
||||
/// Token codificado completo (cashuA... o cashuB...)
|
||||
final String encoded;
|
||||
|
||||
/// Monto del token en la unidad base
|
||||
final BigInt amount;
|
||||
|
||||
/// URL del mint que emitió el token
|
||||
final String mintUrl;
|
||||
|
||||
/// Unidad detectada del token (sat, usd, eur, etc.)
|
||||
final String? unit;
|
||||
|
||||
/// Fecha en que se guardó el token
|
||||
final DateTime savedAt;
|
||||
|
||||
/// Memo opcional del token
|
||||
final String? memo;
|
||||
|
||||
/// Número de intentos fallidos de reclamo
|
||||
final int retryCount;
|
||||
|
||||
/// Último error al intentar reclamar
|
||||
final String? lastError;
|
||||
|
||||
/// Fecha del último intento de reclamo
|
||||
final DateTime? lastAttempt;
|
||||
|
||||
PendingToken({
|
||||
required this.id,
|
||||
required this.encoded,
|
||||
required this.amount,
|
||||
required this.mintUrl,
|
||||
this.unit,
|
||||
required this.savedAt,
|
||||
this.memo,
|
||||
this.retryCount = 0,
|
||||
this.lastError,
|
||||
this.lastAttempt,
|
||||
});
|
||||
|
||||
/// Verifica si el token ha expirado (30 días)
|
||||
bool get isExpired =>
|
||||
savedAt.add(const Duration(days: 30)).isBefore(DateTime.now());
|
||||
|
||||
/// Días restantes antes de expirar
|
||||
int get daysRemaining {
|
||||
final expiresAt = savedAt.add(const Duration(days: 30));
|
||||
final remaining = expiresAt.difference(DateTime.now()).inDays;
|
||||
return remaining < 0 ? 0 : remaining;
|
||||
}
|
||||
|
||||
/// Verifica si el token tiene errores de reclamo
|
||||
bool get hasError => lastError != null && lastError!.isNotEmpty;
|
||||
|
||||
/// Crea una copia con campos actualizados
|
||||
PendingToken copyWith({
|
||||
String? id,
|
||||
String? encoded,
|
||||
BigInt? amount,
|
||||
String? mintUrl,
|
||||
String? unit,
|
||||
DateTime? savedAt,
|
||||
String? memo,
|
||||
int? retryCount,
|
||||
String? lastError,
|
||||
DateTime? lastAttempt,
|
||||
}) {
|
||||
return PendingToken(
|
||||
id: id ?? this.id,
|
||||
encoded: encoded ?? this.encoded,
|
||||
amount: amount ?? this.amount,
|
||||
mintUrl: mintUrl ?? this.mintUrl,
|
||||
unit: unit ?? this.unit,
|
||||
savedAt: savedAt ?? this.savedAt,
|
||||
memo: memo ?? this.memo,
|
||||
retryCount: retryCount ?? this.retryCount,
|
||||
lastError: lastError ?? this.lastError,
|
||||
lastAttempt: lastAttempt ?? this.lastAttempt,
|
||||
);
|
||||
}
|
||||
|
||||
/// Convierte a Map para persistencia SQLite
|
||||
Map<String, dynamic> toMap() {
|
||||
return {
|
||||
'id': id,
|
||||
'encoded': encoded,
|
||||
'amount': amount.toString(),
|
||||
'mint_url': mintUrl,
|
||||
'unit': unit,
|
||||
'saved_at': savedAt.millisecondsSinceEpoch,
|
||||
'memo': memo,
|
||||
'retry_count': retryCount,
|
||||
'last_error': lastError,
|
||||
'last_attempt': lastAttempt?.millisecondsSinceEpoch,
|
||||
};
|
||||
}
|
||||
|
||||
/// Crea desde Map de SQLite
|
||||
factory PendingToken.fromMap(Map<String, dynamic> map) {
|
||||
return PendingToken(
|
||||
id: map['id'] as String,
|
||||
encoded: map['encoded'] as String,
|
||||
amount: BigInt.parse(map['amount'] as String),
|
||||
mintUrl: map['mint_url'] as String,
|
||||
unit: map['unit'] as String?,
|
||||
savedAt: DateTime.fromMillisecondsSinceEpoch(map['saved_at'] as int),
|
||||
memo: map['memo'] as String?,
|
||||
retryCount: map['retry_count'] as int? ?? 0,
|
||||
lastError: map['last_error'] as String?,
|
||||
lastAttempt: map['last_attempt'] != null
|
||||
? DateTime.fromMillisecondsSinceEpoch(map['last_attempt'] as int)
|
||||
: null,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'PendingToken(id: $id, amount: $amount, unit: $unit, mintUrl: $mintUrl, daysRemaining: $daysRemaining)';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
|
||||
import 'pending_token.dart';
|
||||
|
||||
/// Storage para tokens Cashu pendientes de reclamar.
|
||||
/// Usa SQLite para persistencia robusta con soporte para consultas complejas.
|
||||
///
|
||||
/// Características:
|
||||
/// - Límite máximo de 50 tokens
|
||||
/// - Expiración automática a 30 días
|
||||
/// - Cache en memoria para acceso rápido
|
||||
/// - Singleton para acceso global
|
||||
class PendingTokenStorage {
|
||||
static const _dbName = 'pending_tokens.db';
|
||||
static const _tableName = 'pending_tokens';
|
||||
static const _maxTokens = 50;
|
||||
static const _expirationDays = 30;
|
||||
|
||||
Database? _db;
|
||||
final Map<String, PendingToken> _cache = {};
|
||||
bool _isInitialized = false;
|
||||
|
||||
/// Stream controller para notificar cambios
|
||||
StreamController<void> _changesController = StreamController<void>.broadcast();
|
||||
|
||||
/// Stream de cambios para que la UI pueda reaccionar
|
||||
Stream<void> get changes => _changesController.stream;
|
||||
|
||||
/// Singleton
|
||||
static final PendingTokenStorage _instance = PendingTokenStorage._internal();
|
||||
factory PendingTokenStorage() => _instance;
|
||||
PendingTokenStorage._internal();
|
||||
|
||||
/// Verifica si está inicializado
|
||||
bool get isInitialized => _isInitialized;
|
||||
|
||||
/// Cantidad de tokens pendientes
|
||||
int get count => _cache.length;
|
||||
|
||||
/// Verifica si hay tokens pendientes
|
||||
bool get hasPendingTokens => _cache.isNotEmpty;
|
||||
|
||||
/// Inicializa el storage. Llamar antes de usar.
|
||||
Future<void> init() async {
|
||||
if (_isInitialized) return;
|
||||
|
||||
// Inicializar FFI para Linux/Windows
|
||||
if (Platform.isLinux || Platform.isWindows) {
|
||||
sqfliteFfiInit();
|
||||
databaseFactory = databaseFactoryFfi;
|
||||
}
|
||||
|
||||
final dir = await getApplicationDocumentsDirectory();
|
||||
final dbPath = '${dir.path}/$_dbName';
|
||||
|
||||
_db = await openDatabase(
|
||||
dbPath,
|
||||
version: 1,
|
||||
onCreate: (db, version) async {
|
||||
await db.execute('''
|
||||
CREATE TABLE $_tableName (
|
||||
id TEXT PRIMARY KEY,
|
||||
encoded TEXT NOT NULL,
|
||||
amount TEXT NOT NULL,
|
||||
mint_url TEXT NOT NULL,
|
||||
unit TEXT,
|
||||
saved_at INTEGER NOT NULL,
|
||||
memo TEXT,
|
||||
retry_count INTEGER DEFAULT 0,
|
||||
last_error TEXT,
|
||||
last_attempt INTEGER
|
||||
)
|
||||
''');
|
||||
|
||||
// Índices para consultas eficientes
|
||||
await db.execute(
|
||||
'CREATE INDEX idx_mint_url ON $_tableName(mint_url)',
|
||||
);
|
||||
await db.execute(
|
||||
'CREATE INDEX idx_saved_at ON $_tableName(saved_at)',
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
await _loadFromDb();
|
||||
await _cleanExpired();
|
||||
|
||||
_isInitialized = true;
|
||||
debugPrint('PendingTokenStorage inicializado con ${_cache.length} tokens');
|
||||
}
|
||||
|
||||
/// Carga todos los tokens desde la base de datos al cache
|
||||
Future<void> _loadFromDb() async {
|
||||
if (_db == null) return;
|
||||
|
||||
final results = await _db!.query(
|
||||
_tableName,
|
||||
orderBy: 'saved_at DESC',
|
||||
);
|
||||
|
||||
_cache.clear();
|
||||
for (final row in results) {
|
||||
final token = PendingToken.fromMap(row);
|
||||
_cache[token.id] = token;
|
||||
}
|
||||
}
|
||||
|
||||
/// Agrega un token pendiente.
|
||||
/// Retorna el PendingToken creado o null si se alcanzó el límite.
|
||||
Future<PendingToken?> add({
|
||||
required String id,
|
||||
required String encoded,
|
||||
required BigInt amount,
|
||||
required String mintUrl,
|
||||
String? unit,
|
||||
String? memo,
|
||||
}) async {
|
||||
if (_db == null) {
|
||||
throw StateError('PendingTokenStorage no inicializado');
|
||||
}
|
||||
|
||||
// Verificar límite
|
||||
if (_cache.length >= _maxTokens) {
|
||||
debugPrint('Límite de $_maxTokens tokens pendientes alcanzado');
|
||||
return null;
|
||||
}
|
||||
|
||||
// Verificar si ya existe (mismo encoded)
|
||||
for (final existing in _cache.values) {
|
||||
if (existing.encoded == encoded) {
|
||||
debugPrint('Token ya existe en pending: ${existing.id}');
|
||||
return existing;
|
||||
}
|
||||
}
|
||||
|
||||
final token = PendingToken(
|
||||
id: id,
|
||||
encoded: encoded,
|
||||
amount: amount,
|
||||
mintUrl: mintUrl,
|
||||
unit: unit,
|
||||
savedAt: DateTime.now(),
|
||||
memo: memo,
|
||||
);
|
||||
|
||||
await _db!.insert(_tableName, token.toMap());
|
||||
_cache[id] = token;
|
||||
_notifyChanges();
|
||||
|
||||
debugPrint('Token pendiente guardado: $id');
|
||||
return token;
|
||||
}
|
||||
|
||||
/// Obtiene un token por ID
|
||||
PendingToken? get(String id) {
|
||||
return _cache[id];
|
||||
}
|
||||
|
||||
/// Lista todos los tokens pendientes (ordenados por fecha, más reciente primero)
|
||||
List<PendingToken> listAll() {
|
||||
final tokens = _cache.values.toList();
|
||||
tokens.sort((a, b) => b.savedAt.compareTo(a.savedAt));
|
||||
return tokens;
|
||||
}
|
||||
|
||||
/// Lista solo tokens válidos (no expirados)
|
||||
List<PendingToken> listValid() {
|
||||
return listAll().where((t) => !t.isExpired).toList();
|
||||
}
|
||||
|
||||
/// Lista tokens por mint
|
||||
List<PendingToken> listByMint(String mintUrl) {
|
||||
return listAll().where((t) => t.mintUrl == mintUrl).toList();
|
||||
}
|
||||
|
||||
/// Actualiza un token (para reintentos, errores, etc.)
|
||||
Future<void> update(PendingToken token) async {
|
||||
if (_db == null) return;
|
||||
if (!_cache.containsKey(token.id)) return;
|
||||
|
||||
await _db!.update(
|
||||
_tableName,
|
||||
token.toMap(),
|
||||
where: 'id = ?',
|
||||
whereArgs: [token.id],
|
||||
);
|
||||
|
||||
_cache[token.id] = token;
|
||||
_notifyChanges();
|
||||
}
|
||||
|
||||
/// Registra un intento fallido de reclamo
|
||||
Future<void> recordFailedAttempt(String id, String error) async {
|
||||
final token = _cache[id];
|
||||
if (token == null) return;
|
||||
|
||||
final updated = token.copyWith(
|
||||
retryCount: token.retryCount + 1,
|
||||
lastError: error,
|
||||
lastAttempt: DateTime.now(),
|
||||
);
|
||||
|
||||
await update(updated);
|
||||
}
|
||||
|
||||
/// Elimina un token por ID
|
||||
Future<void> remove(String id) async {
|
||||
if (_db == null) return;
|
||||
|
||||
await _db!.delete(
|
||||
_tableName,
|
||||
where: 'id = ?',
|
||||
whereArgs: [id],
|
||||
);
|
||||
|
||||
_cache.remove(id);
|
||||
_notifyChanges();
|
||||
|
||||
debugPrint('Token pendiente eliminado: $id');
|
||||
}
|
||||
|
||||
/// Limpia tokens expirados
|
||||
Future<int> cleanExpired() async {
|
||||
return await _cleanExpired();
|
||||
}
|
||||
|
||||
Future<int> _cleanExpired() async {
|
||||
if (_db == null) return 0;
|
||||
|
||||
final cutoff = DateTime.now()
|
||||
.subtract(const Duration(days: _expirationDays))
|
||||
.millisecondsSinceEpoch;
|
||||
|
||||
final deleted = await _db!.delete(
|
||||
_tableName,
|
||||
where: 'saved_at < ?',
|
||||
whereArgs: [cutoff],
|
||||
);
|
||||
|
||||
if (deleted > 0) {
|
||||
await _loadFromDb();
|
||||
_notifyChanges();
|
||||
debugPrint('Limpiados $deleted tokens expirados');
|
||||
}
|
||||
|
||||
return deleted;
|
||||
}
|
||||
|
||||
/// Elimina todos los tokens pendientes
|
||||
Future<void> clear() async {
|
||||
if (_db == null) return;
|
||||
|
||||
await _db!.delete(_tableName);
|
||||
_cache.clear();
|
||||
_notifyChanges();
|
||||
|
||||
debugPrint('Todos los tokens pendientes eliminados');
|
||||
}
|
||||
|
||||
/// Notifica a los listeners de cambios
|
||||
void _notifyChanges() {
|
||||
if (!_changesController.isClosed) {
|
||||
_changesController.add(null);
|
||||
}
|
||||
}
|
||||
|
||||
/// Cierra la base de datos (llamar al cerrar la app si es necesario)
|
||||
Future<void> close() async {
|
||||
await _db?.close();
|
||||
_db = null;
|
||||
_isInitialized = false;
|
||||
await _changesController.close();
|
||||
// Recrear el controller para permitir re-inicialización del singleton
|
||||
_changesController = StreamController<void>.broadcast();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,484 @@
|
||||
{
|
||||
"@@locale": "de",
|
||||
|
||||
"appName": "ElCaju",
|
||||
"appTagline": "Deine private ecash Wallet",
|
||||
"loadingMessage1": "Verschlüsselung deiner Münzen...",
|
||||
"loadingMessage2": "Vorbereitung deiner E-Tokens...",
|
||||
"loadingMessage3": "Verbindung zum Mint...",
|
||||
"loadingMessage4": "Privatsphäre standardmäßig.",
|
||||
"loadingMessage5": "Blind signierte Tokens...",
|
||||
"loadingMessage6": "Go full Calle...",
|
||||
"loadingMessage7": "Cashu + Bitchat = Privatsphäre + Freiheit",
|
||||
"aboutTagline": "Privatsphäre ohne Grenzen.",
|
||||
|
||||
"welcomeTitle": "Willkommen bei ElCaju",
|
||||
"welcomeSubtitle": "Cashu für die Welt. Made in Cuba.",
|
||||
|
||||
"createWallet": "Neue Wallet erstellen",
|
||||
"restoreWallet": "Wallet wiederherstellen",
|
||||
|
||||
"createWalletTitle": "Wallet erstellen",
|
||||
"creatingWallet": "Erstelle deine Wallet...",
|
||||
"generatingSeed": "Sichere Generierung deiner Seed-Phrase",
|
||||
"createWalletDescription": "Eine 12-Wort Seed-Phrase wird generiert.\nBewahre sie an einem sicheren Ort auf.",
|
||||
"generateWallet": "Wallet generieren",
|
||||
|
||||
"walletCreated": "Wallet erstellt!",
|
||||
"walletCreatedDescription": "Deine Wallet ist bereit. Wir empfehlen, jetzt ein Backup deiner Seed-Phrase zu erstellen.",
|
||||
"backupWarning": "Ohne Backup verlierst du den Zugang zu deinen Mitteln, wenn du das Gerät verlierst.",
|
||||
"backupNow": "Jetzt sichern",
|
||||
"backupLater": "Später machen",
|
||||
|
||||
"backupTitle": "Backup",
|
||||
"seedPhraseTitle": "Deine Seed-Phrase",
|
||||
"seedPhraseDescription": "Speichere diese 12 Wörter in der richtigen Reihenfolge. Sie sind der einzige Weg, deine Wallet wiederherzustellen.",
|
||||
"revealSeedPhrase": "Seed-Phrase anzeigen",
|
||||
"tapToReveal": "Tippe auf den Button, um\ndeine Seed-Phrase anzuzeigen",
|
||||
"copyToClipboard": "In Zwischenablage kopieren",
|
||||
"seedCopied": "Phrase in Zwischenablage kopiert",
|
||||
"neverShareSeed": "Teile deine Seed-Phrase niemals mit anderen.",
|
||||
"confirmBackup": "Ich habe meine Seed-Phrase an einem sicheren Ort gespeichert",
|
||||
"continue_": "Weiter",
|
||||
|
||||
"restoreTitle": "Wallet wiederherstellen",
|
||||
"enterSeedPhrase": "Gib deine Seed-Phrase ein",
|
||||
"enterSeedDescription": "Gib die 12 oder 24 Wörter durch Leerzeichen getrennt ein.",
|
||||
"seedPlaceholder": "wort1 wort2 wort3 ...",
|
||||
"wordCount": "{count} Wörter",
|
||||
"@wordCount": {
|
||||
"placeholders": {
|
||||
"count": { "type": "int" }
|
||||
}
|
||||
},
|
||||
"needWords": "(du brauchst 12 oder 24)",
|
||||
"restoreError": "Wiederherstellungsfehler: {error}",
|
||||
"@restoreError": {
|
||||
"placeholders": {
|
||||
"error": { "type": "String" }
|
||||
}
|
||||
},
|
||||
|
||||
"homeTitle": "Start",
|
||||
"receive": "Empfangen",
|
||||
"send": "Senden",
|
||||
"sendAction": "Senden ↗",
|
||||
"receiveAction": "↘ Empfangen",
|
||||
"deposit": "Einzahlen",
|
||||
"withdraw": "Abheben",
|
||||
"lightning": "Lightning",
|
||||
"cashu": "Cashu",
|
||||
"ecash": "Ecash",
|
||||
"history": "Verlauf",
|
||||
"noTransactions": "Noch keine Transaktionen",
|
||||
"depositOrReceive": "Zahle ein oder empfange Sats zum Starten",
|
||||
"noMint": "Kein Mint",
|
||||
|
||||
"balance": "Guthaben",
|
||||
"sats": "sats",
|
||||
|
||||
"pasteEcashToken": "Ecash Token einfügen",
|
||||
"generateInvoiceToDeposit": "Rechnung zum Einzahlen erstellen",
|
||||
"createEcashToken": "Ecash Token erstellen",
|
||||
"payLightningInvoice": "Lightning Rechnung bezahlen",
|
||||
|
||||
"receiveCashu": "Cashu empfangen",
|
||||
"pasteTheCashuToken": "Füge den Cashu Token ein:",
|
||||
"pasteFromClipboard": "Aus Zwischenablage einfügen",
|
||||
"validToken": "Token gültig",
|
||||
"invalidToken": "Ungültiger oder fehlerhafter Token",
|
||||
"amount": "Betrag:",
|
||||
"mint": "Mint:",
|
||||
"claiming": "Einlösen...",
|
||||
"claimTokens": "Tokens einlösen",
|
||||
"tokensReceived": "Tokens empfangen",
|
||||
"backToHome": "Zurück zur Startseite",
|
||||
"tokenAlreadyClaimed": "Dieser Token wurde bereits eingelöst",
|
||||
"unknownMint": "Token von unbekanntem Mint",
|
||||
"claimError": "Einlösungsfehler: {error}",
|
||||
"@claimError": {
|
||||
"placeholders": {
|
||||
"error": { "type": "String" }
|
||||
}
|
||||
},
|
||||
|
||||
"sendCashu": "Cashu senden",
|
||||
"selectNotesManually": "Notizen manuell auswählen",
|
||||
"amountToSend": "Zu sendender Betrag:",
|
||||
"available": "Verfügbar:",
|
||||
"max": "(Max)",
|
||||
"memoOptional": "Memo (optional):",
|
||||
"memoPlaceholder": "Wofür ist diese Zahlung?",
|
||||
"creatingToken": "Token wird erstellt...",
|
||||
"createToken": "Token erstellen",
|
||||
"noActiveMint": "Kein aktiver Mint",
|
||||
"offlineModeMessage": "Keine Verbindung. Offline-Modus...",
|
||||
"confirmSend": "Senden bestätigen",
|
||||
"confirm": "Bestätigen",
|
||||
"cancel": "Abbrechen",
|
||||
"insufficientBalance": "Unzureichendes Guthaben",
|
||||
"tokenCreationError": "Fehler beim Erstellen des Tokens: {error}",
|
||||
"@tokenCreationError": {
|
||||
"placeholders": {
|
||||
"error": { "type": "String" }
|
||||
}
|
||||
},
|
||||
|
||||
"tokenCreated": "Token erstellt",
|
||||
"copy": "Kopieren",
|
||||
"share": "Teilen",
|
||||
"tokenCashu": "Cashu Token",
|
||||
"tokenCashuAnimatedQr": "Cashu Token (animierter QR - {fragments} UR-Fragmente)",
|
||||
"@tokenCashuAnimatedQr": {
|
||||
"placeholders": {
|
||||
"fragments": { "type": "int" }
|
||||
}
|
||||
},
|
||||
"keepTokenWarning": "Bewahre diesen Token auf, bis der Empfänger ihn einlöst. Wenn du ihn verlierst, verlierst du die Mittel.",
|
||||
"tokenCopiedToClipboard": "Token in Zwischenablage kopiert",
|
||||
|
||||
"amountToDeposit": "Einzuzahlender Betrag:",
|
||||
"descriptionOptional": "Beschreibung (optional):",
|
||||
"depositPlaceholder": "Wofür ist diese Einzahlung?",
|
||||
"generating": "Generiere...",
|
||||
"generateInvoice": "Rechnung erstellen",
|
||||
"depositLightning": "Lightning einzahlen",
|
||||
|
||||
"payInvoiceTitle": "Rechnung bezahlen",
|
||||
"generatingInvoice": "Rechnung wird erstellt...",
|
||||
"waitingForPayment": "Warte auf Zahlung...",
|
||||
"paymentReceived": "Zahlung empfangen",
|
||||
"tokensIssued": "Tokens ausgegeben!",
|
||||
"error": "Fehler",
|
||||
"unknownError": "Unbekannter Fehler",
|
||||
"back": "Zurück",
|
||||
"copyInvoice": "Rechnung kopieren",
|
||||
"description": "Beschreibung:",
|
||||
"invoiceCopiedToClipboard": "Rechnung in Zwischenablage kopiert",
|
||||
"deposited": "{amount} {unit} eingezahlt",
|
||||
"@deposited": {
|
||||
"placeholders": {
|
||||
"amount": { "type": "String" },
|
||||
"unit": { "type": "String" }
|
||||
}
|
||||
},
|
||||
|
||||
"pasteLightningInvoice": "Füge die Lightning Rechnung ein:",
|
||||
"gettingQuote": "Angebot wird geholt...",
|
||||
"validInvoice": "Rechnung gültig",
|
||||
"invalidInvoice": "Ungültige Rechnung",
|
||||
"invalidInvoiceMalformed": "Ungültige oder fehlerhafte Rechnung",
|
||||
"feeReserved": "Reservierte Gebühr:",
|
||||
"total": "Gesamt:",
|
||||
"paying": "Bezahle...",
|
||||
"payInvoice": "Rechnung bezahlen",
|
||||
"confirmPayment": "Zahlung bestätigen",
|
||||
"pay": "Bezahlen",
|
||||
"fee": "Gebühr",
|
||||
"invoiceExpired": "Rechnung abgelaufen",
|
||||
"amountOutOfRange": "Betrag außerhalb des erlaubten Bereichs",
|
||||
"resolvingType": "Löse {type} auf...",
|
||||
"@resolvingType": {
|
||||
"placeholders": {
|
||||
"type": { "type": "String" }
|
||||
}
|
||||
},
|
||||
"invoiceAlreadyPaid": "Rechnung bereits bezahlt",
|
||||
"paymentError": "Zahlungsfehler: {error}",
|
||||
"@paymentError": {
|
||||
"placeholders": {
|
||||
"error": { "type": "String" }
|
||||
}
|
||||
},
|
||||
"sent": "{amount} {unit} gesendet",
|
||||
"@sent": {
|
||||
"placeholders": {
|
||||
"amount": { "type": "String" },
|
||||
"unit": { "type": "String" }
|
||||
}
|
||||
},
|
||||
|
||||
"filterAll": "Alle",
|
||||
"filterPending": "Ausstehend",
|
||||
"filterEcash": "Ecash",
|
||||
"filterLightning": "Lightning",
|
||||
"receiveTokensToStart": "Empfange Cashu Tokens zum Starten",
|
||||
"noPendingTransactions": "Keine ausstehenden Transaktionen",
|
||||
"allTransactionsCompleted": "Alle deine Transaktionen sind abgeschlossen",
|
||||
"noEcashTransactions": "Keine Ecash Transaktionen",
|
||||
"sendOrReceiveTokens": "Sende oder empfange Cashu Tokens",
|
||||
"noLightningTransactions": "Keine Lightning Transaktionen",
|
||||
"depositOrWithdrawLightning": "Zahle ein oder hebe ab via Lightning",
|
||||
"pendingStatus": "Ausstehend",
|
||||
"receivedStatus": "Empfangen",
|
||||
"sentStatus": "Gesendet",
|
||||
"now": "Jetzt",
|
||||
"agoMinutes": "Vor {minutes} Min",
|
||||
"@agoMinutes": {
|
||||
"placeholders": {
|
||||
"minutes": { "type": "int" }
|
||||
}
|
||||
},
|
||||
"agoHours": "Vor {hours} Std",
|
||||
"@agoHours": {
|
||||
"placeholders": {
|
||||
"hours": { "type": "int" }
|
||||
}
|
||||
},
|
||||
"agoDays": "Vor {days} Tagen",
|
||||
"@agoDays": {
|
||||
"placeholders": {
|
||||
"days": { "type": "int" }
|
||||
}
|
||||
},
|
||||
"lightningInvoice": "Lightning Rechnung",
|
||||
"receivedEcash": "Ecash empfangen",
|
||||
"sentEcash": "Ecash gesendet",
|
||||
"outgoingLightningPayment": "Ausgehende Lightning Zahlung",
|
||||
"invoiceNotAvailable": "Rechnung nicht verfügbar",
|
||||
"tokenNotAvailable": "Token nicht verfügbar",
|
||||
"unit": "Einheit",
|
||||
"status": "Status",
|
||||
"pending": "Ausstehend",
|
||||
"memo": "Memo",
|
||||
"copyInvoiceButton": "RECHNUNG KOPIEREN",
|
||||
"copyButton": "KOPIEREN",
|
||||
"invoiceCopied": "Rechnung kopiert",
|
||||
"tokenCopied": "Token kopiert",
|
||||
"speed": "GESCHWINDIGKEIT:",
|
||||
|
||||
"settings": "Einstellungen",
|
||||
"walletSection": "WALLET",
|
||||
"backupSeedPhrase": "Seed-Phrase sichern",
|
||||
"viewRecoveryWords": "Deine Wiederherstellungswörter anzeigen",
|
||||
"connectedMints": "Verbundene Mints",
|
||||
"manageCashuMints": "Verwalte deine Cashu Mints",
|
||||
"pinAccess": "PIN-Zugang",
|
||||
"pinEnabled": "Aktiviert",
|
||||
"protectWithPin": "App mit PIN schützen",
|
||||
"recoverTokens": "Tokens wiederherstellen",
|
||||
"scanMintsWithSeed": "Mints mit Seed-Phrase scannen",
|
||||
"appearanceSection": "SPRACHE",
|
||||
"language": "Sprache",
|
||||
"informationSection": "INFORMATION",
|
||||
"version": "Version",
|
||||
"about": "Über",
|
||||
"deleteWallet": "Wallet löschen",
|
||||
"spanish": "Español",
|
||||
"english": "English",
|
||||
"portuguese": "Português",
|
||||
"french": "Français",
|
||||
"russian": "Русский",
|
||||
"german": "Deutsch",
|
||||
|
||||
"mnemonicNotFound": "Mnemonic nicht gefunden",
|
||||
"createPin": "PIN erstellen",
|
||||
"enterPinDigits": "Gib eine 4-stellige PIN ein",
|
||||
"confirmPin": "PIN bestätigen",
|
||||
"enterPinAgain": "PIN erneut eingeben",
|
||||
"pinMismatch": "PINs stimmen nicht überein",
|
||||
"pinActivated": "PIN aktiviert",
|
||||
"pinDeactivated": "PIN deaktiviert",
|
||||
"verifyPin": "PIN überprüfen",
|
||||
"enterCurrentPin": "Gib deine aktuelle PIN ein",
|
||||
"incorrectPin": "Falsche PIN",
|
||||
"selectLanguage": "Sprache auswählen",
|
||||
"languageChanged": "Sprache geändert zu {language}",
|
||||
"@languageChanged": {
|
||||
"placeholders": {
|
||||
"language": { "type": "String" }
|
||||
}
|
||||
},
|
||||
"close": "Schließen",
|
||||
"aboutDescription": "Eine Cashu Wallet mit kubanischer DNA für die ganze Welt. Bruder von La Chispa.",
|
||||
"couldNotOpenLink": "Link konnte nicht geöffnet werden",
|
||||
|
||||
"deleteWalletQuestion": "Wallet löschen?",
|
||||
"actionIrreversible": "Diese Aktion ist unwiderruflich",
|
||||
"deleteWalletWarning": "Alle Daten werden gelöscht, einschließlich deiner Seed-Phrase und Tokens. Stelle sicher, dass du ein Backup hast.",
|
||||
"typeDeleteToConfirm": "Gib \"LÖSCHEN\" zur Bestätigung ein:",
|
||||
"deleteConfirmWord": "LÖSCHEN",
|
||||
"deleteError": "Löschfehler: {error}",
|
||||
"@deleteError": {
|
||||
"placeholders": {
|
||||
"error": { "type": "String" }
|
||||
}
|
||||
},
|
||||
|
||||
"recoverTokensTitle": "Tokens wiederherstellen",
|
||||
"recoverTokensDescription": "Mints scannen, um Tokens wiederherzustellen, die mit deiner Seed-Phrase verknüpft sind (NUT-13)",
|
||||
"useCurrentSeedPhrase": "Meine aktuelle Seed-Phrase verwenden",
|
||||
"scanWithSavedWords": "Mints mit den gespeicherten 12 Wörtern scannen",
|
||||
"useOtherSeedPhrase": "Andere Seed-Phrase verwenden",
|
||||
"recoverFromOtherWords": "Tokens von anderen 12 Wörtern wiederherstellen",
|
||||
"mintsToScan": "Zu scannende Mints:",
|
||||
"allMints": "Alle Mints ({count})",
|
||||
"@allMints": {
|
||||
"placeholders": {
|
||||
"count": { "type": "int" }
|
||||
}
|
||||
},
|
||||
"specificMint": "Ein bestimmter Mint",
|
||||
"enterMnemonicWords": "Gib die 12 Wörter durch Leerzeichen getrennt ein...",
|
||||
"scanMints": "Mints scannen",
|
||||
"selectMintToScan": "Wähle einen Mint zum Scannen",
|
||||
"mnemonicMustHaveWords": "Mnemonic muss 12 oder 24 Wörter haben",
|
||||
"noConnectedMintsToScan": "Keine verbundenen Mints zum Scannen",
|
||||
"recoveredTokens": "{tokens} von {mints} Mint(s) wiederhergestellt!",
|
||||
"@recoveredTokens": {
|
||||
"placeholders": {
|
||||
"tokens": { "type": "String" },
|
||||
"mints": { "type": "int" }
|
||||
}
|
||||
},
|
||||
"scanCompleteNoTokens": "Scan abgeschlossen. Keine neuen Tokens gefunden.",
|
||||
"mintsWithError": "({count} Mint(s) mit Fehler)",
|
||||
"@mintsWithError": {
|
||||
"placeholders": {
|
||||
"count": { "type": "int" }
|
||||
}
|
||||
},
|
||||
"recoveredFromMint": "{tokens} von {mint} wiederhergestellt!",
|
||||
"@recoveredFromMint": {
|
||||
"placeholders": {
|
||||
"tokens": { "type": "String" },
|
||||
"mint": { "type": "String" }
|
||||
}
|
||||
},
|
||||
"noTokensFoundInMint": "Keine Tokens in {mint} gefunden.",
|
||||
"@noTokensFoundInMint": {
|
||||
"placeholders": {
|
||||
"mint": { "type": "String" }
|
||||
}
|
||||
},
|
||||
"recoveredAndTransferred": "{amount} {unit} wiederhergestellt und in deine Wallet übertragen!",
|
||||
"@recoveredAndTransferred": {
|
||||
"placeholders": {
|
||||
"amount": { "type": "String" },
|
||||
"unit": { "type": "String" }
|
||||
}
|
||||
},
|
||||
"noTokensForMnemonic": "Keine Tokens mit diesem Mnemonic verknüpft gefunden.",
|
||||
|
||||
"noConnectedMints": "Keine verbundenen Mints",
|
||||
"addMintToStart": "Füge einen Mint hinzu, um zu starten",
|
||||
"addMint": "Mint hinzufügen",
|
||||
"mintDeleted": "Mint gelöscht",
|
||||
"activeMintUpdated": "Aktiver Mint aktualisiert",
|
||||
"mintUrl": "Mint URL:",
|
||||
"mintUrlPlaceholder": "https://mint.example.com",
|
||||
"urlMustStartWithHttps": "URL muss mit https:// beginnen",
|
||||
"connectingToMint": "Verbinde mit Mint...",
|
||||
"mintAddedSuccessfully": "Mint erfolgreich hinzugefügt",
|
||||
"couldNotConnectToMint": "Verbindung zum Mint fehlgeschlagen",
|
||||
"add": "Hinzufügen",
|
||||
|
||||
"success": "Erfolg",
|
||||
"loading": "Laden...",
|
||||
"retry": "Erneut versuchen",
|
||||
|
||||
"activeMint": "Aktiver Mint",
|
||||
"mintMessage": "Mint Nachricht",
|
||||
"url": "URL",
|
||||
"currency": "Währung",
|
||||
"unknown": "Unbekannt",
|
||||
"useThisMint": "Diesen Mint verwenden",
|
||||
"copyMintUrl": "Mint URL kopieren",
|
||||
"deleteMint": "Mint löschen",
|
||||
"copied": "{label} kopiert",
|
||||
"@copied": {
|
||||
"placeholders": {
|
||||
"label": { "type": "String" }
|
||||
}
|
||||
},
|
||||
"deleteMintConfirmTitle": "Mint löschen",
|
||||
"deleteMintConfirmMessage": "Wenn du Guthaben auf diesem Mint hast, geht es verloren. Bist du sicher?",
|
||||
"delete": "Löschen",
|
||||
|
||||
"offlineSend": "Offline senden",
|
||||
"selectNotesToSend": "Wähle die zu sendenden Notizen:",
|
||||
"totalToSend": "Gesamt zu senden",
|
||||
"notesSelected": "{count} Notizen ausgewählt",
|
||||
"@notesSelected": {
|
||||
"placeholders": {
|
||||
"count": { "type": "int" }
|
||||
}
|
||||
},
|
||||
"loadingProofsError": "Fehler beim Laden der Beweise: {error}",
|
||||
"@loadingProofsError": {
|
||||
"placeholders": {
|
||||
"error": { "type": "String" }
|
||||
}
|
||||
},
|
||||
"creatingTokenError": "Fehler beim Erstellen des Tokens: {error}",
|
||||
"@creatingTokenError": {
|
||||
"placeholders": {
|
||||
"error": { "type": "String" }
|
||||
}
|
||||
},
|
||||
|
||||
"unknownState": "Unbekannter Zustand",
|
||||
"depositAmountTitle": "{amount} {unit} einzahlen",
|
||||
"@depositAmountTitle": {
|
||||
"placeholders": {
|
||||
"amount": { "type": "String" },
|
||||
"unit": { "type": "String" }
|
||||
}
|
||||
},
|
||||
|
||||
"receiveNow": "Jetzt empfangen",
|
||||
"receiveLater": "Später empfangen",
|
||||
"tokenSavedForLater": "Token zum späteren Einlösen gespeichert",
|
||||
"noConnectionTokenSaved": "Keine Verbindung. Token zum späteren Einlösen gespeichert.",
|
||||
"unknownMintOffline": "Dieser Token stammt von einem unbekannten Mint. Verbinde dich mit dem Internet, um ihn hinzuzufügen und den Token einzulösen.",
|
||||
"noConnectionTryLater": "Keine Verbindung zum Mint. Versuche es später erneut.",
|
||||
"saveTokenError": "Fehler beim Speichern des Tokens. Bitte erneut versuchen.",
|
||||
"pendingTokenLimitReached": "Limit für ausstehende Tokens erreicht (max 50)",
|
||||
"filterToReceive": "Zu empfangen",
|
||||
"noPendingTokens": "Keine ausstehenden Tokens",
|
||||
"noPendingTokensHint": "Speichere Tokens zum späteren Einlösen",
|
||||
"pendingBadge": "AUSSTEHEND",
|
||||
"expiresInDays": "{days, plural, =1{Läuft in 1 Tag ab} other{Läuft in {days} Tagen ab}}",
|
||||
"@expiresInDays": {
|
||||
"placeholders": {
|
||||
"days": { "type": "int" }
|
||||
}
|
||||
},
|
||||
"retryCount": "{count} Versuche",
|
||||
"@retryCount": {
|
||||
"placeholders": {
|
||||
"count": { "type": "int" }
|
||||
}
|
||||
},
|
||||
"claimNow": "Jetzt einlösen",
|
||||
"pendingTokenClaimedSuccess": "{amount} {unit} eingelöst",
|
||||
"@pendingTokenClaimedSuccess": {
|
||||
"placeholders": {
|
||||
"amount": { "type": "String" },
|
||||
"unit": { "type": "String" }
|
||||
}
|
||||
},
|
||||
"pendingTokensClaimed": "{count, plural, =1{1 Token eingelöst ({amount} {unit})} other{{count} Tokens eingelöst ({amount} {unit})}}",
|
||||
"@pendingTokensClaimed": {
|
||||
"placeholders": {
|
||||
"count": { "type": "int" },
|
||||
"amount": { "type": "String" },
|
||||
"unit": { "type": "String" }
|
||||
}
|
||||
},
|
||||
|
||||
"scan": "Scannen",
|
||||
"scanQrCode": "QR scannen",
|
||||
"scanCashuToken": "Cashu Token scannen",
|
||||
"scanLightningInvoice": "Rechnung scannen",
|
||||
"scanningAnimatedQr": "Animierten QR scannen...",
|
||||
"pointCameraAtQr": "Richte die Kamera auf den QR-Code",
|
||||
"pointCameraAtCashuQr": "Richte die Kamera auf den Cashu Token QR",
|
||||
"pointCameraAtInvoiceQr": "Richte die Kamera auf den Rechnungs-QR",
|
||||
"unrecognizedQrCode": "Nicht erkannter QR-Code",
|
||||
"scanCashuTokenHint": "Scanne einen Cashu Token (cashuA... oder cashuB...)",
|
||||
"scanLightningInvoiceHint": "Scanne eine Lightning Rechnung (lnbc...)",
|
||||
"addMintQuestion": "Diesen Mint hinzufügen?",
|
||||
"cameraPermissionDenied": "Kamera-Berechtigung verweigert",
|
||||
"paymentRequestNotSupported": "Zahlungsanfragen werden noch nicht unterstützt"
|
||||
}
|
||||
+297
-7
@@ -3,9 +3,17 @@
|
||||
|
||||
"appName": "ElCaju",
|
||||
"appTagline": "Your private ecash wallet",
|
||||
"loadingMessage1": "Encrypting your coins...",
|
||||
"loadingMessage2": "Preparing your e-tokens...",
|
||||
"loadingMessage3": "Connecting to the Mint...",
|
||||
"loadingMessage4": "Privacy by default.",
|
||||
"loadingMessage5": "Blind signing tokens...",
|
||||
"loadingMessage6": "Go full Calle...",
|
||||
"loadingMessage7": "Cashu + Bitchat = Privacy + Freedom",
|
||||
"aboutTagline": "Privacy without borders.",
|
||||
|
||||
"welcomeTitle": "Welcome to ElCaju",
|
||||
"welcomeSubtitle": "Your private ecash wallet.\nSimple, secure and non-custodial.",
|
||||
"welcomeSubtitle": "Cashu for the world. Made in Cuba.",
|
||||
|
||||
"createWallet": "Create new wallet",
|
||||
"restoreWallet": "Restore wallet",
|
||||
@@ -44,31 +52,313 @@
|
||||
"homeTitle": "Home",
|
||||
"receive": "Receive",
|
||||
"send": "Send",
|
||||
"sendAction": "Send ↗",
|
||||
"receiveAction": "↘ Receive",
|
||||
"deposit": "Deposit",
|
||||
"withdraw": "Withdraw",
|
||||
"lightning": "Lightning",
|
||||
"cashu": "Cashu",
|
||||
"ecash": "Ecash",
|
||||
"history": "History",
|
||||
"noTransactions": "No transactions yet",
|
||||
"depositOrReceive": "Deposit or receive sats to start",
|
||||
"noMint": "No mint",
|
||||
|
||||
"balance": "Balance",
|
||||
"sats": "sats",
|
||||
|
||||
"pasteEcashToken": "Paste ecash token",
|
||||
"generateInvoiceToDeposit": "Generate invoice to deposit",
|
||||
"createEcashToken": "Create ecash token",
|
||||
"payLightningInvoice": "Pay Lightning invoice",
|
||||
|
||||
"receiveCashu": "Receive Cashu",
|
||||
"pasteTheCashuToken": "Paste the Cashu token:",
|
||||
"pasteFromClipboard": "Paste from clipboard",
|
||||
"validToken": "Valid token",
|
||||
"invalidToken": "Invalid or malformed token",
|
||||
"amount": "Amount:",
|
||||
"mint": "Mint:",
|
||||
"claiming": "Claiming...",
|
||||
"claimTokens": "Claim tokens",
|
||||
"tokensReceived": "Tokens received",
|
||||
"backToHome": "Back to home",
|
||||
"tokenAlreadyClaimed": "This token was already claimed",
|
||||
"unknownMint": "Token from unknown mint",
|
||||
"claimError": "Claim error: {error}",
|
||||
|
||||
"sendCashu": "Send Cashu",
|
||||
"selectNotesManually": "Select notes manually",
|
||||
"amountToSend": "Amount to send:",
|
||||
"available": "Available:",
|
||||
"max": "(Max)",
|
||||
"memoOptional": "Memo (optional):",
|
||||
"memoPlaceholder": "What is this payment for?",
|
||||
"creatingToken": "Creating token...",
|
||||
"createToken": "Create token",
|
||||
"noActiveMint": "No active mint",
|
||||
"offlineModeMessage": "No connection. Using offline mode...",
|
||||
"confirmSend": "Confirm send",
|
||||
"confirm": "Confirm",
|
||||
"cancel": "Cancel",
|
||||
"insufficientBalance": "Insufficient balance",
|
||||
"tokenCreationError": "Error creating token: {error}",
|
||||
|
||||
"tokenCreated": "Token created",
|
||||
"copy": "Copy",
|
||||
"share": "Share",
|
||||
"tokenCashu": "Cashu Token",
|
||||
"tokenCashuAnimatedQr": "Cashu Token (animated QR - {fragments} UR fragments)",
|
||||
"keepTokenWarning": "Keep this token until the recipient claims it. If you lose it, you will lose the funds.",
|
||||
"tokenCopiedToClipboard": "Token copied to clipboard",
|
||||
|
||||
"amountToDeposit": "Amount to deposit:",
|
||||
"descriptionOptional": "Description (optional):",
|
||||
"depositPlaceholder": "What is this deposit for?",
|
||||
"generating": "Generating...",
|
||||
"generateInvoice": "Generate invoice",
|
||||
"depositLightning": "Deposit Lightning",
|
||||
|
||||
"payInvoiceTitle": "Pay invoice",
|
||||
"generatingInvoice": "Generating invoice...",
|
||||
"waitingForPayment": "Waiting for payment...",
|
||||
"paymentReceived": "Payment received",
|
||||
"tokensIssued": "Tokens issued!",
|
||||
"error": "Error",
|
||||
"unknownError": "Unknown error",
|
||||
"back": "Back",
|
||||
"copyInvoice": "Copy invoice",
|
||||
"description": "Description:",
|
||||
"invoiceCopiedToClipboard": "Invoice copied to clipboard",
|
||||
"deposited": "{amount} {unit} deposited",
|
||||
|
||||
"pasteLightningInvoice": "Paste the Lightning invoice:",
|
||||
"gettingQuote": "Getting quote...",
|
||||
"validInvoice": "Valid invoice",
|
||||
"invalidInvoice": "Invalid invoice",
|
||||
"invalidInvoiceMalformed": "Invalid or malformed invoice",
|
||||
"feeReserved": "Fee reserved:",
|
||||
"total": "Total:",
|
||||
"paying": "Paying...",
|
||||
"payInvoice": "Pay invoice",
|
||||
"confirmPayment": "Confirm payment",
|
||||
"pay": "Pay",
|
||||
"fee": "fee",
|
||||
"invoiceExpired": "Invoice expired",
|
||||
"amountOutOfRange": "Amount out of allowed range",
|
||||
"resolvingType": "Resolving {type}...",
|
||||
"@resolvingType": {
|
||||
"placeholders": {
|
||||
"type": { "type": "String" }
|
||||
}
|
||||
},
|
||||
"invoiceAlreadyPaid": "Invoice already paid",
|
||||
"paymentError": "Payment error: {error}",
|
||||
"sent": "{amount} {unit} sent",
|
||||
|
||||
"filterAll": "All",
|
||||
"filterPending": "Pending",
|
||||
"filterEcash": "Ecash",
|
||||
"filterLightning": "Lightning",
|
||||
"receiveTokensToStart": "Receive Cashu tokens to start",
|
||||
"noPendingTransactions": "No pending transactions",
|
||||
"allTransactionsCompleted": "All your transactions are completed",
|
||||
"noEcashTransactions": "No Ecash transactions",
|
||||
"sendOrReceiveTokens": "Send or receive Cashu tokens",
|
||||
"noLightningTransactions": "No Lightning transactions",
|
||||
"depositOrWithdrawLightning": "Deposit or withdraw via Lightning",
|
||||
"pendingStatus": "Pending",
|
||||
"receivedStatus": "Received",
|
||||
"sentStatus": "Sent",
|
||||
"now": "Now",
|
||||
"agoMinutes": "{minutes} min ago",
|
||||
"agoHours": "{hours} h ago",
|
||||
"agoDays": "{days} days ago",
|
||||
"lightningInvoice": "Lightning Invoice",
|
||||
"receivedEcash": "Received Ecash",
|
||||
"sentEcash": "Sent Ecash",
|
||||
"outgoingLightningPayment": "Outgoing Lightning Payment",
|
||||
"invoiceNotAvailable": "Invoice not available",
|
||||
"tokenNotAvailable": "Token not available",
|
||||
"unit": "Unit",
|
||||
"status": "Status",
|
||||
"pending": "Pending",
|
||||
"memo": "Memo",
|
||||
"copyInvoiceButton": "COPY INVOICE",
|
||||
"copyButton": "COPY",
|
||||
"invoiceCopied": "Invoice copied",
|
||||
"tokenCopied": "Token copied",
|
||||
"speed": "SPEED:",
|
||||
|
||||
"settings": "Settings",
|
||||
"walletSection": "WALLET",
|
||||
"backupSeedPhrase": "Backup seed phrase",
|
||||
"viewRecoveryWords": "View your recovery words",
|
||||
"connectedMints": "Connected mints",
|
||||
"manageCashuMints": "Manage your Cashu mints",
|
||||
"pinAccess": "PIN access",
|
||||
"pinEnabled": "Enabled",
|
||||
"protectWithPin": "Protect the app with PIN",
|
||||
"recoverTokens": "Recover tokens",
|
||||
"scanMintsWithSeed": "Scan mints with seed phrase",
|
||||
"appearanceSection": "LANGUAGE",
|
||||
"language": "Language",
|
||||
"spanish": "Español",
|
||||
"english": "English",
|
||||
"informationSection": "INFORMATION",
|
||||
"version": "Version",
|
||||
"about": "About",
|
||||
"deleteWallet": "Delete wallet",
|
||||
"spanish": "Español",
|
||||
"english": "English",
|
||||
"portuguese": "Português",
|
||||
"french": "Français",
|
||||
"russian": "Русский",
|
||||
"german": "Deutsch",
|
||||
|
||||
"mnemonicNotFound": "Mnemonic not found",
|
||||
"createPin": "Create PIN",
|
||||
"enterPinDigits": "Enter a 4-digit PIN",
|
||||
"confirmPin": "Confirm PIN",
|
||||
"enterPinAgain": "Enter the PIN again",
|
||||
"pinMismatch": "PINs do not match",
|
||||
"pinActivated": "PIN activated",
|
||||
"pinDeactivated": "PIN deactivated",
|
||||
"verifyPin": "Verify PIN",
|
||||
"enterCurrentPin": "Enter your current PIN",
|
||||
"incorrectPin": "Incorrect PIN",
|
||||
"selectLanguage": "Select language",
|
||||
"languageChanged": "Language changed to {language}",
|
||||
"close": "Close",
|
||||
"aboutDescription": "A Cashu wallet with Cuban DNA for the entire world. Brother of La Chispa.",
|
||||
"couldNotOpenLink": "Could not open link",
|
||||
|
||||
"deleteWalletQuestion": "Delete wallet?",
|
||||
"actionIrreversible": "This action is irreversible",
|
||||
"deleteWalletWarning": "All data will be deleted including your seed phrase and tokens. Make sure you have a backup.",
|
||||
"typeDeleteToConfirm": "Type \"DELETE\" to confirm:",
|
||||
"deleteConfirmWord": "DELETE",
|
||||
"deleteError": "Delete error: {error}",
|
||||
|
||||
"recoverTokensTitle": "Recover tokens",
|
||||
"recoverTokensDescription": "Scan mints to recover tokens associated with your seed phrase (NUT-13)",
|
||||
"useCurrentSeedPhrase": "Use my current seed phrase",
|
||||
"scanWithSavedWords": "Scan mints with the 12 saved words",
|
||||
"useOtherSeedPhrase": "Use another seed phrase",
|
||||
"recoverFromOtherWords": "Recover tokens from other 12 words",
|
||||
"mintsToScan": "Mints to scan:",
|
||||
"allMints": "All mints ({count})",
|
||||
"specificMint": "A specific mint",
|
||||
"enterMnemonicWords": "Enter the 12 words separated by spaces...",
|
||||
"scanMints": "Scan mints",
|
||||
"selectMintToScan": "Select a mint to scan",
|
||||
"mnemonicMustHaveWords": "Mnemonic must have 12 or 24 words",
|
||||
"noConnectedMintsToScan": "No connected mints to scan",
|
||||
"recoveredTokens": "Recovered {tokens} from {mints} mint(s)!",
|
||||
"scanCompleteNoTokens": "Scan complete. No new tokens found.",
|
||||
"mintsWithError": "({count} mint(s) with error)",
|
||||
"recoveredFromMint": "Recovered {tokens} from {mint}!",
|
||||
"noTokensFoundInMint": "No tokens found in {mint}.",
|
||||
"recoveredAndTransferred": "Recovered and transferred {amount} {unit} to your wallet!",
|
||||
"noTokensForMnemonic": "No tokens found associated with that mnemonic.",
|
||||
|
||||
"noConnectedMints": "No connected mints",
|
||||
"addMintToStart": "Add a mint to start",
|
||||
"addMint": "Add mint",
|
||||
"mintDeleted": "Mint deleted",
|
||||
"activeMintUpdated": "Active mint updated",
|
||||
"mintUrl": "Mint URL:",
|
||||
"mintUrlPlaceholder": "https://mint.example.com",
|
||||
"urlMustStartWithHttps": "URL must start with https://",
|
||||
"connectingToMint": "Connecting to mint...",
|
||||
"mintAddedSuccessfully": "Mint added successfully",
|
||||
"couldNotConnectToMint": "Could not connect to mint",
|
||||
"add": "Add",
|
||||
|
||||
"confirm": "Confirm",
|
||||
"cancel": "Cancel",
|
||||
"error": "Error",
|
||||
"success": "Success",
|
||||
"loading": "Loading...",
|
||||
"retry": "Retry"
|
||||
"retry": "Retry",
|
||||
|
||||
"activeMint": "Active mint",
|
||||
"mintMessage": "Mint Message",
|
||||
"url": "URL",
|
||||
"currency": "Currency",
|
||||
"unknown": "Unknown",
|
||||
"useThisMint": "Use this mint",
|
||||
"copyMintUrl": "Copy mint URL",
|
||||
"deleteMint": "Delete mint",
|
||||
"copied": "{label} copied",
|
||||
"deleteMintConfirmTitle": "Delete mint",
|
||||
"deleteMintConfirmMessage": "If you have balance in this mint, it will be lost. Are you sure?",
|
||||
"delete": "Delete",
|
||||
|
||||
"offlineSend": "Offline Send",
|
||||
"selectNotesToSend": "Select the notes you want to send:",
|
||||
"totalToSend": "Total to send",
|
||||
"notesSelected": "{count} notes selected",
|
||||
"loadingProofsError": "Error loading proofs: {error}",
|
||||
"creatingTokenError": "Error creating token: {error}",
|
||||
|
||||
"unknownState": "Unknown state",
|
||||
"depositAmountTitle": "Deposit {amount} {unit}",
|
||||
"@depositAmountTitle": {
|
||||
"placeholders": {
|
||||
"amount": { "type": "String" },
|
||||
"unit": { "type": "String" }
|
||||
}
|
||||
},
|
||||
|
||||
"receiveNow": "Receive now",
|
||||
"receiveLater": "Receive later",
|
||||
"tokenSavedForLater": "Token saved to claim later",
|
||||
"noConnectionTokenSaved": "No connection. Token saved to claim later.",
|
||||
"unknownMintOffline": "This token is from an unknown mint. Connect to the internet to add it and claim the token.",
|
||||
"noConnectionTryLater": "No connection to mint. Try again later.",
|
||||
"saveTokenError": "Error saving token. Please try again.",
|
||||
"pendingTokenLimitReached": "Pending tokens limit reached (max 50)",
|
||||
"filterToReceive": "To receive",
|
||||
"noPendingTokens": "No pending tokens",
|
||||
"noPendingTokensHint": "Save tokens to claim later",
|
||||
"pendingBadge": "PENDING",
|
||||
"expiresInDays": "{days, plural, =1{Expires in 1 day} other{Expires in {days} days}}",
|
||||
"@expiresInDays": {
|
||||
"placeholders": {
|
||||
"days": { "type": "int" }
|
||||
}
|
||||
},
|
||||
"retryCount": "{count} retries",
|
||||
"@retryCount": {
|
||||
"placeholders": {
|
||||
"count": { "type": "int" }
|
||||
}
|
||||
},
|
||||
"claimNow": "Claim now",
|
||||
"pendingTokenClaimedSuccess": "Claimed {amount} {unit}",
|
||||
"@pendingTokenClaimedSuccess": {
|
||||
"placeholders": {
|
||||
"amount": { "type": "String" },
|
||||
"unit": { "type": "String" }
|
||||
}
|
||||
},
|
||||
"pendingTokensClaimed": "{count, plural, =1{Claimed 1 token ({amount} {unit})} other{Claimed {count} tokens ({amount} {unit})}}",
|
||||
"@pendingTokensClaimed": {
|
||||
"placeholders": {
|
||||
"count": { "type": "int" },
|
||||
"amount": { "type": "String" },
|
||||
"unit": { "type": "String" }
|
||||
}
|
||||
},
|
||||
|
||||
"scan": "Scan",
|
||||
"scanQrCode": "Scan QR Code",
|
||||
"scanCashuToken": "Scan Cashu Token",
|
||||
"scanLightningInvoice": "Scan Invoice",
|
||||
"scanningAnimatedQr": "Scanning animated QR...",
|
||||
"pointCameraAtQr": "Point the camera at the QR code",
|
||||
"pointCameraAtCashuQr": "Point the camera at the Cashu token QR",
|
||||
"pointCameraAtInvoiceQr": "Point the camera at the invoice QR",
|
||||
"unrecognizedQrCode": "Unrecognized QR code",
|
||||
"scanCashuTokenHint": "Scan a Cashu token (cashuA... or cashuB...)",
|
||||
"scanLightningInvoiceHint": "Scan a Lightning invoice (lnbc...)",
|
||||
"addMintQuestion": "Add this mint?",
|
||||
"cameraPermissionDenied": "Camera permission denied",
|
||||
"paymentRequestNotSupported": "Payment requests are not yet supported"
|
||||
}
|
||||
|
||||
+409
-13
@@ -3,9 +3,17 @@
|
||||
|
||||
"appName": "ElCaju",
|
||||
"appTagline": "Tu wallet de ecash privado",
|
||||
"loadingMessage1": "Cifrando tus monedas...",
|
||||
"loadingMessage2": "Preparando tus e-tokens...",
|
||||
"loadingMessage3": "Conectando con el Mint...",
|
||||
"loadingMessage4": "Privacidad por defecto.",
|
||||
"loadingMessage5": "Firmando tokens ciegamente...",
|
||||
"loadingMessage6": "Go full Calle...",
|
||||
"loadingMessage7": "Cashu + Bitchat = Privacidad + Libertad",
|
||||
"aboutTagline": "Privacidad sin fronteras.",
|
||||
|
||||
"welcomeTitle": "Bienvenido a ElCaju",
|
||||
"welcomeSubtitle": "Tu wallet de ecash privado.\nSimple, seguro y sin custodia.",
|
||||
"welcomeSubtitle": "Cashu para el mundo. Hecho en Cuba.",
|
||||
|
||||
"createWallet": "Crear nueva wallet",
|
||||
"restoreWallet": "Restaurar wallet",
|
||||
@@ -40,49 +48,437 @@
|
||||
"wordCount": "{count} palabras",
|
||||
"@wordCount": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
"count": { "type": "int" }
|
||||
}
|
||||
},
|
||||
"needWords": "(necesitas 12 o 24)",
|
||||
"restoreError": "Error al restaurar: {error}",
|
||||
"@restoreError": {
|
||||
"placeholders": {
|
||||
"error": {
|
||||
"type": "String"
|
||||
}
|
||||
"error": { "type": "String" }
|
||||
}
|
||||
},
|
||||
|
||||
"homeTitle": "Inicio",
|
||||
"receive": "Recibir",
|
||||
"send": "Enviar",
|
||||
"sendAction": "Enviar ↗",
|
||||
"receiveAction": "↘ Recibir",
|
||||
"deposit": "Depositar",
|
||||
"withdraw": "Retirar",
|
||||
"lightning": "Lightning",
|
||||
"cashu": "Cashu",
|
||||
"ecash": "Ecash",
|
||||
"history": "Historial",
|
||||
"noTransactions": "Sin transacciones aún",
|
||||
"depositOrReceive": "Deposita o recibe sats para empezar",
|
||||
"noMint": "Sin mint",
|
||||
|
||||
"balance": "Balance",
|
||||
"sats": "sats",
|
||||
|
||||
"pasteEcashToken": "Pegar token ecash",
|
||||
"generateInvoiceToDeposit": "Generar invoice para depositar",
|
||||
"createEcashToken": "Crear token ecash",
|
||||
"payLightningInvoice": "Pagar invoice Lightning",
|
||||
|
||||
"receiveCashu": "Recibir Cashu",
|
||||
"pasteTheCashuToken": "Pega el token Cashu:",
|
||||
"pasteFromClipboard": "Pegar del portapapeles",
|
||||
"validToken": "Token válido",
|
||||
"invalidToken": "Token inválido o malformado",
|
||||
"amount": "Monto:",
|
||||
"mint": "Mint:",
|
||||
"claiming": "Reclamando...",
|
||||
"claimTokens": "Reclamar tokens",
|
||||
"tokensReceived": "Tokens recibidos",
|
||||
"backToHome": "Volver al inicio",
|
||||
"tokenAlreadyClaimed": "Este token ya fue reclamado",
|
||||
"unknownMint": "Token de un mint desconocido",
|
||||
"claimError": "Error al reclamar: {error}",
|
||||
"@claimError": {
|
||||
"placeholders": {
|
||||
"error": { "type": "String" }
|
||||
}
|
||||
},
|
||||
|
||||
"sendCashu": "Enviar Cashu",
|
||||
"selectNotesManually": "Seleccionar notas manualmente",
|
||||
"amountToSend": "Monto a enviar:",
|
||||
"available": "Disponible:",
|
||||
"max": "(Max)",
|
||||
"memoOptional": "Memo (opcional):",
|
||||
"memoPlaceholder": "¿Para qué es este pago?",
|
||||
"creatingToken": "Creando token...",
|
||||
"createToken": "Crear token",
|
||||
"noActiveMint": "No hay mint activo",
|
||||
"offlineModeMessage": "Sin conexión. Usando modo offline...",
|
||||
"confirmSend": "Confirmar envío",
|
||||
"confirm": "Confirmar",
|
||||
"cancel": "Cancelar",
|
||||
"insufficientBalance": "Balance insuficiente",
|
||||
"tokenCreationError": "Error al crear token: {error}",
|
||||
"@tokenCreationError": {
|
||||
"placeholders": {
|
||||
"error": { "type": "String" }
|
||||
}
|
||||
},
|
||||
|
||||
"tokenCreated": "Token creado",
|
||||
"copy": "Copiar",
|
||||
"share": "Compartir",
|
||||
"tokenCashu": "Token Cashu",
|
||||
"tokenCashuAnimatedQr": "Token Cashu (QR animado - {fragments} fragmentos UR)",
|
||||
"@tokenCashuAnimatedQr": {
|
||||
"placeholders": {
|
||||
"fragments": { "type": "int" }
|
||||
}
|
||||
},
|
||||
"keepTokenWarning": "Guarda este token hasta que el receptor lo reclame. Si lo pierdes, perderás los fondos.",
|
||||
"tokenCopiedToClipboard": "Token copiado al portapapeles",
|
||||
|
||||
"amountToDeposit": "Monto a depositar:",
|
||||
"descriptionOptional": "Descripción (opcional):",
|
||||
"depositPlaceholder": "¿Para qué es esta recarga?",
|
||||
"generating": "Generando...",
|
||||
"generateInvoice": "Generar invoice",
|
||||
"depositLightning": "Depositar Lightning",
|
||||
|
||||
"payInvoiceTitle": "Pagar invoice",
|
||||
"generatingInvoice": "Generando invoice...",
|
||||
"waitingForPayment": "Esperando pago...",
|
||||
"paymentReceived": "Pago recibido",
|
||||
"tokensIssued": "Tokens emitidos!",
|
||||
"error": "Error",
|
||||
"unknownError": "Error desconocido",
|
||||
"back": "Volver",
|
||||
"copyInvoice": "Copiar invoice",
|
||||
"description": "Descripción:",
|
||||
"invoiceCopiedToClipboard": "Invoice copiado al portapapeles",
|
||||
"deposited": "{amount} {unit} depositados",
|
||||
"@deposited": {
|
||||
"placeholders": {
|
||||
"amount": { "type": "String" },
|
||||
"unit": { "type": "String" }
|
||||
}
|
||||
},
|
||||
|
||||
"pasteLightningInvoice": "Pega el invoice Lightning:",
|
||||
"gettingQuote": "Obteniendo quote...",
|
||||
"validInvoice": "Invoice válido",
|
||||
"invalidInvoice": "Invoice inválido",
|
||||
"invalidInvoiceMalformed": "Invoice inválido o malformado",
|
||||
"feeReserved": "Fee reservado:",
|
||||
"total": "Total:",
|
||||
"paying": "Pagando...",
|
||||
"payInvoice": "Pagar invoice",
|
||||
"confirmPayment": "Confirmar pago",
|
||||
"pay": "Pagar",
|
||||
"fee": "fee",
|
||||
"invoiceExpired": "Invoice expirado",
|
||||
"amountOutOfRange": "Monto fuera del rango permitido",
|
||||
"resolvingType": "Resolviendo {type}...",
|
||||
"@resolvingType": {
|
||||
"placeholders": {
|
||||
"type": { "type": "String" }
|
||||
}
|
||||
},
|
||||
"invoiceAlreadyPaid": "Invoice ya fue pagado",
|
||||
"paymentError": "Error al pagar: {error}",
|
||||
"@paymentError": {
|
||||
"placeholders": {
|
||||
"error": { "type": "String" }
|
||||
}
|
||||
},
|
||||
"sent": "{amount} {unit} enviados",
|
||||
"@sent": {
|
||||
"placeholders": {
|
||||
"amount": { "type": "String" },
|
||||
"unit": { "type": "String" }
|
||||
}
|
||||
},
|
||||
|
||||
"filterAll": "Todos",
|
||||
"filterPending": "Pendientes",
|
||||
"filterEcash": "Ecash",
|
||||
"filterLightning": "Lightning",
|
||||
"receiveTokensToStart": "Recibe tokens Cashu para empezar",
|
||||
"noPendingTransactions": "Sin transacciones pendientes",
|
||||
"allTransactionsCompleted": "Todas tus transacciones están completadas",
|
||||
"noEcashTransactions": "Sin transacciones Ecash",
|
||||
"sendOrReceiveTokens": "Envía o recibe tokens Cashu",
|
||||
"noLightningTransactions": "Sin transacciones Lightning",
|
||||
"depositOrWithdrawLightning": "Deposita o retira via Lightning",
|
||||
"pendingStatus": "Pendiente",
|
||||
"receivedStatus": "Recibido",
|
||||
"sentStatus": "Enviado",
|
||||
"now": "Ahora",
|
||||
"agoMinutes": "Hace {minutes} min",
|
||||
"@agoMinutes": {
|
||||
"placeholders": {
|
||||
"minutes": { "type": "int" }
|
||||
}
|
||||
},
|
||||
"agoHours": "Hace {hours} h",
|
||||
"@agoHours": {
|
||||
"placeholders": {
|
||||
"hours": { "type": "int" }
|
||||
}
|
||||
},
|
||||
"agoDays": "Hace {days} días",
|
||||
"@agoDays": {
|
||||
"placeholders": {
|
||||
"days": { "type": "int" }
|
||||
}
|
||||
},
|
||||
"lightningInvoice": "Invoice Lightning",
|
||||
"receivedEcash": "Ecash Recibido",
|
||||
"sentEcash": "Ecash Enviado",
|
||||
"outgoingLightningPayment": "Pago Lightning Saliente",
|
||||
"invoiceNotAvailable": "Invoice no disponible",
|
||||
"tokenNotAvailable": "Token no disponible",
|
||||
"unit": "Unidad",
|
||||
"status": "Estado",
|
||||
"pending": "Pendiente",
|
||||
"memo": "Memo",
|
||||
"copyInvoiceButton": "COPIAR INVOICE",
|
||||
"copyButton": "COPIAR",
|
||||
"invoiceCopied": "Invoice copiado",
|
||||
"tokenCopied": "Token copiado",
|
||||
"speed": "VELOCIDAD:",
|
||||
|
||||
"settings": "Configuración",
|
||||
"walletSection": "WALLET",
|
||||
"backupSeedPhrase": "Backup seed phrase",
|
||||
"viewRecoveryWords": "Ver tus palabras de recuperación",
|
||||
"connectedMints": "Mints conectados",
|
||||
"manageCashuMints": "Gestionar tus mints Cashu",
|
||||
"pinAccess": "PIN de acceso",
|
||||
"pinEnabled": "Activado",
|
||||
"protectWithPin": "Proteger la app con PIN",
|
||||
"recoverTokens": "Recuperar tokens",
|
||||
"scanMintsWithSeed": "Escanear mints con seed phrase",
|
||||
"appearanceSection": "IDIOMA",
|
||||
"language": "Idioma",
|
||||
"spanish": "Español",
|
||||
"english": "English",
|
||||
"informationSection": "INFORMACIÓN",
|
||||
"version": "Versión",
|
||||
"about": "Acerca de",
|
||||
"deleteWallet": "Borrar wallet",
|
||||
"spanish": "Español",
|
||||
"english": "English",
|
||||
"portuguese": "Português",
|
||||
"french": "Français",
|
||||
"russian": "Русский",
|
||||
"german": "Deutsch",
|
||||
|
||||
"mnemonicNotFound": "No se encontró el mnemonic",
|
||||
"createPin": "Crear PIN",
|
||||
"enterPinDigits": "Ingresa un PIN de 4 dígitos",
|
||||
"confirmPin": "Confirmar PIN",
|
||||
"enterPinAgain": "Ingresa el PIN nuevamente",
|
||||
"pinMismatch": "Los PIN no coinciden",
|
||||
"pinActivated": "PIN activado",
|
||||
"pinDeactivated": "PIN desactivado",
|
||||
"verifyPin": "Verificar PIN",
|
||||
"enterCurrentPin": "Ingresa tu PIN actual",
|
||||
"incorrectPin": "PIN incorrecto",
|
||||
"selectLanguage": "Seleccionar idioma",
|
||||
"languageChanged": "Idioma cambiado a {language}",
|
||||
"@languageChanged": {
|
||||
"placeholders": {
|
||||
"language": { "type": "String" }
|
||||
}
|
||||
},
|
||||
"close": "Cerrar",
|
||||
"aboutDescription": "Un wallet de Cashu con ADN cubano para el mundo entero. Hermano de La Chispa.",
|
||||
"couldNotOpenLink": "No se pudo abrir el enlace",
|
||||
|
||||
"deleteWalletQuestion": "¿Borrar wallet?",
|
||||
"actionIrreversible": "Esta acción es irreversible",
|
||||
"deleteWalletWarning": "Se eliminarán todos los datos incluyendo tu seed phrase y tokens. Asegúrate de tener un backup.",
|
||||
"typeDeleteToConfirm": "Escribe \"BORRAR\" para confirmar:",
|
||||
"deleteConfirmWord": "BORRAR",
|
||||
"deleteError": "Error al borrar: {error}",
|
||||
"@deleteError": {
|
||||
"placeholders": {
|
||||
"error": { "type": "String" }
|
||||
}
|
||||
},
|
||||
|
||||
"recoverTokensTitle": "Recuperar tokens",
|
||||
"recoverTokensDescription": "Escanea los mints para recuperar tokens asociados a tu seed phrase (NUT-13)",
|
||||
"useCurrentSeedPhrase": "Usar mi seed phrase actual",
|
||||
"scanWithSavedWords": "Escanear mints con las 12 palabras guardadas",
|
||||
"useOtherSeedPhrase": "Usar otra seed phrase",
|
||||
"recoverFromOtherWords": "Recuperar tokens de otras 12 palabras",
|
||||
"mintsToScan": "Mints a escanear:",
|
||||
"allMints": "Todos los mints ({count})",
|
||||
"@allMints": {
|
||||
"placeholders": {
|
||||
"count": { "type": "int" }
|
||||
}
|
||||
},
|
||||
"specificMint": "Un mint específico",
|
||||
"enterMnemonicWords": "Ingresa las 12 palabras separadas por espacios...",
|
||||
"scanMints": "Escanear mints",
|
||||
"selectMintToScan": "Selecciona un mint para escanear",
|
||||
"mnemonicMustHaveWords": "El mnemonic debe tener 12 o 24 palabras",
|
||||
"noConnectedMintsToScan": "No hay mints conectados para escanear",
|
||||
"recoveredTokens": "¡Recuperados {tokens} de {mints} mint(s)!",
|
||||
"@recoveredTokens": {
|
||||
"placeholders": {
|
||||
"tokens": { "type": "String" },
|
||||
"mints": { "type": "int" }
|
||||
}
|
||||
},
|
||||
"scanCompleteNoTokens": "Escaneo completado. No se encontraron tokens nuevos.",
|
||||
"mintsWithError": "({count} mint(s) con error)",
|
||||
"@mintsWithError": {
|
||||
"placeholders": {
|
||||
"count": { "type": "int" }
|
||||
}
|
||||
},
|
||||
"recoveredFromMint": "¡Recuperados {tokens} de {mint}!",
|
||||
"@recoveredFromMint": {
|
||||
"placeholders": {
|
||||
"tokens": { "type": "String" },
|
||||
"mint": { "type": "String" }
|
||||
}
|
||||
},
|
||||
"noTokensFoundInMint": "No se encontraron tokens en {mint}.",
|
||||
"@noTokensFoundInMint": {
|
||||
"placeholders": {
|
||||
"mint": { "type": "String" }
|
||||
}
|
||||
},
|
||||
"recoveredAndTransferred": "¡Recuperados y transferidos {amount} {unit} a tu wallet!",
|
||||
"@recoveredAndTransferred": {
|
||||
"placeholders": {
|
||||
"amount": { "type": "String" },
|
||||
"unit": { "type": "String" }
|
||||
}
|
||||
},
|
||||
"noTokensForMnemonic": "No se encontraron tokens asociados a ese mnemonic.",
|
||||
|
||||
"noConnectedMints": "No hay mints conectados",
|
||||
"addMintToStart": "Agrega un mint para comenzar",
|
||||
"addMint": "Agregar mint",
|
||||
"mintDeleted": "Mint eliminado",
|
||||
"activeMintUpdated": "Mint activo actualizado",
|
||||
"mintUrl": "URL del mint:",
|
||||
"mintUrlPlaceholder": "https://mint.example.com",
|
||||
"urlMustStartWithHttps": "La URL debe comenzar con https://",
|
||||
"connectingToMint": "Conectando al mint...",
|
||||
"mintAddedSuccessfully": "Mint agregado correctamente",
|
||||
"couldNotConnectToMint": "No se pudo conectar al mint",
|
||||
"add": "Agregar",
|
||||
|
||||
"confirm": "Confirmar",
|
||||
"cancel": "Cancelar",
|
||||
"error": "Error",
|
||||
"success": "Éxito",
|
||||
"loading": "Cargando...",
|
||||
"retry": "Reintentar"
|
||||
"retry": "Reintentar",
|
||||
|
||||
"activeMint": "Mint activo",
|
||||
"mintMessage": "Mensaje del Mint",
|
||||
"url": "URL",
|
||||
"currency": "Moneda",
|
||||
"unknown": "Desconocido",
|
||||
"useThisMint": "Usar este mint",
|
||||
"copyMintUrl": "Copiar URL del mint",
|
||||
"deleteMint": "Eliminar mint",
|
||||
"copied": "{label} copiado",
|
||||
"@copied": {
|
||||
"placeholders": {
|
||||
"label": { "type": "String" }
|
||||
}
|
||||
},
|
||||
"deleteMintConfirmTitle": "Eliminar mint",
|
||||
"deleteMintConfirmMessage": "Si tienes balance en este mint, se perderá. ¿Estás seguro?",
|
||||
"delete": "Eliminar",
|
||||
|
||||
"offlineSend": "Envío Offline",
|
||||
"selectNotesToSend": "Selecciona las notas que deseas enviar:",
|
||||
"totalToSend": "Total a enviar",
|
||||
"notesSelected": "{count} notas seleccionadas",
|
||||
"@notesSelected": {
|
||||
"placeholders": {
|
||||
"count": { "type": "int" }
|
||||
}
|
||||
},
|
||||
"loadingProofsError": "Error cargando proofs: {error}",
|
||||
"@loadingProofsError": {
|
||||
"placeholders": {
|
||||
"error": { "type": "String" }
|
||||
}
|
||||
},
|
||||
"creatingTokenError": "Error creando token: {error}",
|
||||
"@creatingTokenError": {
|
||||
"placeholders": {
|
||||
"error": { "type": "String" }
|
||||
}
|
||||
},
|
||||
|
||||
"unknownState": "Estado desconocido",
|
||||
"depositAmountTitle": "Depositar {amount} {unit}",
|
||||
"@depositAmountTitle": {
|
||||
"placeholders": {
|
||||
"amount": { "type": "String" },
|
||||
"unit": { "type": "String" }
|
||||
}
|
||||
},
|
||||
|
||||
"receiveNow": "Recibir ahora",
|
||||
"receiveLater": "Recibir después",
|
||||
"tokenSavedForLater": "Token guardado para reclamar después",
|
||||
"noConnectionTokenSaved": "Sin conexión. Token guardado para reclamar después.",
|
||||
"unknownMintOffline": "Este token es de un mint desconocido. Conéctate a internet para agregarlo y reclamar el token.",
|
||||
"noConnectionTryLater": "Sin conexión al mint. Intenta más tarde.",
|
||||
"saveTokenError": "Error al guardar el token. Intenta de nuevo.",
|
||||
"pendingTokenLimitReached": "Límite de tokens pendientes alcanzado (max 50)",
|
||||
"filterToReceive": "Para recibir",
|
||||
"noPendingTokens": "Sin tokens pendientes",
|
||||
"noPendingTokensHint": "Guarda tokens para reclamar después",
|
||||
"pendingBadge": "PENDIENTE",
|
||||
"expiresInDays": "{days, plural, =1{Expira en 1 día} other{Expira en {days} días}}",
|
||||
"@expiresInDays": {
|
||||
"placeholders": {
|
||||
"days": { "type": "int" }
|
||||
}
|
||||
},
|
||||
"retryCount": "{count} reintentos",
|
||||
"@retryCount": {
|
||||
"placeholders": {
|
||||
"count": { "type": "int" }
|
||||
}
|
||||
},
|
||||
"claimNow": "Reclamar ahora",
|
||||
"pendingTokenClaimedSuccess": "Reclamados {amount} {unit}",
|
||||
"@pendingTokenClaimedSuccess": {
|
||||
"placeholders": {
|
||||
"amount": { "type": "String" },
|
||||
"unit": { "type": "String" }
|
||||
}
|
||||
},
|
||||
"pendingTokensClaimed": "{count, plural, =1{Reclamado 1 token ({amount} {unit})} other{Reclamados {count} tokens ({amount} {unit})}}",
|
||||
"@pendingTokensClaimed": {
|
||||
"placeholders": {
|
||||
"count": { "type": "int" },
|
||||
"amount": { "type": "String" },
|
||||
"unit": { "type": "String" }
|
||||
}
|
||||
},
|
||||
|
||||
"scan": "Escanear",
|
||||
"scanQrCode": "Escanear QR",
|
||||
"scanCashuToken": "Escanear token Cashu",
|
||||
"scanLightningInvoice": "Escanear invoice",
|
||||
"scanningAnimatedQr": "Escaneando QR animado...",
|
||||
"pointCameraAtQr": "Apunta la cámara al código QR",
|
||||
"pointCameraAtCashuQr": "Apunta la cámara al QR del token Cashu",
|
||||
"pointCameraAtInvoiceQr": "Apunta la cámara al QR del invoice",
|
||||
"unrecognizedQrCode": "Código QR no reconocido",
|
||||
"scanCashuTokenHint": "Escanea un token Cashu (cashuA... o cashuB...)",
|
||||
"scanLightningInvoiceHint": "Escanea un invoice Lightning (lnbc...)",
|
||||
"addMintQuestion": "¿Agregar este mint?",
|
||||
"cameraPermissionDenied": "Permiso de cámara denegado",
|
||||
"paymentRequestNotSupported": "Los payment requests aún no están soportados"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,484 @@
|
||||
{
|
||||
"@@locale": "fr",
|
||||
|
||||
"appName": "ElCaju",
|
||||
"appTagline": "Votre portefeuille ecash privé",
|
||||
"loadingMessage1": "Chiffrement de vos pièces...",
|
||||
"loadingMessage2": "Préparation de vos e-tokens...",
|
||||
"loadingMessage3": "Connexion au Mint...",
|
||||
"loadingMessage4": "Confidentialité par défaut.",
|
||||
"loadingMessage5": "Signature aveugle des tokens...",
|
||||
"loadingMessage6": "Go full Calle...",
|
||||
"loadingMessage7": "Cashu + Bitchat = Confidentialité + Liberté",
|
||||
"aboutTagline": "Confidentialité sans frontières.",
|
||||
|
||||
"welcomeTitle": "Bienvenue sur ElCaju",
|
||||
"welcomeSubtitle": "Cashu pour le monde. Fait à Cuba.",
|
||||
|
||||
"createWallet": "Créer un nouveau portefeuille",
|
||||
"restoreWallet": "Restaurer le portefeuille",
|
||||
|
||||
"createWalletTitle": "Créer un portefeuille",
|
||||
"creatingWallet": "Création de votre portefeuille...",
|
||||
"generatingSeed": "Génération sécurisée de votre phrase de récupération",
|
||||
"createWalletDescription": "Une phrase de récupération de 12 mots sera générée.\nConservez-la dans un endroit sûr.",
|
||||
"generateWallet": "Générer le portefeuille",
|
||||
|
||||
"walletCreated": "Portefeuille créé !",
|
||||
"walletCreatedDescription": "Votre portefeuille est prêt. Nous vous recommandons de sauvegarder votre phrase de récupération maintenant.",
|
||||
"backupWarning": "Sans sauvegarde, vous perdrez l'accès à vos fonds si vous perdez l'appareil.",
|
||||
"backupNow": "Sauvegarder maintenant",
|
||||
"backupLater": "Plus tard",
|
||||
|
||||
"backupTitle": "Sauvegarde",
|
||||
"seedPhraseTitle": "Votre phrase de récupération",
|
||||
"seedPhraseDescription": "Conservez ces 12 mots dans l'ordre. C'est le seul moyen de récupérer votre portefeuille.",
|
||||
"revealSeedPhrase": "Révéler la phrase de récupération",
|
||||
"tapToReveal": "Appuyez sur le bouton pour révéler\nvotre phrase de récupération",
|
||||
"copyToClipboard": "Copier dans le presse-papiers",
|
||||
"seedCopied": "Phrase copiée dans le presse-papiers",
|
||||
"neverShareSeed": "Ne partagez jamais votre phrase de récupération avec personne.",
|
||||
"confirmBackup": "J'ai sauvegardé ma phrase de récupération dans un endroit sûr",
|
||||
"continue_": "Continuer",
|
||||
|
||||
"restoreTitle": "Restaurer le portefeuille",
|
||||
"enterSeedPhrase": "Entrez votre phrase de récupération",
|
||||
"enterSeedDescription": "Tapez les 12 ou 24 mots séparés par des espaces.",
|
||||
"seedPlaceholder": "mot1 mot2 mot3 ...",
|
||||
"wordCount": "{count} mots",
|
||||
"@wordCount": {
|
||||
"placeholders": {
|
||||
"count": { "type": "int" }
|
||||
}
|
||||
},
|
||||
"needWords": "(vous avez besoin de 12 ou 24)",
|
||||
"restoreError": "Erreur de restauration : {error}",
|
||||
"@restoreError": {
|
||||
"placeholders": {
|
||||
"error": { "type": "String" }
|
||||
}
|
||||
},
|
||||
|
||||
"homeTitle": "Accueil",
|
||||
"receive": "Recevoir",
|
||||
"send": "Envoyer",
|
||||
"sendAction": "Envoyer ↗",
|
||||
"receiveAction": "↘ Recevoir",
|
||||
"deposit": "Déposer",
|
||||
"withdraw": "Retirer",
|
||||
"lightning": "Lightning",
|
||||
"cashu": "Cashu",
|
||||
"ecash": "Ecash",
|
||||
"history": "Historique",
|
||||
"noTransactions": "Aucune transaction",
|
||||
"depositOrReceive": "Déposez ou recevez des sats pour commencer",
|
||||
"noMint": "Aucun mint",
|
||||
|
||||
"balance": "Solde",
|
||||
"sats": "sats",
|
||||
|
||||
"pasteEcashToken": "Coller le token ecash",
|
||||
"generateInvoiceToDeposit": "Générer une facture pour déposer",
|
||||
"createEcashToken": "Créer un token ecash",
|
||||
"payLightningInvoice": "Payer une facture Lightning",
|
||||
|
||||
"receiveCashu": "Recevoir Cashu",
|
||||
"pasteTheCashuToken": "Collez le token Cashu :",
|
||||
"pasteFromClipboard": "Coller depuis le presse-papiers",
|
||||
"validToken": "Token valide",
|
||||
"invalidToken": "Token invalide ou malformé",
|
||||
"amount": "Montant :",
|
||||
"mint": "Mint :",
|
||||
"claiming": "Réclamation...",
|
||||
"claimTokens": "Réclamer les tokens",
|
||||
"tokensReceived": "Tokens reçus",
|
||||
"backToHome": "Retour à l'accueil",
|
||||
"tokenAlreadyClaimed": "Ce token a déjà été réclamé",
|
||||
"unknownMint": "Token d'un mint inconnu",
|
||||
"claimError": "Erreur de réclamation : {error}",
|
||||
"@claimError": {
|
||||
"placeholders": {
|
||||
"error": { "type": "String" }
|
||||
}
|
||||
},
|
||||
|
||||
"sendCashu": "Envoyer Cashu",
|
||||
"selectNotesManually": "Sélectionner les notes manuellement",
|
||||
"amountToSend": "Montant à envoyer :",
|
||||
"available": "Disponible :",
|
||||
"max": "(Max)",
|
||||
"memoOptional": "Mémo (optionnel) :",
|
||||
"memoPlaceholder": "À quoi sert ce paiement ?",
|
||||
"creatingToken": "Création du token...",
|
||||
"createToken": "Créer le token",
|
||||
"noActiveMint": "Aucun mint actif",
|
||||
"offlineModeMessage": "Pas de connexion. Mode hors ligne...",
|
||||
"confirmSend": "Confirmer l'envoi",
|
||||
"confirm": "Confirmer",
|
||||
"cancel": "Annuler",
|
||||
"insufficientBalance": "Solde insuffisant",
|
||||
"tokenCreationError": "Erreur de création du token : {error}",
|
||||
"@tokenCreationError": {
|
||||
"placeholders": {
|
||||
"error": { "type": "String" }
|
||||
}
|
||||
},
|
||||
|
||||
"tokenCreated": "Token créé",
|
||||
"copy": "Copier",
|
||||
"share": "Partager",
|
||||
"tokenCashu": "Token Cashu",
|
||||
"tokenCashuAnimatedQr": "Token Cashu (QR animé - {fragments} fragments UR)",
|
||||
"@tokenCashuAnimatedQr": {
|
||||
"placeholders": {
|
||||
"fragments": { "type": "int" }
|
||||
}
|
||||
},
|
||||
"keepTokenWarning": "Conservez ce token jusqu'à ce que le destinataire le réclame. Si vous le perdez, vous perdrez les fonds.",
|
||||
"tokenCopiedToClipboard": "Token copié dans le presse-papiers",
|
||||
|
||||
"amountToDeposit": "Montant à déposer :",
|
||||
"descriptionOptional": "Description (optionnelle) :",
|
||||
"depositPlaceholder": "À quoi sert ce dépôt ?",
|
||||
"generating": "Génération...",
|
||||
"generateInvoice": "Générer la facture",
|
||||
"depositLightning": "Déposer Lightning",
|
||||
|
||||
"payInvoiceTitle": "Payer la facture",
|
||||
"generatingInvoice": "Génération de la facture...",
|
||||
"waitingForPayment": "En attente du paiement...",
|
||||
"paymentReceived": "Paiement reçu",
|
||||
"tokensIssued": "Tokens émis !",
|
||||
"error": "Erreur",
|
||||
"unknownError": "Erreur inconnue",
|
||||
"back": "Retour",
|
||||
"copyInvoice": "Copier la facture",
|
||||
"description": "Description :",
|
||||
"invoiceCopiedToClipboard": "Facture copiée dans le presse-papiers",
|
||||
"deposited": "{amount} {unit} déposés",
|
||||
"@deposited": {
|
||||
"placeholders": {
|
||||
"amount": { "type": "String" },
|
||||
"unit": { "type": "String" }
|
||||
}
|
||||
},
|
||||
|
||||
"pasteLightningInvoice": "Collez la facture Lightning :",
|
||||
"gettingQuote": "Obtention du devis...",
|
||||
"validInvoice": "Facture valide",
|
||||
"invalidInvoice": "Facture invalide",
|
||||
"invalidInvoiceMalformed": "Facture invalide ou malformée",
|
||||
"feeReserved": "Frais réservés :",
|
||||
"total": "Total :",
|
||||
"paying": "Paiement...",
|
||||
"payInvoice": "Payer la facture",
|
||||
"confirmPayment": "Confirmer le paiement",
|
||||
"pay": "Payer",
|
||||
"fee": "frais",
|
||||
"invoiceExpired": "Facture expirée",
|
||||
"amountOutOfRange": "Montant hors de la plage autorisée",
|
||||
"resolvingType": "Résolution de {type}...",
|
||||
"@resolvingType": {
|
||||
"placeholders": {
|
||||
"type": { "type": "String" }
|
||||
}
|
||||
},
|
||||
"invoiceAlreadyPaid": "Facture déjà payée",
|
||||
"paymentError": "Erreur de paiement : {error}",
|
||||
"@paymentError": {
|
||||
"placeholders": {
|
||||
"error": { "type": "String" }
|
||||
}
|
||||
},
|
||||
"sent": "{amount} {unit} envoyés",
|
||||
"@sent": {
|
||||
"placeholders": {
|
||||
"amount": { "type": "String" },
|
||||
"unit": { "type": "String" }
|
||||
}
|
||||
},
|
||||
|
||||
"filterAll": "Tous",
|
||||
"filterPending": "En attente",
|
||||
"filterEcash": "Ecash",
|
||||
"filterLightning": "Lightning",
|
||||
"receiveTokensToStart": "Recevez des tokens Cashu pour commencer",
|
||||
"noPendingTransactions": "Aucune transaction en attente",
|
||||
"allTransactionsCompleted": "Toutes vos transactions sont terminées",
|
||||
"noEcashTransactions": "Aucune transaction Ecash",
|
||||
"sendOrReceiveTokens": "Envoyez ou recevez des tokens Cashu",
|
||||
"noLightningTransactions": "Aucune transaction Lightning",
|
||||
"depositOrWithdrawLightning": "Déposez ou retirez via Lightning",
|
||||
"pendingStatus": "En attente",
|
||||
"receivedStatus": "Reçu",
|
||||
"sentStatus": "Envoyé",
|
||||
"now": "Maintenant",
|
||||
"agoMinutes": "Il y a {minutes} min",
|
||||
"@agoMinutes": {
|
||||
"placeholders": {
|
||||
"minutes": { "type": "int" }
|
||||
}
|
||||
},
|
||||
"agoHours": "Il y a {hours} h",
|
||||
"@agoHours": {
|
||||
"placeholders": {
|
||||
"hours": { "type": "int" }
|
||||
}
|
||||
},
|
||||
"agoDays": "Il y a {days} jours",
|
||||
"@agoDays": {
|
||||
"placeholders": {
|
||||
"days": { "type": "int" }
|
||||
}
|
||||
},
|
||||
"lightningInvoice": "Facture Lightning",
|
||||
"receivedEcash": "Ecash reçu",
|
||||
"sentEcash": "Ecash envoyé",
|
||||
"outgoingLightningPayment": "Paiement Lightning sortant",
|
||||
"invoiceNotAvailable": "Facture non disponible",
|
||||
"tokenNotAvailable": "Token non disponible",
|
||||
"unit": "Unité",
|
||||
"status": "Statut",
|
||||
"pending": "En attente",
|
||||
"memo": "Mémo",
|
||||
"copyInvoiceButton": "COPIER LA FACTURE",
|
||||
"copyButton": "COPIER",
|
||||
"invoiceCopied": "Facture copiée",
|
||||
"tokenCopied": "Token copié",
|
||||
"speed": "VITESSE :",
|
||||
|
||||
"settings": "Paramètres",
|
||||
"walletSection": "PORTEFEUILLE",
|
||||
"backupSeedPhrase": "Sauvegarder la phrase de récupération",
|
||||
"viewRecoveryWords": "Voir vos mots de récupération",
|
||||
"connectedMints": "Mints connectés",
|
||||
"manageCashuMints": "Gérer vos mints Cashu",
|
||||
"pinAccess": "Code PIN",
|
||||
"pinEnabled": "Activé",
|
||||
"protectWithPin": "Protéger l'app avec un PIN",
|
||||
"recoverTokens": "Récupérer les tokens",
|
||||
"scanMintsWithSeed": "Scanner les mints avec la phrase de récupération",
|
||||
"appearanceSection": "LANGUE",
|
||||
"language": "Langue",
|
||||
"informationSection": "INFORMATIONS",
|
||||
"version": "Version",
|
||||
"about": "À propos",
|
||||
"deleteWallet": "Supprimer le portefeuille",
|
||||
"spanish": "Español",
|
||||
"english": "English",
|
||||
"portuguese": "Português",
|
||||
"french": "Français",
|
||||
"russian": "Русский",
|
||||
"german": "Deutsch",
|
||||
|
||||
"mnemonicNotFound": "Mnémonique non trouvé",
|
||||
"createPin": "Créer un PIN",
|
||||
"enterPinDigits": "Entrez un PIN à 4 chiffres",
|
||||
"confirmPin": "Confirmer le PIN",
|
||||
"enterPinAgain": "Entrez le PIN à nouveau",
|
||||
"pinMismatch": "Les PIN ne correspondent pas",
|
||||
"pinActivated": "PIN activé",
|
||||
"pinDeactivated": "PIN désactivé",
|
||||
"verifyPin": "Vérifier le PIN",
|
||||
"enterCurrentPin": "Entrez votre PIN actuel",
|
||||
"incorrectPin": "PIN incorrect",
|
||||
"selectLanguage": "Sélectionner la langue",
|
||||
"languageChanged": "Langue changée en {language}",
|
||||
"@languageChanged": {
|
||||
"placeholders": {
|
||||
"language": { "type": "String" }
|
||||
}
|
||||
},
|
||||
"close": "Fermer",
|
||||
"aboutDescription": "Un portefeuille Cashu avec ADN cubain pour le monde entier. Frère de La Chispa.",
|
||||
"couldNotOpenLink": "Impossible d'ouvrir le lien",
|
||||
|
||||
"deleteWalletQuestion": "Supprimer le portefeuille ?",
|
||||
"actionIrreversible": "Cette action est irréversible",
|
||||
"deleteWalletWarning": "Toutes les données seront supprimées, y compris votre phrase de récupération et vos tokens. Assurez-vous d'avoir une sauvegarde.",
|
||||
"typeDeleteToConfirm": "Tapez \"SUPPRIMER\" pour confirmer :",
|
||||
"deleteConfirmWord": "SUPPRIMER",
|
||||
"deleteError": "Erreur de suppression : {error}",
|
||||
"@deleteError": {
|
||||
"placeholders": {
|
||||
"error": { "type": "String" }
|
||||
}
|
||||
},
|
||||
|
||||
"recoverTokensTitle": "Récupérer les tokens",
|
||||
"recoverTokensDescription": "Scanner les mints pour récupérer les tokens associés à votre phrase de récupération (NUT-13)",
|
||||
"useCurrentSeedPhrase": "Utiliser ma phrase de récupération actuelle",
|
||||
"scanWithSavedWords": "Scanner les mints avec les 12 mots sauvegardés",
|
||||
"useOtherSeedPhrase": "Utiliser une autre phrase de récupération",
|
||||
"recoverFromOtherWords": "Récupérer les tokens d'autres 12 mots",
|
||||
"mintsToScan": "Mints à scanner :",
|
||||
"allMints": "Tous les mints ({count})",
|
||||
"@allMints": {
|
||||
"placeholders": {
|
||||
"count": { "type": "int" }
|
||||
}
|
||||
},
|
||||
"specificMint": "Un mint spécifique",
|
||||
"enterMnemonicWords": "Entrez les 12 mots séparés par des espaces...",
|
||||
"scanMints": "Scanner les mints",
|
||||
"selectMintToScan": "Sélectionnez un mint à scanner",
|
||||
"mnemonicMustHaveWords": "Le mnémonique doit avoir 12 ou 24 mots",
|
||||
"noConnectedMintsToScan": "Aucun mint connecté à scanner",
|
||||
"recoveredTokens": "Récupéré {tokens} de {mints} mint(s) !",
|
||||
"@recoveredTokens": {
|
||||
"placeholders": {
|
||||
"tokens": { "type": "String" },
|
||||
"mints": { "type": "int" }
|
||||
}
|
||||
},
|
||||
"scanCompleteNoTokens": "Scan terminé. Aucun nouveau token trouvé.",
|
||||
"mintsWithError": "({count} mint(s) avec erreur)",
|
||||
"@mintsWithError": {
|
||||
"placeholders": {
|
||||
"count": { "type": "int" }
|
||||
}
|
||||
},
|
||||
"recoveredFromMint": "Récupéré {tokens} de {mint} !",
|
||||
"@recoveredFromMint": {
|
||||
"placeholders": {
|
||||
"tokens": { "type": "String" },
|
||||
"mint": { "type": "String" }
|
||||
}
|
||||
},
|
||||
"noTokensFoundInMint": "Aucun token trouvé dans {mint}.",
|
||||
"@noTokensFoundInMint": {
|
||||
"placeholders": {
|
||||
"mint": { "type": "String" }
|
||||
}
|
||||
},
|
||||
"recoveredAndTransferred": "Récupéré et transféré {amount} {unit} vers votre portefeuille !",
|
||||
"@recoveredAndTransferred": {
|
||||
"placeholders": {
|
||||
"amount": { "type": "String" },
|
||||
"unit": { "type": "String" }
|
||||
}
|
||||
},
|
||||
"noTokensForMnemonic": "Aucun token trouvé associé à ce mnémonique.",
|
||||
|
||||
"noConnectedMints": "Aucun mint connecté",
|
||||
"addMintToStart": "Ajoutez un mint pour commencer",
|
||||
"addMint": "Ajouter un mint",
|
||||
"mintDeleted": "Mint supprimé",
|
||||
"activeMintUpdated": "Mint actif mis à jour",
|
||||
"mintUrl": "URL du mint :",
|
||||
"mintUrlPlaceholder": "https://mint.example.com",
|
||||
"urlMustStartWithHttps": "L'URL doit commencer par https://",
|
||||
"connectingToMint": "Connexion au mint...",
|
||||
"mintAddedSuccessfully": "Mint ajouté avec succès",
|
||||
"couldNotConnectToMint": "Impossible de se connecter au mint",
|
||||
"add": "Ajouter",
|
||||
|
||||
"success": "Succès",
|
||||
"loading": "Chargement...",
|
||||
"retry": "Réessayer",
|
||||
|
||||
"activeMint": "Mint actif",
|
||||
"mintMessage": "Message du Mint",
|
||||
"url": "URL",
|
||||
"currency": "Devise",
|
||||
"unknown": "Inconnu",
|
||||
"useThisMint": "Utiliser ce mint",
|
||||
"copyMintUrl": "Copier l'URL du mint",
|
||||
"deleteMint": "Supprimer le mint",
|
||||
"copied": "{label} copié",
|
||||
"@copied": {
|
||||
"placeholders": {
|
||||
"label": { "type": "String" }
|
||||
}
|
||||
},
|
||||
"deleteMintConfirmTitle": "Supprimer le mint",
|
||||
"deleteMintConfirmMessage": "Si vous avez un solde sur ce mint, il sera perdu. Êtes-vous sûr ?",
|
||||
"delete": "Supprimer",
|
||||
|
||||
"offlineSend": "Envoi hors ligne",
|
||||
"selectNotesToSend": "Sélectionnez les notes à envoyer :",
|
||||
"totalToSend": "Total à envoyer",
|
||||
"notesSelected": "{count} notes sélectionnées",
|
||||
"@notesSelected": {
|
||||
"placeholders": {
|
||||
"count": { "type": "int" }
|
||||
}
|
||||
},
|
||||
"loadingProofsError": "Erreur de chargement des preuves : {error}",
|
||||
"@loadingProofsError": {
|
||||
"placeholders": {
|
||||
"error": { "type": "String" }
|
||||
}
|
||||
},
|
||||
"creatingTokenError": "Erreur de création du token : {error}",
|
||||
"@creatingTokenError": {
|
||||
"placeholders": {
|
||||
"error": { "type": "String" }
|
||||
}
|
||||
},
|
||||
|
||||
"unknownState": "État inconnu",
|
||||
"depositAmountTitle": "Déposer {amount} {unit}",
|
||||
"@depositAmountTitle": {
|
||||
"placeholders": {
|
||||
"amount": { "type": "String" },
|
||||
"unit": { "type": "String" }
|
||||
}
|
||||
},
|
||||
|
||||
"receiveNow": "Recevoir maintenant",
|
||||
"receiveLater": "Recevoir plus tard",
|
||||
"tokenSavedForLater": "Token sauvegardé pour réclamer plus tard",
|
||||
"noConnectionTokenSaved": "Pas de connexion. Token sauvegardé pour réclamer plus tard.",
|
||||
"unknownMintOffline": "Ce token provient d'un mint inconnu. Connectez-vous à Internet pour l'ajouter et réclamer le token.",
|
||||
"noConnectionTryLater": "Pas de connexion au mint. Réessayez plus tard.",
|
||||
"saveTokenError": "Erreur lors de la sauvegarde du token. Veuillez réessayer.",
|
||||
"pendingTokenLimitReached": "Limite de tokens en attente atteinte (max 50)",
|
||||
"filterToReceive": "À recevoir",
|
||||
"noPendingTokens": "Aucun token en attente",
|
||||
"noPendingTokensHint": "Sauvegardez des tokens pour les réclamer plus tard",
|
||||
"pendingBadge": "EN ATTENTE",
|
||||
"expiresInDays": "{days, plural, =1{Expire dans 1 jour} other{Expire dans {days} jours}}",
|
||||
"@expiresInDays": {
|
||||
"placeholders": {
|
||||
"days": { "type": "int" }
|
||||
}
|
||||
},
|
||||
"retryCount": "{count} tentatives",
|
||||
"@retryCount": {
|
||||
"placeholders": {
|
||||
"count": { "type": "int" }
|
||||
}
|
||||
},
|
||||
"claimNow": "Réclamer maintenant",
|
||||
"pendingTokenClaimedSuccess": "Réclamé {amount} {unit}",
|
||||
"@pendingTokenClaimedSuccess": {
|
||||
"placeholders": {
|
||||
"amount": { "type": "String" },
|
||||
"unit": { "type": "String" }
|
||||
}
|
||||
},
|
||||
"pendingTokensClaimed": "{count, plural, =1{Réclamé 1 token ({amount} {unit})} other{Réclamé {count} tokens ({amount} {unit})}}",
|
||||
"@pendingTokensClaimed": {
|
||||
"placeholders": {
|
||||
"count": { "type": "int" },
|
||||
"amount": { "type": "String" },
|
||||
"unit": { "type": "String" }
|
||||
}
|
||||
},
|
||||
|
||||
"scan": "Scanner",
|
||||
"scanQrCode": "Scanner le QR",
|
||||
"scanCashuToken": "Scanner un token Cashu",
|
||||
"scanLightningInvoice": "Scanner une facture",
|
||||
"scanningAnimatedQr": "Scan du QR animé...",
|
||||
"pointCameraAtQr": "Pointez la caméra vers le code QR",
|
||||
"pointCameraAtCashuQr": "Pointez la caméra vers le QR du token Cashu",
|
||||
"pointCameraAtInvoiceQr": "Pointez la caméra vers le QR de la facture",
|
||||
"unrecognizedQrCode": "Code QR non reconnu",
|
||||
"scanCashuTokenHint": "Scannez un token Cashu (cashuA... ou cashuB...)",
|
||||
"scanLightningInvoiceHint": "Scannez une facture Lightning (lnbc...)",
|
||||
"addMintQuestion": "Ajouter ce mint ?",
|
||||
"cameraPermissionDenied": "Permission de la caméra refusée",
|
||||
"paymentRequestNotSupported": "Les demandes de paiement ne sont pas encore prises en charge"
|
||||
}
|
||||
@@ -0,0 +1,484 @@
|
||||
{
|
||||
"@@locale": "pt",
|
||||
|
||||
"appName": "ElCaju",
|
||||
"appTagline": "Sua wallet de ecash privada",
|
||||
"loadingMessage1": "Criptografando suas moedas...",
|
||||
"loadingMessage2": "Preparando seus e-tokens...",
|
||||
"loadingMessage3": "Conectando ao Mint...",
|
||||
"loadingMessage4": "Privacidade por padrão.",
|
||||
"loadingMessage5": "Assinando tokens cegamente...",
|
||||
"loadingMessage6": "Go full Calle...",
|
||||
"loadingMessage7": "Cashu + Bitchat = Privacidade + Liberdade",
|
||||
"aboutTagline": "Privacidade sem fronteiras.",
|
||||
|
||||
"welcomeTitle": "Bem-vindo ao ElCaju",
|
||||
"welcomeSubtitle": "Cashu para o mundo. Feito em Cuba.",
|
||||
|
||||
"createWallet": "Criar nova wallet",
|
||||
"restoreWallet": "Restaurar wallet",
|
||||
|
||||
"createWalletTitle": "Criar wallet",
|
||||
"creatingWallet": "Criando sua wallet...",
|
||||
"generatingSeed": "Gerando sua frase semente de forma segura",
|
||||
"createWalletDescription": "Uma frase semente de 12 palavras será gerada.\nGuarde-a em um lugar seguro.",
|
||||
"generateWallet": "Gerar wallet",
|
||||
|
||||
"walletCreated": "Wallet criada!",
|
||||
"walletCreatedDescription": "Sua wallet está pronta. Recomendamos fazer backup da sua frase semente agora.",
|
||||
"backupWarning": "Sem backup, você perderá acesso aos seus fundos se perder o dispositivo.",
|
||||
"backupNow": "Fazer backup agora",
|
||||
"backupLater": "Fazer depois",
|
||||
|
||||
"backupTitle": "Backup",
|
||||
"seedPhraseTitle": "Sua frase semente",
|
||||
"seedPhraseDescription": "Guarde estas 12 palavras em ordem. Elas são a única forma de recuperar sua wallet.",
|
||||
"revealSeedPhrase": "Revelar frase semente",
|
||||
"tapToReveal": "Toque no botão para revelar\nsua frase semente",
|
||||
"copyToClipboard": "Copiar para área de transferência",
|
||||
"seedCopied": "Frase copiada para área de transferência",
|
||||
"neverShareSeed": "Nunca compartilhe sua frase semente com ninguém.",
|
||||
"confirmBackup": "Guardei minha frase semente em um lugar seguro",
|
||||
"continue_": "Continuar",
|
||||
|
||||
"restoreTitle": "Restaurar wallet",
|
||||
"enterSeedPhrase": "Digite sua frase semente",
|
||||
"enterSeedDescription": "Digite as 12 ou 24 palavras separadas por espaços.",
|
||||
"seedPlaceholder": "palavra1 palavra2 palavra3 ...",
|
||||
"wordCount": "{count} palavras",
|
||||
"@wordCount": {
|
||||
"placeholders": {
|
||||
"count": { "type": "int" }
|
||||
}
|
||||
},
|
||||
"needWords": "(você precisa de 12 ou 24)",
|
||||
"restoreError": "Erro ao restaurar: {error}",
|
||||
"@restoreError": {
|
||||
"placeholders": {
|
||||
"error": { "type": "String" }
|
||||
}
|
||||
},
|
||||
|
||||
"homeTitle": "Início",
|
||||
"receive": "Receber",
|
||||
"send": "Enviar",
|
||||
"sendAction": "Enviar ↗",
|
||||
"receiveAction": "↘ Receber",
|
||||
"deposit": "Depositar",
|
||||
"withdraw": "Sacar",
|
||||
"lightning": "Lightning",
|
||||
"cashu": "Cashu",
|
||||
"ecash": "Ecash",
|
||||
"history": "Histórico",
|
||||
"noTransactions": "Sem transações ainda",
|
||||
"depositOrReceive": "Deposite ou receba sats para começar",
|
||||
"noMint": "Sem mint",
|
||||
|
||||
"balance": "Saldo",
|
||||
"sats": "sats",
|
||||
|
||||
"pasteEcashToken": "Colar token ecash",
|
||||
"generateInvoiceToDeposit": "Gerar invoice para depositar",
|
||||
"createEcashToken": "Criar token ecash",
|
||||
"payLightningInvoice": "Pagar invoice Lightning",
|
||||
|
||||
"receiveCashu": "Receber Cashu",
|
||||
"pasteTheCashuToken": "Cole o token Cashu:",
|
||||
"pasteFromClipboard": "Colar da área de transferência",
|
||||
"validToken": "Token válido",
|
||||
"invalidToken": "Token inválido ou malformado",
|
||||
"amount": "Valor:",
|
||||
"mint": "Mint:",
|
||||
"claiming": "Resgatando...",
|
||||
"claimTokens": "Resgatar tokens",
|
||||
"tokensReceived": "Tokens recebidos",
|
||||
"backToHome": "Voltar ao início",
|
||||
"tokenAlreadyClaimed": "Este token já foi resgatado",
|
||||
"unknownMint": "Token de um mint desconhecido",
|
||||
"claimError": "Erro ao resgatar: {error}",
|
||||
"@claimError": {
|
||||
"placeholders": {
|
||||
"error": { "type": "String" }
|
||||
}
|
||||
},
|
||||
|
||||
"sendCashu": "Enviar Cashu",
|
||||
"selectNotesManually": "Selecionar notas manualmente",
|
||||
"amountToSend": "Valor a enviar:",
|
||||
"available": "Disponível:",
|
||||
"max": "(Máx)",
|
||||
"memoOptional": "Memo (opcional):",
|
||||
"memoPlaceholder": "Para que é este pagamento?",
|
||||
"creatingToken": "Criando token...",
|
||||
"createToken": "Criar token",
|
||||
"noActiveMint": "Nenhum mint ativo",
|
||||
"offlineModeMessage": "Sem conexão. Usando modo offline...",
|
||||
"confirmSend": "Confirmar envio",
|
||||
"confirm": "Confirmar",
|
||||
"cancel": "Cancelar",
|
||||
"insufficientBalance": "Saldo insuficiente",
|
||||
"tokenCreationError": "Erro ao criar token: {error}",
|
||||
"@tokenCreationError": {
|
||||
"placeholders": {
|
||||
"error": { "type": "String" }
|
||||
}
|
||||
},
|
||||
|
||||
"tokenCreated": "Token criado",
|
||||
"copy": "Copiar",
|
||||
"share": "Compartilhar",
|
||||
"tokenCashu": "Token Cashu",
|
||||
"tokenCashuAnimatedQr": "Token Cashu (QR animado - {fragments} fragmentos UR)",
|
||||
"@tokenCashuAnimatedQr": {
|
||||
"placeholders": {
|
||||
"fragments": { "type": "int" }
|
||||
}
|
||||
},
|
||||
"keepTokenWarning": "Guarde este token até que o destinatário o resgate. Se você perdê-lo, perderá os fundos.",
|
||||
"tokenCopiedToClipboard": "Token copiado para área de transferência",
|
||||
|
||||
"amountToDeposit": "Valor a depositar:",
|
||||
"descriptionOptional": "Descrição (opcional):",
|
||||
"depositPlaceholder": "Para que é este depósito?",
|
||||
"generating": "Gerando...",
|
||||
"generateInvoice": "Gerar invoice",
|
||||
"depositLightning": "Depositar Lightning",
|
||||
|
||||
"payInvoiceTitle": "Pagar invoice",
|
||||
"generatingInvoice": "Gerando invoice...",
|
||||
"waitingForPayment": "Aguardando pagamento...",
|
||||
"paymentReceived": "Pagamento recebido",
|
||||
"tokensIssued": "Tokens emitidos!",
|
||||
"error": "Erro",
|
||||
"unknownError": "Erro desconhecido",
|
||||
"back": "Voltar",
|
||||
"copyInvoice": "Copiar invoice",
|
||||
"description": "Descrição:",
|
||||
"invoiceCopiedToClipboard": "Invoice copiado para área de transferência",
|
||||
"deposited": "{amount} {unit} depositados",
|
||||
"@deposited": {
|
||||
"placeholders": {
|
||||
"amount": { "type": "String" },
|
||||
"unit": { "type": "String" }
|
||||
}
|
||||
},
|
||||
|
||||
"pasteLightningInvoice": "Cole o invoice Lightning:",
|
||||
"gettingQuote": "Obtendo cotação...",
|
||||
"validInvoice": "Invoice válido",
|
||||
"invalidInvoice": "Invoice inválido",
|
||||
"invalidInvoiceMalformed": "Invoice inválido ou malformado",
|
||||
"feeReserved": "Taxa reservada:",
|
||||
"total": "Total:",
|
||||
"paying": "Pagando...",
|
||||
"payInvoice": "Pagar invoice",
|
||||
"confirmPayment": "Confirmar pagamento",
|
||||
"pay": "Pagar",
|
||||
"fee": "taxa",
|
||||
"invoiceExpired": "Invoice expirado",
|
||||
"amountOutOfRange": "Valor fora do intervalo permitido",
|
||||
"resolvingType": "Resolvendo {type}...",
|
||||
"@resolvingType": {
|
||||
"placeholders": {
|
||||
"type": { "type": "String" }
|
||||
}
|
||||
},
|
||||
"invoiceAlreadyPaid": "Invoice já foi pago",
|
||||
"paymentError": "Erro ao pagar: {error}",
|
||||
"@paymentError": {
|
||||
"placeholders": {
|
||||
"error": { "type": "String" }
|
||||
}
|
||||
},
|
||||
"sent": "{amount} {unit} enviados",
|
||||
"@sent": {
|
||||
"placeholders": {
|
||||
"amount": { "type": "String" },
|
||||
"unit": { "type": "String" }
|
||||
}
|
||||
},
|
||||
|
||||
"filterAll": "Todos",
|
||||
"filterPending": "Pendentes",
|
||||
"filterEcash": "Ecash",
|
||||
"filterLightning": "Lightning",
|
||||
"receiveTokensToStart": "Receba tokens Cashu para começar",
|
||||
"noPendingTransactions": "Sem transações pendentes",
|
||||
"allTransactionsCompleted": "Todas as suas transações estão completas",
|
||||
"noEcashTransactions": "Sem transações Ecash",
|
||||
"sendOrReceiveTokens": "Envie ou receba tokens Cashu",
|
||||
"noLightningTransactions": "Sem transações Lightning",
|
||||
"depositOrWithdrawLightning": "Deposite ou saque via Lightning",
|
||||
"pendingStatus": "Pendente",
|
||||
"receivedStatus": "Recebido",
|
||||
"sentStatus": "Enviado",
|
||||
"now": "Agora",
|
||||
"agoMinutes": "Há {minutes} min",
|
||||
"@agoMinutes": {
|
||||
"placeholders": {
|
||||
"minutes": { "type": "int" }
|
||||
}
|
||||
},
|
||||
"agoHours": "Há {hours} h",
|
||||
"@agoHours": {
|
||||
"placeholders": {
|
||||
"hours": { "type": "int" }
|
||||
}
|
||||
},
|
||||
"agoDays": "Há {days} dias",
|
||||
"@agoDays": {
|
||||
"placeholders": {
|
||||
"days": { "type": "int" }
|
||||
}
|
||||
},
|
||||
"lightningInvoice": "Invoice Lightning",
|
||||
"receivedEcash": "Ecash Recebido",
|
||||
"sentEcash": "Ecash Enviado",
|
||||
"outgoingLightningPayment": "Pagamento Lightning Enviado",
|
||||
"invoiceNotAvailable": "Invoice não disponível",
|
||||
"tokenNotAvailable": "Token não disponível",
|
||||
"unit": "Unidade",
|
||||
"status": "Status",
|
||||
"pending": "Pendente",
|
||||
"memo": "Memo",
|
||||
"copyInvoiceButton": "COPIAR INVOICE",
|
||||
"copyButton": "COPIAR",
|
||||
"invoiceCopied": "Invoice copiado",
|
||||
"tokenCopied": "Token copiado",
|
||||
"speed": "VELOCIDADE:",
|
||||
|
||||
"settings": "Configurações",
|
||||
"walletSection": "WALLET",
|
||||
"backupSeedPhrase": "Backup da frase semente",
|
||||
"viewRecoveryWords": "Ver suas palavras de recuperação",
|
||||
"connectedMints": "Mints conectados",
|
||||
"manageCashuMints": "Gerenciar seus mints Cashu",
|
||||
"pinAccess": "PIN de acesso",
|
||||
"pinEnabled": "Ativado",
|
||||
"protectWithPin": "Proteger o app com PIN",
|
||||
"recoverTokens": "Recuperar tokens",
|
||||
"scanMintsWithSeed": "Escanear mints com frase semente",
|
||||
"appearanceSection": "IDIOMA",
|
||||
"language": "Idioma",
|
||||
"informationSection": "INFORMAÇÃO",
|
||||
"version": "Versão",
|
||||
"about": "Sobre",
|
||||
"deleteWallet": "Apagar wallet",
|
||||
"spanish": "Español",
|
||||
"english": "English",
|
||||
"portuguese": "Português",
|
||||
"french": "Français",
|
||||
"russian": "Русский",
|
||||
"german": "Deutsch",
|
||||
|
||||
"mnemonicNotFound": "Mnemônico não encontrado",
|
||||
"createPin": "Criar PIN",
|
||||
"enterPinDigits": "Digite um PIN de 4 dígitos",
|
||||
"confirmPin": "Confirmar PIN",
|
||||
"enterPinAgain": "Digite o PIN novamente",
|
||||
"pinMismatch": "Os PINs não coincidem",
|
||||
"pinActivated": "PIN ativado",
|
||||
"pinDeactivated": "PIN desativado",
|
||||
"verifyPin": "Verificar PIN",
|
||||
"enterCurrentPin": "Digite seu PIN atual",
|
||||
"incorrectPin": "PIN incorreto",
|
||||
"selectLanguage": "Selecionar idioma",
|
||||
"languageChanged": "Idioma alterado para {language}",
|
||||
"@languageChanged": {
|
||||
"placeholders": {
|
||||
"language": { "type": "String" }
|
||||
}
|
||||
},
|
||||
"close": "Fechar",
|
||||
"aboutDescription": "Uma wallet Cashu com DNA cubano para o mundo inteiro. Irmã de La Chispa.",
|
||||
"couldNotOpenLink": "Não foi possível abrir o link",
|
||||
|
||||
"deleteWalletQuestion": "Apagar wallet?",
|
||||
"actionIrreversible": "Esta ação é irreversível",
|
||||
"deleteWalletWarning": "Todos os dados serão excluídos, incluindo sua frase semente e tokens. Certifique-se de ter um backup.",
|
||||
"typeDeleteToConfirm": "Digite \"APAGAR\" para confirmar:",
|
||||
"deleteConfirmWord": "APAGAR",
|
||||
"deleteError": "Erro ao apagar: {error}",
|
||||
"@deleteError": {
|
||||
"placeholders": {
|
||||
"error": { "type": "String" }
|
||||
}
|
||||
},
|
||||
|
||||
"recoverTokensTitle": "Recuperar tokens",
|
||||
"recoverTokensDescription": "Escanear mints para recuperar tokens associados à sua frase semente (NUT-13)",
|
||||
"useCurrentSeedPhrase": "Usar minha frase semente atual",
|
||||
"scanWithSavedWords": "Escanear mints com as 12 palavras salvas",
|
||||
"useOtherSeedPhrase": "Usar outra frase semente",
|
||||
"recoverFromOtherWords": "Recuperar tokens de outras 12 palavras",
|
||||
"mintsToScan": "Mints para escanear:",
|
||||
"allMints": "Todos os mints ({count})",
|
||||
"@allMints": {
|
||||
"placeholders": {
|
||||
"count": { "type": "int" }
|
||||
}
|
||||
},
|
||||
"specificMint": "Um mint específico",
|
||||
"enterMnemonicWords": "Digite as 12 palavras separadas por espaços...",
|
||||
"scanMints": "Escanear mints",
|
||||
"selectMintToScan": "Selecione um mint para escanear",
|
||||
"mnemonicMustHaveWords": "O mnemônico deve ter 12 ou 24 palavras",
|
||||
"noConnectedMintsToScan": "Nenhum mint conectado para escanear",
|
||||
"recoveredTokens": "Recuperados {tokens} de {mints} mint(s)!",
|
||||
"@recoveredTokens": {
|
||||
"placeholders": {
|
||||
"tokens": { "type": "String" },
|
||||
"mints": { "type": "int" }
|
||||
}
|
||||
},
|
||||
"scanCompleteNoTokens": "Escaneamento completo. Nenhum token novo encontrado.",
|
||||
"mintsWithError": "({count} mint(s) com erro)",
|
||||
"@mintsWithError": {
|
||||
"placeholders": {
|
||||
"count": { "type": "int" }
|
||||
}
|
||||
},
|
||||
"recoveredFromMint": "Recuperados {tokens} de {mint}!",
|
||||
"@recoveredFromMint": {
|
||||
"placeholders": {
|
||||
"tokens": { "type": "String" },
|
||||
"mint": { "type": "String" }
|
||||
}
|
||||
},
|
||||
"noTokensFoundInMint": "Nenhum token encontrado em {mint}.",
|
||||
"@noTokensFoundInMint": {
|
||||
"placeholders": {
|
||||
"mint": { "type": "String" }
|
||||
}
|
||||
},
|
||||
"recoveredAndTransferred": "Recuperados e transferidos {amount} {unit} para sua wallet!",
|
||||
"@recoveredAndTransferred": {
|
||||
"placeholders": {
|
||||
"amount": { "type": "String" },
|
||||
"unit": { "type": "String" }
|
||||
}
|
||||
},
|
||||
"noTokensForMnemonic": "Nenhum token encontrado associado a esse mnemônico.",
|
||||
|
||||
"noConnectedMints": "Nenhum mint conectado",
|
||||
"addMintToStart": "Adicione um mint para começar",
|
||||
"addMint": "Adicionar mint",
|
||||
"mintDeleted": "Mint excluído",
|
||||
"activeMintUpdated": "Mint ativo atualizado",
|
||||
"mintUrl": "URL do mint:",
|
||||
"mintUrlPlaceholder": "https://mint.example.com",
|
||||
"urlMustStartWithHttps": "A URL deve começar com https://",
|
||||
"connectingToMint": "Conectando ao mint...",
|
||||
"mintAddedSuccessfully": "Mint adicionado com sucesso",
|
||||
"couldNotConnectToMint": "Não foi possível conectar ao mint",
|
||||
"add": "Adicionar",
|
||||
|
||||
"success": "Sucesso",
|
||||
"loading": "Carregando...",
|
||||
"retry": "Tentar novamente",
|
||||
|
||||
"activeMint": "Mint ativo",
|
||||
"mintMessage": "Mensagem do Mint",
|
||||
"url": "URL",
|
||||
"currency": "Moeda",
|
||||
"unknown": "Desconhecido",
|
||||
"useThisMint": "Usar este mint",
|
||||
"copyMintUrl": "Copiar URL do mint",
|
||||
"deleteMint": "Excluir mint",
|
||||
"copied": "{label} copiado",
|
||||
"@copied": {
|
||||
"placeholders": {
|
||||
"label": { "type": "String" }
|
||||
}
|
||||
},
|
||||
"deleteMintConfirmTitle": "Excluir mint",
|
||||
"deleteMintConfirmMessage": "Se você tiver saldo neste mint, ele será perdido. Tem certeza?",
|
||||
"delete": "Excluir",
|
||||
|
||||
"offlineSend": "Envio Offline",
|
||||
"selectNotesToSend": "Selecione as notas que deseja enviar:",
|
||||
"totalToSend": "Total a enviar",
|
||||
"notesSelected": "{count} notas selecionadas",
|
||||
"@notesSelected": {
|
||||
"placeholders": {
|
||||
"count": { "type": "int" }
|
||||
}
|
||||
},
|
||||
"loadingProofsError": "Erro ao carregar provas: {error}",
|
||||
"@loadingProofsError": {
|
||||
"placeholders": {
|
||||
"error": { "type": "String" }
|
||||
}
|
||||
},
|
||||
"creatingTokenError": "Erro ao criar token: {error}",
|
||||
"@creatingTokenError": {
|
||||
"placeholders": {
|
||||
"error": { "type": "String" }
|
||||
}
|
||||
},
|
||||
|
||||
"unknownState": "Estado desconhecido",
|
||||
"depositAmountTitle": "Depositar {amount} {unit}",
|
||||
"@depositAmountTitle": {
|
||||
"placeholders": {
|
||||
"amount": { "type": "String" },
|
||||
"unit": { "type": "String" }
|
||||
}
|
||||
},
|
||||
|
||||
"receiveNow": "Receber agora",
|
||||
"receiveLater": "Receber depois",
|
||||
"tokenSavedForLater": "Token salvo para resgatar depois",
|
||||
"noConnectionTokenSaved": "Sem conexão. Token salvo para resgatar depois.",
|
||||
"unknownMintOffline": "Este token é de um mint desconhecido. Conecte-se à internet para adicioná-lo e resgatar o token.",
|
||||
"noConnectionTryLater": "Sem conexão ao mint. Tente mais tarde.",
|
||||
"saveTokenError": "Erro ao salvar o token. Tente novamente.",
|
||||
"pendingTokenLimitReached": "Limite de tokens pendentes atingido (máx 50)",
|
||||
"filterToReceive": "Para receber",
|
||||
"noPendingTokens": "Sem tokens pendentes",
|
||||
"noPendingTokensHint": "Salve tokens para resgatar depois",
|
||||
"pendingBadge": "PENDENTE",
|
||||
"expiresInDays": "{days, plural, =1{Expira em 1 dia} other{Expira em {days} dias}}",
|
||||
"@expiresInDays": {
|
||||
"placeholders": {
|
||||
"days": { "type": "int" }
|
||||
}
|
||||
},
|
||||
"retryCount": "{count} tentativas",
|
||||
"@retryCount": {
|
||||
"placeholders": {
|
||||
"count": { "type": "int" }
|
||||
}
|
||||
},
|
||||
"claimNow": "Resgatar agora",
|
||||
"pendingTokenClaimedSuccess": "Resgatados {amount} {unit}",
|
||||
"@pendingTokenClaimedSuccess": {
|
||||
"placeholders": {
|
||||
"amount": { "type": "String" },
|
||||
"unit": { "type": "String" }
|
||||
}
|
||||
},
|
||||
"pendingTokensClaimed": "{count, plural, =1{Resgatado 1 token ({amount} {unit})} other{Resgatados {count} tokens ({amount} {unit})}}",
|
||||
"@pendingTokensClaimed": {
|
||||
"placeholders": {
|
||||
"count": { "type": "int" },
|
||||
"amount": { "type": "String" },
|
||||
"unit": { "type": "String" }
|
||||
}
|
||||
},
|
||||
|
||||
"scan": "Escanear",
|
||||
"scanQrCode": "Escanear QR",
|
||||
"scanCashuToken": "Escanear token Cashu",
|
||||
"scanLightningInvoice": "Escanear invoice",
|
||||
"scanningAnimatedQr": "Escaneando QR animado...",
|
||||
"pointCameraAtQr": "Aponte a câmera para o código QR",
|
||||
"pointCameraAtCashuQr": "Aponte a câmera para o QR do token Cashu",
|
||||
"pointCameraAtInvoiceQr": "Aponte a câmera para o QR do invoice",
|
||||
"unrecognizedQrCode": "Código QR não reconhecido",
|
||||
"scanCashuTokenHint": "Escaneie um token Cashu (cashuA... ou cashuB...)",
|
||||
"scanLightningInvoiceHint": "Escaneie um invoice Lightning (lnbc...)",
|
||||
"addMintQuestion": "Adicionar este mint?",
|
||||
"cameraPermissionDenied": "Permissão de câmera negada",
|
||||
"paymentRequestNotSupported": "Solicitações de pagamento ainda não são suportadas"
|
||||
}
|
||||
@@ -0,0 +1,484 @@
|
||||
{
|
||||
"@@locale": "ru",
|
||||
|
||||
"appName": "ElCaju",
|
||||
"appTagline": "Ваш приватный ecash кошелёк",
|
||||
"loadingMessage1": "Шифрование ваших монет...",
|
||||
"loadingMessage2": "Подготовка ваших e-токенов...",
|
||||
"loadingMessage3": "Подключение к Mint...",
|
||||
"loadingMessage4": "Конфиденциальность по умолчанию.",
|
||||
"loadingMessage5": "Слепая подпись токенов...",
|
||||
"loadingMessage6": "Go full Calle...",
|
||||
"loadingMessage7": "Cashu + Bitchat = Конфиденциальность + Свобода",
|
||||
"aboutTagline": "Конфиденциальность без границ.",
|
||||
|
||||
"welcomeTitle": "Добро пожаловать в ElCaju",
|
||||
"welcomeSubtitle": "Cashu для мира. Сделано на Кубе.",
|
||||
|
||||
"createWallet": "Создать новый кошелёк",
|
||||
"restoreWallet": "Восстановить кошелёк",
|
||||
|
||||
"createWalletTitle": "Создать кошелёк",
|
||||
"creatingWallet": "Создание вашего кошелька...",
|
||||
"generatingSeed": "Безопасная генерация вашей сид-фразы",
|
||||
"createWalletDescription": "Будет сгенерирована сид-фраза из 12 слов.\nСохраните её в безопасном месте.",
|
||||
"generateWallet": "Создать кошелёк",
|
||||
|
||||
"walletCreated": "Кошелёк создан!",
|
||||
"walletCreatedDescription": "Ваш кошелёк готов. Рекомендуем сделать резервную копию сид-фразы сейчас.",
|
||||
"backupWarning": "Без резервной копии вы потеряете доступ к средствам при потере устройства.",
|
||||
"backupNow": "Сделать резервную копию",
|
||||
"backupLater": "Сделать позже",
|
||||
|
||||
"backupTitle": "Резервная копия",
|
||||
"seedPhraseTitle": "Ваша сид-фраза",
|
||||
"seedPhraseDescription": "Сохраните эти 12 слов по порядку. Это единственный способ восстановить кошелёк.",
|
||||
"revealSeedPhrase": "Показать сид-фразу",
|
||||
"tapToReveal": "Нажмите кнопку, чтобы показать\nвашу сид-фразу",
|
||||
"copyToClipboard": "Копировать в буфер обмена",
|
||||
"seedCopied": "Фраза скопирована в буфер обмена",
|
||||
"neverShareSeed": "Никогда не делитесь сид-фразой ни с кем.",
|
||||
"confirmBackup": "Я сохранил сид-фразу в безопасном месте",
|
||||
"continue_": "Продолжить",
|
||||
|
||||
"restoreTitle": "Восстановить кошелёк",
|
||||
"enterSeedPhrase": "Введите вашу сид-фразу",
|
||||
"enterSeedDescription": "Введите 12 или 24 слова через пробел.",
|
||||
"seedPlaceholder": "слово1 слово2 слово3 ...",
|
||||
"wordCount": "{count} слов",
|
||||
"@wordCount": {
|
||||
"placeholders": {
|
||||
"count": { "type": "int" }
|
||||
}
|
||||
},
|
||||
"needWords": "(нужно 12 или 24)",
|
||||
"restoreError": "Ошибка восстановления: {error}",
|
||||
"@restoreError": {
|
||||
"placeholders": {
|
||||
"error": { "type": "String" }
|
||||
}
|
||||
},
|
||||
|
||||
"homeTitle": "Главная",
|
||||
"receive": "Получить",
|
||||
"send": "Отправить",
|
||||
"sendAction": "Отправить ↗",
|
||||
"receiveAction": "↘ Получить",
|
||||
"deposit": "Пополнить",
|
||||
"withdraw": "Вывести",
|
||||
"lightning": "Lightning",
|
||||
"cashu": "Cashu",
|
||||
"ecash": "Ecash",
|
||||
"history": "История",
|
||||
"noTransactions": "Нет транзакций",
|
||||
"depositOrReceive": "Пополните или получите sats для начала",
|
||||
"noMint": "Нет mint",
|
||||
|
||||
"balance": "Баланс",
|
||||
"sats": "sats",
|
||||
|
||||
"pasteEcashToken": "Вставить ecash токен",
|
||||
"generateInvoiceToDeposit": "Создать счёт для пополнения",
|
||||
"createEcashToken": "Создать ecash токен",
|
||||
"payLightningInvoice": "Оплатить Lightning счёт",
|
||||
|
||||
"receiveCashu": "Получить Cashu",
|
||||
"pasteTheCashuToken": "Вставьте Cashu токен:",
|
||||
"pasteFromClipboard": "Вставить из буфера обмена",
|
||||
"validToken": "Токен действителен",
|
||||
"invalidToken": "Недействительный или повреждённый токен",
|
||||
"amount": "Сумма:",
|
||||
"mint": "Mint:",
|
||||
"claiming": "Получение...",
|
||||
"claimTokens": "Получить токены",
|
||||
"tokensReceived": "Токены получены",
|
||||
"backToHome": "Вернуться на главную",
|
||||
"tokenAlreadyClaimed": "Этот токен уже был получен",
|
||||
"unknownMint": "Токен от неизвестного mint",
|
||||
"claimError": "Ошибка получения: {error}",
|
||||
"@claimError": {
|
||||
"placeholders": {
|
||||
"error": { "type": "String" }
|
||||
}
|
||||
},
|
||||
|
||||
"sendCashu": "Отправить Cashu",
|
||||
"selectNotesManually": "Выбрать заметки вручную",
|
||||
"amountToSend": "Сумма для отправки:",
|
||||
"available": "Доступно:",
|
||||
"max": "(Макс)",
|
||||
"memoOptional": "Заметка (необязательно):",
|
||||
"memoPlaceholder": "Для чего этот платёж?",
|
||||
"creatingToken": "Создание токена...",
|
||||
"createToken": "Создать токен",
|
||||
"noActiveMint": "Нет активного mint",
|
||||
"offlineModeMessage": "Нет соединения. Офлайн режим...",
|
||||
"confirmSend": "Подтвердить отправку",
|
||||
"confirm": "Подтвердить",
|
||||
"cancel": "Отмена",
|
||||
"insufficientBalance": "Недостаточный баланс",
|
||||
"tokenCreationError": "Ошибка создания токена: {error}",
|
||||
"@tokenCreationError": {
|
||||
"placeholders": {
|
||||
"error": { "type": "String" }
|
||||
}
|
||||
},
|
||||
|
||||
"tokenCreated": "Токен создан",
|
||||
"copy": "Копировать",
|
||||
"share": "Поделиться",
|
||||
"tokenCashu": "Cashu токен",
|
||||
"tokenCashuAnimatedQr": "Cashu токен (анимированный QR - {fragments} UR фрагментов)",
|
||||
"@tokenCashuAnimatedQr": {
|
||||
"placeholders": {
|
||||
"fragments": { "type": "int" }
|
||||
}
|
||||
},
|
||||
"keepTokenWarning": "Сохраните этот токен, пока получатель не заберёт его. Если потеряете — потеряете средства.",
|
||||
"tokenCopiedToClipboard": "Токен скопирован в буфер обмена",
|
||||
|
||||
"amountToDeposit": "Сумма для пополнения:",
|
||||
"descriptionOptional": "Описание (необязательно):",
|
||||
"depositPlaceholder": "Для чего это пополнение?",
|
||||
"generating": "Генерация...",
|
||||
"generateInvoice": "Создать счёт",
|
||||
"depositLightning": "Пополнить Lightning",
|
||||
|
||||
"payInvoiceTitle": "Оплатить счёт",
|
||||
"generatingInvoice": "Создание счёта...",
|
||||
"waitingForPayment": "Ожидание оплаты...",
|
||||
"paymentReceived": "Платёж получен",
|
||||
"tokensIssued": "Токены выпущены!",
|
||||
"error": "Ошибка",
|
||||
"unknownError": "Неизвестная ошибка",
|
||||
"back": "Назад",
|
||||
"copyInvoice": "Копировать счёт",
|
||||
"description": "Описание:",
|
||||
"invoiceCopiedToClipboard": "Счёт скопирован в буфер обмена",
|
||||
"deposited": "{amount} {unit} пополнено",
|
||||
"@deposited": {
|
||||
"placeholders": {
|
||||
"amount": { "type": "String" },
|
||||
"unit": { "type": "String" }
|
||||
}
|
||||
},
|
||||
|
||||
"pasteLightningInvoice": "Вставьте Lightning счёт:",
|
||||
"gettingQuote": "Получение котировки...",
|
||||
"validInvoice": "Счёт действителен",
|
||||
"invalidInvoice": "Недействительный счёт",
|
||||
"invalidInvoiceMalformed": "Недействительный или повреждённый счёт",
|
||||
"feeReserved": "Зарезервированная комиссия:",
|
||||
"total": "Итого:",
|
||||
"paying": "Оплата...",
|
||||
"payInvoice": "Оплатить счёт",
|
||||
"confirmPayment": "Подтвердить оплату",
|
||||
"pay": "Оплатить",
|
||||
"fee": "комиссия",
|
||||
"invoiceExpired": "Счёт истёк",
|
||||
"amountOutOfRange": "Сумма вне допустимого диапазона",
|
||||
"resolvingType": "Разрешение {type}...",
|
||||
"@resolvingType": {
|
||||
"placeholders": {
|
||||
"type": { "type": "String" }
|
||||
}
|
||||
},
|
||||
"invoiceAlreadyPaid": "Счёт уже оплачен",
|
||||
"paymentError": "Ошибка оплаты: {error}",
|
||||
"@paymentError": {
|
||||
"placeholders": {
|
||||
"error": { "type": "String" }
|
||||
}
|
||||
},
|
||||
"sent": "{amount} {unit} отправлено",
|
||||
"@sent": {
|
||||
"placeholders": {
|
||||
"amount": { "type": "String" },
|
||||
"unit": { "type": "String" }
|
||||
}
|
||||
},
|
||||
|
||||
"filterAll": "Все",
|
||||
"filterPending": "Ожидающие",
|
||||
"filterEcash": "Ecash",
|
||||
"filterLightning": "Lightning",
|
||||
"receiveTokensToStart": "Получите Cashu токены для начала",
|
||||
"noPendingTransactions": "Нет ожидающих транзакций",
|
||||
"allTransactionsCompleted": "Все ваши транзакции завершены",
|
||||
"noEcashTransactions": "Нет Ecash транзакций",
|
||||
"sendOrReceiveTokens": "Отправьте или получите Cashu токены",
|
||||
"noLightningTransactions": "Нет Lightning транзакций",
|
||||
"depositOrWithdrawLightning": "Пополните или выведите через Lightning",
|
||||
"pendingStatus": "Ожидание",
|
||||
"receivedStatus": "Получено",
|
||||
"sentStatus": "Отправлено",
|
||||
"now": "Сейчас",
|
||||
"agoMinutes": "{minutes} мин назад",
|
||||
"@agoMinutes": {
|
||||
"placeholders": {
|
||||
"minutes": { "type": "int" }
|
||||
}
|
||||
},
|
||||
"agoHours": "{hours} ч назад",
|
||||
"@agoHours": {
|
||||
"placeholders": {
|
||||
"hours": { "type": "int" }
|
||||
}
|
||||
},
|
||||
"agoDays": "{days} дней назад",
|
||||
"@agoDays": {
|
||||
"placeholders": {
|
||||
"days": { "type": "int" }
|
||||
}
|
||||
},
|
||||
"lightningInvoice": "Lightning счёт",
|
||||
"receivedEcash": "Ecash получен",
|
||||
"sentEcash": "Ecash отправлен",
|
||||
"outgoingLightningPayment": "Исходящий Lightning платёж",
|
||||
"invoiceNotAvailable": "Счёт недоступен",
|
||||
"tokenNotAvailable": "Токен недоступен",
|
||||
"unit": "Единица",
|
||||
"status": "Статус",
|
||||
"pending": "Ожидание",
|
||||
"memo": "Заметка",
|
||||
"copyInvoiceButton": "КОПИРОВАТЬ СЧЁТ",
|
||||
"copyButton": "КОПИРОВАТЬ",
|
||||
"invoiceCopied": "Счёт скопирован",
|
||||
"tokenCopied": "Токен скопирован",
|
||||
"speed": "СКОРОСТЬ:",
|
||||
|
||||
"settings": "Настройки",
|
||||
"walletSection": "КОШЕЛЁК",
|
||||
"backupSeedPhrase": "Резервная копия сид-фразы",
|
||||
"viewRecoveryWords": "Посмотреть слова восстановления",
|
||||
"connectedMints": "Подключённые mint",
|
||||
"manageCashuMints": "Управление вашими Cashu mint",
|
||||
"pinAccess": "PIN-код",
|
||||
"pinEnabled": "Включён",
|
||||
"protectWithPin": "Защитить приложение PIN-кодом",
|
||||
"recoverTokens": "Восстановить токены",
|
||||
"scanMintsWithSeed": "Сканировать mint с сид-фразой",
|
||||
"appearanceSection": "ЯЗЫК",
|
||||
"language": "Язык",
|
||||
"informationSection": "ИНФОРМАЦИЯ",
|
||||
"version": "Версия",
|
||||
"about": "О приложении",
|
||||
"deleteWallet": "Удалить кошелёк",
|
||||
"spanish": "Español",
|
||||
"english": "English",
|
||||
"portuguese": "Português",
|
||||
"french": "Français",
|
||||
"russian": "Русский",
|
||||
"german": "Deutsch",
|
||||
|
||||
"mnemonicNotFound": "Мнемоника не найдена",
|
||||
"createPin": "Создать PIN",
|
||||
"enterPinDigits": "Введите 4-значный PIN",
|
||||
"confirmPin": "Подтвердить PIN",
|
||||
"enterPinAgain": "Введите PIN ещё раз",
|
||||
"pinMismatch": "PIN-коды не совпадают",
|
||||
"pinActivated": "PIN активирован",
|
||||
"pinDeactivated": "PIN деактивирован",
|
||||
"verifyPin": "Проверить PIN",
|
||||
"enterCurrentPin": "Введите текущий PIN",
|
||||
"incorrectPin": "Неверный PIN",
|
||||
"selectLanguage": "Выбрать язык",
|
||||
"languageChanged": "Язык изменён на {language}",
|
||||
"@languageChanged": {
|
||||
"placeholders": {
|
||||
"language": { "type": "String" }
|
||||
}
|
||||
},
|
||||
"close": "Закрыть",
|
||||
"aboutDescription": "Cashu кошелёк с кубинской ДНК для всего мира. Брат La Chispa.",
|
||||
"couldNotOpenLink": "Не удалось открыть ссылку",
|
||||
|
||||
"deleteWalletQuestion": "Удалить кошелёк?",
|
||||
"actionIrreversible": "Это действие необратимо",
|
||||
"deleteWalletWarning": "Все данные будут удалены, включая сид-фразу и токены. Убедитесь, что у вас есть резервная копия.",
|
||||
"typeDeleteToConfirm": "Введите \"УДАЛИТЬ\" для подтверждения:",
|
||||
"deleteConfirmWord": "УДАЛИТЬ",
|
||||
"deleteError": "Ошибка удаления: {error}",
|
||||
"@deleteError": {
|
||||
"placeholders": {
|
||||
"error": { "type": "String" }
|
||||
}
|
||||
},
|
||||
|
||||
"recoverTokensTitle": "Восстановить токены",
|
||||
"recoverTokensDescription": "Сканировать mint для восстановления токенов, связанных с вашей сид-фразой (NUT-13)",
|
||||
"useCurrentSeedPhrase": "Использовать текущую сид-фразу",
|
||||
"scanWithSavedWords": "Сканировать mint с сохранёнными 12 словами",
|
||||
"useOtherSeedPhrase": "Использовать другую сид-фразу",
|
||||
"recoverFromOtherWords": "Восстановить токены из других 12 слов",
|
||||
"mintsToScan": "Mint для сканирования:",
|
||||
"allMints": "Все mint ({count})",
|
||||
"@allMints": {
|
||||
"placeholders": {
|
||||
"count": { "type": "int" }
|
||||
}
|
||||
},
|
||||
"specificMint": "Конкретный mint",
|
||||
"enterMnemonicWords": "Введите 12 слов через пробел...",
|
||||
"scanMints": "Сканировать mint",
|
||||
"selectMintToScan": "Выберите mint для сканирования",
|
||||
"mnemonicMustHaveWords": "Мнемоника должна содержать 12 или 24 слова",
|
||||
"noConnectedMintsToScan": "Нет подключённых mint для сканирования",
|
||||
"recoveredTokens": "Восстановлено {tokens} из {mints} mint!",
|
||||
"@recoveredTokens": {
|
||||
"placeholders": {
|
||||
"tokens": { "type": "String" },
|
||||
"mints": { "type": "int" }
|
||||
}
|
||||
},
|
||||
"scanCompleteNoTokens": "Сканирование завершено. Новых токенов не найдено.",
|
||||
"mintsWithError": "({count} mint с ошибкой)",
|
||||
"@mintsWithError": {
|
||||
"placeholders": {
|
||||
"count": { "type": "int" }
|
||||
}
|
||||
},
|
||||
"recoveredFromMint": "Восстановлено {tokens} из {mint}!",
|
||||
"@recoveredFromMint": {
|
||||
"placeholders": {
|
||||
"tokens": { "type": "String" },
|
||||
"mint": { "type": "String" }
|
||||
}
|
||||
},
|
||||
"noTokensFoundInMint": "Токены не найдены в {mint}.",
|
||||
"@noTokensFoundInMint": {
|
||||
"placeholders": {
|
||||
"mint": { "type": "String" }
|
||||
}
|
||||
},
|
||||
"recoveredAndTransferred": "Восстановлено и переведено {amount} {unit} в ваш кошелёк!",
|
||||
"@recoveredAndTransferred": {
|
||||
"placeholders": {
|
||||
"amount": { "type": "String" },
|
||||
"unit": { "type": "String" }
|
||||
}
|
||||
},
|
||||
"noTokensForMnemonic": "Токены, связанные с этой мнемоникой, не найдены.",
|
||||
|
||||
"noConnectedMints": "Нет подключённых mint",
|
||||
"addMintToStart": "Добавьте mint для начала",
|
||||
"addMint": "Добавить mint",
|
||||
"mintDeleted": "Mint удалён",
|
||||
"activeMintUpdated": "Активный mint обновлён",
|
||||
"mintUrl": "URL mint:",
|
||||
"mintUrlPlaceholder": "https://mint.example.com",
|
||||
"urlMustStartWithHttps": "URL должен начинаться с https://",
|
||||
"connectingToMint": "Подключение к mint...",
|
||||
"mintAddedSuccessfully": "Mint успешно добавлен",
|
||||
"couldNotConnectToMint": "Не удалось подключиться к mint",
|
||||
"add": "Добавить",
|
||||
|
||||
"success": "Успех",
|
||||
"loading": "Загрузка...",
|
||||
"retry": "Повторить",
|
||||
|
||||
"activeMint": "Активный mint",
|
||||
"mintMessage": "Сообщение Mint",
|
||||
"url": "URL",
|
||||
"currency": "Валюта",
|
||||
"unknown": "Неизвестно",
|
||||
"useThisMint": "Использовать этот mint",
|
||||
"copyMintUrl": "Копировать URL mint",
|
||||
"deleteMint": "Удалить mint",
|
||||
"copied": "{label} скопировано",
|
||||
"@copied": {
|
||||
"placeholders": {
|
||||
"label": { "type": "String" }
|
||||
}
|
||||
},
|
||||
"deleteMintConfirmTitle": "Удалить mint",
|
||||
"deleteMintConfirmMessage": "Если у вас есть баланс на этом mint, он будет потерян. Вы уверены?",
|
||||
"delete": "Удалить",
|
||||
|
||||
"offlineSend": "Офлайн отправка",
|
||||
"selectNotesToSend": "Выберите заметки для отправки:",
|
||||
"totalToSend": "Итого к отправке",
|
||||
"notesSelected": "{count} заметок выбрано",
|
||||
"@notesSelected": {
|
||||
"placeholders": {
|
||||
"count": { "type": "int" }
|
||||
}
|
||||
},
|
||||
"loadingProofsError": "Ошибка загрузки доказательств: {error}",
|
||||
"@loadingProofsError": {
|
||||
"placeholders": {
|
||||
"error": { "type": "String" }
|
||||
}
|
||||
},
|
||||
"creatingTokenError": "Ошибка создания токена: {error}",
|
||||
"@creatingTokenError": {
|
||||
"placeholders": {
|
||||
"error": { "type": "String" }
|
||||
}
|
||||
},
|
||||
|
||||
"unknownState": "Неизвестное состояние",
|
||||
"depositAmountTitle": "Пополнить {amount} {unit}",
|
||||
"@depositAmountTitle": {
|
||||
"placeholders": {
|
||||
"amount": { "type": "String" },
|
||||
"unit": { "type": "String" }
|
||||
}
|
||||
},
|
||||
|
||||
"receiveNow": "Получить сейчас",
|
||||
"receiveLater": "Получить позже",
|
||||
"tokenSavedForLater": "Токен сохранён для получения позже",
|
||||
"noConnectionTokenSaved": "Нет соединения. Токен сохранён для получения позже.",
|
||||
"unknownMintOffline": "Этот токен от неизвестного mint. Подключитесь к интернету, чтобы добавить его и получить токен.",
|
||||
"noConnectionTryLater": "Нет соединения с mint. Попробуйте позже.",
|
||||
"saveTokenError": "Ошибка сохранения токена. Попробуйте снова.",
|
||||
"pendingTokenLimitReached": "Достигнут лимит ожидающих токенов (макс 50)",
|
||||
"filterToReceive": "К получению",
|
||||
"noPendingTokens": "Нет ожидающих токенов",
|
||||
"noPendingTokensHint": "Сохраните токены для получения позже",
|
||||
"pendingBadge": "ОЖИДАНИЕ",
|
||||
"expiresInDays": "{days, plural, =1{Истекает через 1 день} other{Истекает через {days} дней}}",
|
||||
"@expiresInDays": {
|
||||
"placeholders": {
|
||||
"days": { "type": "int" }
|
||||
}
|
||||
},
|
||||
"retryCount": "{count} попыток",
|
||||
"@retryCount": {
|
||||
"placeholders": {
|
||||
"count": { "type": "int" }
|
||||
}
|
||||
},
|
||||
"claimNow": "Получить сейчас",
|
||||
"pendingTokenClaimedSuccess": "Получено {amount} {unit}",
|
||||
"@pendingTokenClaimedSuccess": {
|
||||
"placeholders": {
|
||||
"amount": { "type": "String" },
|
||||
"unit": { "type": "String" }
|
||||
}
|
||||
},
|
||||
"pendingTokensClaimed": "{count, plural, =1{Получен 1 токен ({amount} {unit})} other{Получено {count} токенов ({amount} {unit})}}",
|
||||
"@pendingTokensClaimed": {
|
||||
"placeholders": {
|
||||
"count": { "type": "int" },
|
||||
"amount": { "type": "String" },
|
||||
"unit": { "type": "String" }
|
||||
}
|
||||
},
|
||||
|
||||
"scan": "Сканировать",
|
||||
"scanQrCode": "Сканировать QR",
|
||||
"scanCashuToken": "Сканировать Cashu токен",
|
||||
"scanLightningInvoice": "Сканировать счёт",
|
||||
"scanningAnimatedQr": "Сканирование анимированного QR...",
|
||||
"pointCameraAtQr": "Наведите камеру на QR-код",
|
||||
"pointCameraAtCashuQr": "Наведите камеру на QR Cashu токена",
|
||||
"pointCameraAtInvoiceQr": "Наведите камеру на QR счёта",
|
||||
"unrecognizedQrCode": "Нераспознанный QR-код",
|
||||
"scanCashuTokenHint": "Сканируйте Cashu токен (cashuA... или cashuB...)",
|
||||
"scanLightningInvoiceHint": "Сканируйте Lightning счёт (lnbc...)",
|
||||
"addMintQuestion": "Добавить этот mint?",
|
||||
"cameraPermissionDenied": "Доступ к камере запрещён",
|
||||
"paymentRequestNotSupported": "Запросы на оплату пока не поддерживаются"
|
||||
}
|
||||
+13
-1
@@ -7,6 +7,7 @@ import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
import 'core/theme/app_theme.dart';
|
||||
import 'providers/wallet_provider.dart';
|
||||
import 'providers/settings_provider.dart';
|
||||
import 'providers/price_provider.dart';
|
||||
import 'screens/1_splash/splash_screen.dart';
|
||||
|
||||
void main() async {
|
||||
@@ -30,11 +31,16 @@ void main() async {
|
||||
),
|
||||
);
|
||||
|
||||
// Inicializar PriceProvider
|
||||
final priceProvider = PriceProvider();
|
||||
priceProvider.initialize();
|
||||
|
||||
runApp(
|
||||
MultiProvider(
|
||||
providers: [
|
||||
ChangeNotifierProvider(create: (_) => SettingsProvider()),
|
||||
ChangeNotifierProvider(create: (_) => WalletProvider()),
|
||||
ChangeNotifierProvider.value(value: priceProvider),
|
||||
],
|
||||
child: const ElCajuApp(),
|
||||
),
|
||||
@@ -46,6 +52,8 @@ class ElCajuApp extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final settingsProvider = context.watch<SettingsProvider>();
|
||||
|
||||
return MaterialApp(
|
||||
title: 'ElCaju',
|
||||
debugShowCheckedModeBanner: false,
|
||||
@@ -61,8 +69,12 @@ class ElCajuApp extends StatelessWidget {
|
||||
supportedLocales: const [
|
||||
Locale('es'), // Español (por defecto)
|
||||
Locale('en'), // English
|
||||
Locale('pt'), // Português
|
||||
Locale('fr'), // Français
|
||||
Locale('ru'), // Русский
|
||||
Locale('de'), // Deutsch
|
||||
],
|
||||
locale: null, // null = detectar del sistema
|
||||
locale: Locale(settingsProvider.locale),
|
||||
|
||||
home: const SplashScreen(),
|
||||
);
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import '../core/services/price_service.dart';
|
||||
|
||||
/// Provider para manejar precios de BTC con caché
|
||||
class PriceProvider extends ChangeNotifier {
|
||||
/// Precio de BTC en USD (caché)
|
||||
double? _btcPriceUsd;
|
||||
|
||||
/// Precio de BTC en EUR (caché)
|
||||
double? _btcPriceEur;
|
||||
|
||||
/// Timestamp de última actualización
|
||||
DateTime? _lastUpdate;
|
||||
|
||||
/// Intervalo mínimo entre actualizaciones (60 segundos)
|
||||
static const _minRefreshInterval = Duration(seconds: 60);
|
||||
|
||||
/// Timer para refresh automático
|
||||
Timer? _refreshTimer;
|
||||
|
||||
/// Error de última operación
|
||||
String? _error;
|
||||
|
||||
/// Getters
|
||||
double? get btcPriceUsd => _btcPriceUsd;
|
||||
double? get btcPriceEur => _btcPriceEur;
|
||||
DateTime? get lastUpdate => _lastUpdate;
|
||||
String? get error => _error;
|
||||
bool get hasPrice => _btcPriceUsd != null;
|
||||
|
||||
/// Obtiene el precio de BTC para una moneda específica
|
||||
double? getBtcPrice(String currency) {
|
||||
switch (currency.toLowerCase()) {
|
||||
case 'usd':
|
||||
return _btcPriceUsd;
|
||||
case 'eur':
|
||||
return _btcPriceEur;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Verifica si necesita actualizar el precio
|
||||
bool get _needsRefresh {
|
||||
if (_lastUpdate == null) return true;
|
||||
return DateTime.now().difference(_lastUpdate!) > _minRefreshInterval;
|
||||
}
|
||||
|
||||
/// Inicializa el provider y comienza refresh automático
|
||||
Future<void> initialize() async {
|
||||
await refreshPrices();
|
||||
_startAutoRefresh();
|
||||
}
|
||||
|
||||
/// Inicia el timer de refresh automático
|
||||
void _startAutoRefresh() {
|
||||
_refreshTimer?.cancel();
|
||||
_refreshTimer = Timer.periodic(_minRefreshInterval, (_) {
|
||||
refreshPrices();
|
||||
});
|
||||
}
|
||||
|
||||
/// Actualiza los precios desde la API
|
||||
Future<void> refreshPrices() async {
|
||||
if (!_needsRefresh && _btcPriceUsd != null) return;
|
||||
|
||||
try {
|
||||
_error = null;
|
||||
|
||||
// Obtener precio USD
|
||||
_btcPriceUsd = await PriceService.getBtcPrice('USD');
|
||||
|
||||
// Intentar obtener EUR también
|
||||
try {
|
||||
_btcPriceEur = await PriceService.getBtcPrice('EUR');
|
||||
} catch (_) {
|
||||
// EUR es opcional
|
||||
}
|
||||
|
||||
_lastUpdate = DateTime.now();
|
||||
notifyListeners();
|
||||
} catch (e) {
|
||||
_error = e.toString();
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
/// Convierte cantidad en unidad fiat (centavos) a sats
|
||||
/// Usa caché si está disponible, sino llama a la API
|
||||
Future<BigInt> fiatCentsToSats(BigInt cents, String fiatCurrency) async {
|
||||
final btcPrice = getBtcPrice(fiatCurrency);
|
||||
|
||||
if (btcPrice != null) {
|
||||
// Usar precio cacheado
|
||||
final fiatAmount = cents.toDouble() / 100;
|
||||
final btcAmount = fiatAmount / btcPrice;
|
||||
final sats = (btcAmount * 100000000).round();
|
||||
return BigInt.from(sats);
|
||||
}
|
||||
|
||||
// Fallback a API directa
|
||||
return PriceService.fiatCentsToSats(cents, fiatCurrency);
|
||||
}
|
||||
|
||||
/// Convierte sats a cantidad en unidad fiat (centavos)
|
||||
Future<BigInt> satsToFiatCents(BigInt sats, String fiatCurrency) async {
|
||||
final btcPrice = getBtcPrice(fiatCurrency);
|
||||
|
||||
if (btcPrice != null) {
|
||||
// Usar precio cacheado
|
||||
final btcAmount = sats.toDouble() / 100000000;
|
||||
final fiatAmount = btcAmount * btcPrice;
|
||||
return BigInt.from((fiatAmount * 100).round());
|
||||
}
|
||||
|
||||
// Fallback a API directa
|
||||
return PriceService.satsToFiatCents(sats, fiatCurrency);
|
||||
}
|
||||
|
||||
/// Formatea el precio para display
|
||||
String formatBtcPrice(String currency) {
|
||||
final price = getBtcPrice(currency);
|
||||
if (price == null) return '--';
|
||||
|
||||
// Formatear con separador de miles
|
||||
return price.toStringAsFixed(0).replaceAllMapped(
|
||||
RegExp(r'(\d{1,3})(?=(\d{3})+(?!\d))'),
|
||||
(match) => '${match[1]},',
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_refreshTimer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,10 @@ import 'package:cdk_flutter/cdk_flutter.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
import '../data/transaction_meta_storage.dart';
|
||||
import '../data/pending_token.dart';
|
||||
import '../data/pending_token_storage.dart';
|
||||
|
||||
/// Helper class para info de token parseado
|
||||
class TokenInfo {
|
||||
@@ -33,6 +36,12 @@ class WalletProvider extends ChangeNotifier {
|
||||
/// Storage para metadata de transacciones (tipo, token, invoice)
|
||||
final TransactionMetaStorage _txMetaStorage = TransactionMetaStorage();
|
||||
|
||||
/// Storage para tokens pendientes de reclamar (Receive Later)
|
||||
final PendingTokenStorage _pendingTokenStorage = PendingTokenStorage();
|
||||
|
||||
/// Generador de UUIDs
|
||||
static const _uuid = Uuid();
|
||||
|
||||
/// Mints conocidos con sus unidades soportadas.
|
||||
/// Ejemplo: {'mint.cubabitcoin.org': ['sat', 'usd']}
|
||||
final Map<String, List<String>> _mintUnits = {};
|
||||
@@ -86,6 +95,19 @@ class WalletProvider extends ChangeNotifier {
|
||||
/// Lista de URLs de mints conocidos (orden de inserción)
|
||||
List<String> get mintUrls => _mintUnits.keys.toList();
|
||||
|
||||
// ============================================================
|
||||
// PENDING TOKENS GETTERS
|
||||
// ============================================================
|
||||
|
||||
/// Cantidad de tokens pendientes de reclamar
|
||||
int get pendingTokenCount => _pendingTokenStorage.count;
|
||||
|
||||
/// Verifica si hay tokens pendientes
|
||||
bool get hasPendingTokens => _pendingTokenStorage.hasPendingTokens;
|
||||
|
||||
/// Stream de cambios en pending tokens
|
||||
Stream<void> get pendingTokenChanges => _pendingTokenStorage.changes;
|
||||
|
||||
/// Lista de mints ordenados por balance.
|
||||
/// Cuba Bitcoin siempre primero, luego ordenados por: sats → usd → eur → otros.
|
||||
Future<List<String>> getSortedMintUrls() async {
|
||||
@@ -143,6 +165,26 @@ class WalletProvider extends ChangeNotifier {
|
||||
return result;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// CONNECTIVITY
|
||||
// ============================================================
|
||||
|
||||
/// Verifica si podemos alcanzar un mint específico.
|
||||
/// Hace ping HTTP GET a {mintUrl}/v1/info con timeout de 3 segundos.
|
||||
/// Retorna true si responde 200, false en cualquier otro caso.
|
||||
Future<bool> canReachMint(String mintUrl) async {
|
||||
try {
|
||||
final uri = Uri.parse('$mintUrl/v1/info');
|
||||
final response = await http.get(uri).timeout(
|
||||
const Duration(seconds: 3),
|
||||
);
|
||||
return response.statusCode == 200;
|
||||
} catch (e) {
|
||||
debugPrint('Ping to mint failed: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// MINT INFO
|
||||
// ============================================================
|
||||
@@ -275,6 +317,9 @@ class WalletProvider extends ChangeNotifier {
|
||||
// Inicializar storage de metadata de transacciones
|
||||
await _txMetaStorage.init();
|
||||
|
||||
// Inicializar storage de tokens pendientes
|
||||
await _pendingTokenStorage.init();
|
||||
|
||||
// Obtener directorio de documentos (path absoluto requerido)
|
||||
final dir = await getApplicationDocumentsDirectory();
|
||||
final dbPath = '${dir.path}/elcaju_wallet.sqlite';
|
||||
@@ -1307,4 +1352,159 @@ class WalletProvider extends ChangeNotifier {
|
||||
_db = null;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// PENDING TOKENS (Receive Later)
|
||||
// ============================================================
|
||||
|
||||
/// Guarda un token para reclamar después.
|
||||
/// Retorna el PendingToken creado o null si hay error/límite alcanzado.
|
||||
Future<PendingToken?> addPendingToken(String encodedToken) async {
|
||||
// Parsear token para validar y extraer info
|
||||
final tokenInfo = parseToken(encodedToken);
|
||||
if (tokenInfo == null) {
|
||||
debugPrint('Token inválido para pending');
|
||||
return null;
|
||||
}
|
||||
|
||||
// Generar ID único
|
||||
final id = _uuid.v4();
|
||||
|
||||
// Guardar en storage
|
||||
final pending = await _pendingTokenStorage.add(
|
||||
id: id,
|
||||
encoded: encodedToken,
|
||||
amount: tokenInfo.amount,
|
||||
mintUrl: tokenInfo.mintUrl,
|
||||
unit: tokenInfo.unit,
|
||||
);
|
||||
|
||||
if (pending != null) {
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
return pending;
|
||||
}
|
||||
|
||||
/// Lista todos los tokens pendientes
|
||||
List<PendingToken> listPendingTokens() {
|
||||
return _pendingTokenStorage.listAll();
|
||||
}
|
||||
|
||||
/// Lista solo tokens pendientes válidos (no expirados)
|
||||
List<PendingToken> listValidPendingTokens() {
|
||||
return _pendingTokenStorage.listValid();
|
||||
}
|
||||
|
||||
/// Elimina un token pendiente por ID
|
||||
Future<void> removePendingToken(String id) async {
|
||||
await _pendingTokenStorage.remove(id);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Intenta reclamar un token pendiente.
|
||||
/// Retorna el monto recibido si tiene éxito, o lanza excepción.
|
||||
/// Si el token está gastado o es inválido, lo elimina automáticamente.
|
||||
/// Verifica conectividad al mint antes de intentar reclamar.
|
||||
Future<BigInt> claimPendingToken(String id) async {
|
||||
final pending = _pendingTokenStorage.get(id);
|
||||
if (pending == null) {
|
||||
throw Exception('Token pendiente no encontrado');
|
||||
}
|
||||
|
||||
// Verificar conectividad al mint primero (evita esperas largas sin conexión)
|
||||
final canReach = await canReachMint(pending.mintUrl);
|
||||
if (!canReach) {
|
||||
throw Exception('No connection to mint');
|
||||
}
|
||||
|
||||
try {
|
||||
// Intentar reclamar usando el método existente
|
||||
final amount = await receiveToken(pending.encoded);
|
||||
|
||||
// Éxito: eliminar de pending
|
||||
await _pendingTokenStorage.remove(id);
|
||||
notifyListeners();
|
||||
|
||||
debugPrint('Token pendiente reclamado: $id, monto: $amount');
|
||||
return amount;
|
||||
} catch (e) {
|
||||
final errorStr = e.toString().toLowerCase();
|
||||
|
||||
// Si el token ya fue gastado o es inválido, eliminarlo
|
||||
if (errorStr.contains('already spent') ||
|
||||
errorStr.contains('token already') ||
|
||||
errorStr.contains('invalid')) {
|
||||
await _pendingTokenStorage.remove(id);
|
||||
notifyListeners();
|
||||
debugPrint('Token pendiente eliminado (gastado/inválido): $id');
|
||||
} else {
|
||||
// Otro error: registrar intento fallido
|
||||
await _pendingTokenStorage.recordFailedAttempt(id, e.toString());
|
||||
}
|
||||
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Verifica y reclama automáticamente tokens pendientes.
|
||||
/// Retorna un mapa con estadísticas: claimed, failed, removed, totalClaimed, unit.
|
||||
Future<Map<String, dynamic>> checkPendingTokens() async {
|
||||
final tokens = _pendingTokenStorage.listValid();
|
||||
if (tokens.isEmpty) {
|
||||
return {'claimed': 0, 'failed': 0, 'removed': 0, 'totalClaimed': BigInt.zero, 'unit': _activeUnit};
|
||||
}
|
||||
|
||||
int claimed = 0;
|
||||
int failed = 0;
|
||||
int removed = 0;
|
||||
BigInt totalClaimed = BigInt.zero;
|
||||
String? claimedUnit;
|
||||
|
||||
for (final token in tokens) {
|
||||
try {
|
||||
final amount = await claimPendingToken(token.id);
|
||||
claimed++;
|
||||
totalClaimed += amount;
|
||||
claimedUnit ??= token.unit; // Usar la unidad del primer token reclamado
|
||||
debugPrint('Auto-claim exitoso: ${token.id}');
|
||||
} catch (e) {
|
||||
final errorStr = e.toString().toLowerCase();
|
||||
if (errorStr.contains('already spent') ||
|
||||
errorStr.contains('token already') ||
|
||||
errorStr.contains('invalid')) {
|
||||
removed++;
|
||||
debugPrint('Auto-claim: token eliminado (gastado): ${token.id}');
|
||||
} else {
|
||||
failed++;
|
||||
debugPrint('Auto-claim fallido: ${token.id} - $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Limpiar expirados también
|
||||
final expired = await _pendingTokenStorage.cleanExpired();
|
||||
removed += expired;
|
||||
|
||||
if (claimed > 0 || removed > 0) {
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
return {
|
||||
'claimed': claimed,
|
||||
'failed': failed,
|
||||
'removed': removed,
|
||||
'totalClaimed': totalClaimed,
|
||||
'unit': claimedUnit ?? _activeUnit,
|
||||
};
|
||||
}
|
||||
|
||||
/// Limpia tokens pendientes expirados
|
||||
Future<int> cleanExpiredPendingTokens() async {
|
||||
final cleaned = await _pendingTokenStorage.cleanExpired();
|
||||
if (cleaned > 0) {
|
||||
notifyListeners();
|
||||
}
|
||||
return cleaned;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,365 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:lucide_icons/lucide_icons.dart';
|
||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
import '../../core/constants/colors.dart';
|
||||
import '../../core/utils/incoming_data_parser.dart';
|
||||
import '../../widgets/scanner/qr_scanner_widget.dart';
|
||||
import '../../providers/wallet_provider.dart';
|
||||
import '../4_receive/receive_screen.dart';
|
||||
import '../7_melt/melt_screen.dart';
|
||||
|
||||
/// Pantalla de escaneo QR con soporte para diferentes modos
|
||||
class ScanScreen extends StatefulWidget {
|
||||
/// Modo de escaneo
|
||||
final ScanMode mode;
|
||||
|
||||
/// Callback cuando se detecta un dato válido (para modos específicos)
|
||||
final void Function(String data)? onDataScanned;
|
||||
|
||||
const ScanScreen({
|
||||
super.key,
|
||||
this.mode = ScanMode.any,
|
||||
this.onDataScanned,
|
||||
});
|
||||
|
||||
@override
|
||||
State<ScanScreen> createState() => _ScanScreenState();
|
||||
}
|
||||
|
||||
class _ScanScreenState extends State<ScanScreen> {
|
||||
bool _isProcessing = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = L10n.of(context)!;
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.black,
|
||||
appBar: AppBar(
|
||||
backgroundColor: Colors.transparent,
|
||||
elevation: 0,
|
||||
leading: IconButton(
|
||||
icon: const Icon(LucideIcons.x, color: Colors.white),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
title: Text(
|
||||
_getTitleForMode(l10n),
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
centerTitle: true,
|
||||
),
|
||||
body: SafeArea(
|
||||
child: Stack(
|
||||
children: [
|
||||
// Scanner
|
||||
QrScannerWidget(
|
||||
onDetect: _onCodeDetected,
|
||||
showFlashControl: true,
|
||||
showCameraSwitch: false,
|
||||
),
|
||||
|
||||
// Instrucciones en la parte inferior
|
||||
Positioned(
|
||||
bottom: 100,
|
||||
left: 24,
|
||||
right: 24,
|
||||
child: Text(
|
||||
_getInstructionForMode(l10n),
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 16,
|
||||
color: Colors.white.withValues(alpha: 0.8),
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
|
||||
// Indicador de procesamiento
|
||||
if (_isProcessing)
|
||||
Container(
|
||||
color: Colors.black54,
|
||||
child: const Center(
|
||||
child: CircularProgressIndicator(
|
||||
color: AppColors.primaryAction,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _getTitleForMode(L10n l10n) {
|
||||
switch (widget.mode) {
|
||||
case ScanMode.any:
|
||||
return l10n.scanQrCode;
|
||||
case ScanMode.cashuOnly:
|
||||
return l10n.scanCashuToken;
|
||||
case ScanMode.invoiceOnly:
|
||||
return l10n.scanLightningInvoice;
|
||||
}
|
||||
}
|
||||
|
||||
String _getInstructionForMode(L10n l10n) {
|
||||
switch (widget.mode) {
|
||||
case ScanMode.any:
|
||||
return l10n.pointCameraAtQr;
|
||||
case ScanMode.cashuOnly:
|
||||
return l10n.pointCameraAtCashuQr;
|
||||
case ScanMode.invoiceOnly:
|
||||
return l10n.pointCameraAtInvoiceQr;
|
||||
}
|
||||
}
|
||||
|
||||
void _onCodeDetected(String rawData) async {
|
||||
if (_isProcessing) return;
|
||||
|
||||
setState(() => _isProcessing = true);
|
||||
|
||||
try {
|
||||
// Parsear los datos
|
||||
final parsed = IncomingDataParser.parse(rawData);
|
||||
|
||||
// Verificar si es válido para el modo actual
|
||||
if (!IncomingDataParser.isValidForMode(parsed, widget.mode)) {
|
||||
_showInvalidTypeError(parsed.type);
|
||||
return; // finally se encarga de setState
|
||||
}
|
||||
|
||||
// Procesar según el tipo y modo
|
||||
await _processData(parsed);
|
||||
} catch (e) {
|
||||
_showError(e.toString());
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() => _isProcessing = false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _processData(ParsedData data) async {
|
||||
switch (widget.mode) {
|
||||
case ScanMode.any:
|
||||
await _handleAnyMode(data);
|
||||
break;
|
||||
case ScanMode.cashuOnly:
|
||||
_handleCashuOnlyMode(data);
|
||||
break;
|
||||
case ScanMode.invoiceOnly:
|
||||
_handleInvoiceOnlyMode(data);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _handleAnyMode(ParsedData data) async {
|
||||
final l10n = L10n.of(context)!;
|
||||
|
||||
switch (data.type) {
|
||||
case IncomingDataType.cashuToken:
|
||||
// Navegar a ReceiveScreen con el token pre-cargado
|
||||
if (mounted) {
|
||||
Navigator.pop(context); // Cerrar scanner
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => ReceiveScreen(initialToken: data.raw),
|
||||
),
|
||||
);
|
||||
}
|
||||
break;
|
||||
|
||||
case IncomingDataType.lightningInvoice:
|
||||
// Navegar a MeltScreen con el invoice pre-cargado
|
||||
if (mounted) {
|
||||
Navigator.pop(context); // Cerrar scanner
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => MeltScreen(initialInvoice: data.invoiceBolt11),
|
||||
),
|
||||
);
|
||||
}
|
||||
break;
|
||||
|
||||
case IncomingDataType.mintUrl:
|
||||
// Mostrar diálogo para agregar mint
|
||||
if (mounted) {
|
||||
await _showAddMintDialog(data.mintUrl!);
|
||||
}
|
||||
break;
|
||||
|
||||
case IncomingDataType.paymentRequest:
|
||||
// TODO: Implementar manejo de payment requests (post-MVP)
|
||||
_showError(l10n.paymentRequestNotSupported);
|
||||
break;
|
||||
|
||||
case IncomingDataType.unknown:
|
||||
// Si es una URL https, intentar verificar si es un mint
|
||||
if (data.raw.toLowerCase().startsWith('https://')) {
|
||||
await _tryVerifyAndAddMint(data.raw);
|
||||
} else {
|
||||
_showError(l10n.unrecognizedQrCode);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
void _handleCashuOnlyMode(ParsedData data) {
|
||||
if (data.type == IncomingDataType.cashuToken) {
|
||||
// Retornar el token vía callback
|
||||
Navigator.pop(context, data.raw);
|
||||
widget.onDataScanned?.call(data.raw);
|
||||
} else {
|
||||
_showInvalidTypeError(data.type);
|
||||
}
|
||||
}
|
||||
|
||||
void _handleInvoiceOnlyMode(ParsedData data) {
|
||||
if (data.type == IncomingDataType.lightningInvoice) {
|
||||
// Retornar el invoice vía callback
|
||||
Navigator.pop(context, data.invoiceBolt11 ?? data.raw);
|
||||
widget.onDataScanned?.call(data.invoiceBolt11 ?? data.raw);
|
||||
} else {
|
||||
_showInvalidTypeError(data.type);
|
||||
}
|
||||
}
|
||||
|
||||
/// Intenta verificar si una URL es un mint válido antes de mostrar el diálogo
|
||||
Future<void> _tryVerifyAndAddMint(String rawUrl) async {
|
||||
final l10n = L10n.of(context)!;
|
||||
|
||||
try {
|
||||
// Extraer URL base
|
||||
final uri = Uri.parse(rawUrl);
|
||||
String mintUrl = '${uri.scheme}://${uri.host}';
|
||||
if (uri.port != 443 && uri.port != 0) {
|
||||
mintUrl += ':${uri.port}';
|
||||
}
|
||||
|
||||
// Verificar si es un mint real (timeout corto)
|
||||
final walletProvider = context.read<WalletProvider>();
|
||||
final isValidMint = await walletProvider.canReachMint(mintUrl);
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
if (isValidMint) {
|
||||
// Es un mint válido → mostrar diálogo
|
||||
await _showAddMintDialog(mintUrl);
|
||||
} else {
|
||||
// No es un mint → QR no reconocido
|
||||
_showError(l10n.unrecognizedQrCode);
|
||||
}
|
||||
} catch (_) {
|
||||
if (mounted) {
|
||||
_showError(l10n.unrecognizedQrCode);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _showAddMintDialog(String mintUrl) async {
|
||||
final l10n = L10n.of(context)!;
|
||||
|
||||
final result = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
backgroundColor: AppColors.deepVoidPurple,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
title: Text(
|
||||
l10n.addMintQuestion,
|
||||
style: const TextStyle(color: Colors.white),
|
||||
),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
mintUrl,
|
||||
style: TextStyle(
|
||||
color: AppColors.textSecondary,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
child: Text(
|
||||
l10n.cancel,
|
||||
style: const TextStyle(color: Colors.white70),
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
child: Text(
|
||||
l10n.add,
|
||||
style: const TextStyle(color: AppColors.primaryAction),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (result == true && mounted) {
|
||||
try {
|
||||
final walletProvider = context.read<WalletProvider>();
|
||||
await walletProvider.addMint(mintUrl);
|
||||
|
||||
if (mounted) {
|
||||
Navigator.pop(context); // Cerrar scanner
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(l10n.mintAddedSuccessfully),
|
||||
backgroundColor: AppColors.success,
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
_showError(l10n.couldNotConnectToMint);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _showInvalidTypeError(IncomingDataType detectedType) {
|
||||
final l10n = L10n.of(context)!;
|
||||
|
||||
String message;
|
||||
switch (widget.mode) {
|
||||
case ScanMode.cashuOnly:
|
||||
message = l10n.scanCashuTokenHint;
|
||||
break;
|
||||
case ScanMode.invoiceOnly:
|
||||
message = l10n.scanLightningInvoiceHint;
|
||||
break;
|
||||
case ScanMode.any:
|
||||
message = l10n.unrecognizedQrCode;
|
||||
break;
|
||||
}
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(message),
|
||||
backgroundColor: AppColors.warning,
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showError(String message) {
|
||||
if (!mounted) return;
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(message),
|
||||
backgroundColor: AppColors.error,
|
||||
duration: const Duration(seconds: 3),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'dart:async';
|
||||
import 'dart:math';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
@@ -25,6 +26,11 @@ class _SplashScreenState extends State<SplashScreen>
|
||||
late Animation<double> _fadeAnimation;
|
||||
String? _errorMessage;
|
||||
|
||||
// Mensajes de carga aleatorios
|
||||
int _currentMessageIndex = 0;
|
||||
Timer? _messageTimer;
|
||||
final _random = Random();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
@@ -39,10 +45,35 @@ class _SplashScreenState extends State<SplashScreen>
|
||||
|
||||
_controller.forward();
|
||||
|
||||
// Iniciar rotación de mensajes de carga
|
||||
_currentMessageIndex = _random.nextInt(7);
|
||||
_messageTimer = Timer.periodic(const Duration(milliseconds: 800), (_) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_currentMessageIndex = _random.nextInt(7);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Inicializar la app
|
||||
_initializeApp();
|
||||
}
|
||||
|
||||
/// Obtiene el mensaje de carga actual basado en el índice
|
||||
String _getLoadingMessage(L10n? l10n) {
|
||||
if (l10n == null) return 'Cargando...';
|
||||
switch (_currentMessageIndex) {
|
||||
case 0: return l10n.loadingMessage1;
|
||||
case 1: return l10n.loadingMessage2;
|
||||
case 2: return l10n.loadingMessage3;
|
||||
case 3: return l10n.loadingMessage4;
|
||||
case 4: return l10n.loadingMessage5;
|
||||
case 5: return l10n.loadingMessage6;
|
||||
case 6: return l10n.loadingMessage7;
|
||||
default: return l10n.loadingMessage1;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _initializeApp() async {
|
||||
try {
|
||||
final settingsProvider = context.read<SettingsProvider>();
|
||||
@@ -148,6 +179,7 @@ class _SplashScreenState extends State<SplashScreen>
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_messageTimer?.cancel();
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
@@ -188,9 +220,9 @@ class _SplashScreenState extends State<SplashScreen>
|
||||
),
|
||||
const SizedBox(height: AppDimensions.paddingSmall),
|
||||
|
||||
// Subtítulo
|
||||
// Mensaje de carga aleatorio
|
||||
Text(
|
||||
l10n?.appTagline ?? 'Tu wallet de ecash privado',
|
||||
_getLoadingMessage(l10n),
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 16,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:lucide_icons/lucide_icons.dart';
|
||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
import '../../core/constants/colors.dart';
|
||||
import '../../core/constants/dimensions.dart';
|
||||
@@ -53,7 +54,7 @@ class _BackupSeedScreenState extends State<BackupSeedScreen> {
|
||||
backgroundColor: Colors.transparent,
|
||||
elevation: 0,
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back, color: Colors.white),
|
||||
icon: const Icon(LucideIcons.arrowLeft, color: Colors.white),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
title: Text(
|
||||
@@ -111,7 +112,7 @@ class _BackupSeedScreenState extends State<BackupSeedScreen> {
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.copy,
|
||||
LucideIcons.copy,
|
||||
color: AppColors.textSecondary,
|
||||
size: 18,
|
||||
),
|
||||
@@ -140,7 +141,7 @@ class _BackupSeedScreenState extends State<BackupSeedScreen> {
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.warning_amber_rounded,
|
||||
LucideIcons.alertTriangle,
|
||||
color: AppColors.warning,
|
||||
size: 24,
|
||||
),
|
||||
@@ -182,7 +183,7 @@ class _BackupSeedScreenState extends State<BackupSeedScreen> {
|
||||
),
|
||||
),
|
||||
child: _confirmed
|
||||
? const Icon(Icons.check, size: 16, color: Colors.white)
|
||||
? const Icon(LucideIcons.check, size: 16, color: Colors.white)
|
||||
: null,
|
||||
),
|
||||
const SizedBox(width: AppDimensions.paddingSmall),
|
||||
@@ -205,7 +206,7 @@ class _BackupSeedScreenState extends State<BackupSeedScreen> {
|
||||
if (!_revealed)
|
||||
PrimaryButton(
|
||||
text: l10n.revealSeedPhrase,
|
||||
icon: Icons.visibility,
|
||||
icon: LucideIcons.eye,
|
||||
onPressed: () => setState(() => _revealed = true),
|
||||
)
|
||||
else
|
||||
@@ -229,7 +230,7 @@ class _BackupSeedScreenState extends State<BackupSeedScreen> {
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.visibility_off,
|
||||
LucideIcons.eyeOff,
|
||||
size: 48,
|
||||
color: AppColors.textSecondary.withValues(alpha: 0.5),
|
||||
),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:lucide_icons/lucide_icons.dart';
|
||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
import '../../core/constants/colors.dart';
|
||||
import '../../core/constants/dimensions.dart';
|
||||
@@ -87,7 +88,7 @@ class _CreateWalletScreenState extends State<CreateWalletScreen> {
|
||||
backgroundColor: Colors.transparent,
|
||||
elevation: 0,
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back, color: Colors.white),
|
||||
icon: const Icon(LucideIcons.arrowLeft, color: Colors.white),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
title: Text(
|
||||
@@ -114,101 +115,111 @@ class _CreateWalletScreenState extends State<CreateWalletScreen> {
|
||||
Widget _buildCreateView(L10n l10n) {
|
||||
return Column(
|
||||
children: [
|
||||
const Spacer(),
|
||||
|
||||
Container(
|
||||
width: 120,
|
||||
height: 120,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.primaryAction.withValues(alpha: 0.1),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(
|
||||
_isCreating ? Icons.hourglass_top : Icons.account_balance_wallet,
|
||||
size: 60,
|
||||
color: AppColors.secondaryAction,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppDimensions.paddingLarge),
|
||||
|
||||
Text(
|
||||
_isCreating ? l10n.creatingWallet : l10n.createWalletTitle,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.white,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: AppDimensions.paddingMedium),
|
||||
|
||||
Text(
|
||||
_isCreating ? l10n.generatingSeed : l10n.createWalletDescription,
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 16,
|
||||
color: AppColors.textSecondary.withValues(alpha: 0.8),
|
||||
height: 1.5,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
|
||||
// Mensaje de error si existe
|
||||
if (_errorMessage != null) ...[
|
||||
const SizedBox(height: AppDimensions.paddingLarge),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(AppDimensions.paddingMedium),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.error.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: AppColors.error.withValues(alpha: 0.3),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
// Contenido principal centrado
|
||||
Expanded(
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.error_outline,
|
||||
color: AppColors.error,
|
||||
size: 20,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
_errorMessage!,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 14,
|
||||
color: AppColors.error,
|
||||
),
|
||||
Container(
|
||||
width: 120,
|
||||
height: 120,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.primaryAction.withValues(alpha: 0.1),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(
|
||||
_isCreating ? LucideIcons.hourglass : LucideIcons.wallet,
|
||||
size: 60,
|
||||
color: AppColors.secondaryAction,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppDimensions.paddingLarge),
|
||||
|
||||
Text(
|
||||
_isCreating ? l10n.creatingWallet : l10n.createWalletTitle,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.white,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: AppDimensions.paddingMedium),
|
||||
|
||||
Text(
|
||||
_isCreating ? l10n.generatingSeed : l10n.createWalletDescription,
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 16,
|
||||
color: AppColors.textSecondary.withValues(alpha: 0.8),
|
||||
height: 1.5,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
|
||||
// Mensaje de error si existe
|
||||
if (_errorMessage != null) ...[
|
||||
const SizedBox(height: AppDimensions.paddingLarge),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(AppDimensions.paddingMedium),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.error.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: AppColors.error.withValues(alpha: 0.3),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(
|
||||
LucideIcons.alertCircle,
|
||||
color: AppColors.error,
|
||||
size: 20,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
_errorMessage!,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 14,
|
||||
color: AppColors.error,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
// Spinner cuando está creando (integrado en el contenido centrado)
|
||||
if (_isCreating) ...[
|
||||
const SizedBox(height: AppDimensions.paddingXLarge),
|
||||
const SizedBox(
|
||||
width: 48,
|
||||
height: 48,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 3,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(AppColors.secondaryAction),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const Spacer(),
|
||||
|
||||
if (!_isCreating)
|
||||
// Botón solo cuando no está creando
|
||||
if (!_isCreating) ...[
|
||||
PrimaryButton(
|
||||
text: l10n.generateWallet,
|
||||
icon: Icons.auto_awesome,
|
||||
icon: LucideIcons.sparkles,
|
||||
onPressed: _createWallet,
|
||||
),
|
||||
|
||||
if (_isCreating)
|
||||
const SizedBox(
|
||||
width: 48,
|
||||
height: 48,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 3,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(AppColors.secondaryAction),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: AppDimensions.paddingXLarge),
|
||||
const SizedBox(height: AppDimensions.paddingXLarge),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -226,7 +237,7 @@ class _CreateWalletScreenState extends State<CreateWalletScreen> {
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.check_circle,
|
||||
LucideIcons.checkCircle,
|
||||
size: 60,
|
||||
color: AppColors.success,
|
||||
),
|
||||
@@ -261,7 +272,7 @@ class _CreateWalletScreenState extends State<CreateWalletScreen> {
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.warning_amber_rounded,
|
||||
LucideIcons.alertTriangle,
|
||||
color: AppColors.warning,
|
||||
size: 32,
|
||||
),
|
||||
@@ -285,7 +296,7 @@ class _CreateWalletScreenState extends State<CreateWalletScreen> {
|
||||
|
||||
PrimaryButton(
|
||||
text: l10n.backupNow,
|
||||
icon: Icons.shield,
|
||||
icon: LucideIcons.shield,
|
||||
onPressed: _goToBackup,
|
||||
),
|
||||
const SizedBox(height: AppDimensions.paddingMedium),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:lucide_icons/lucide_icons.dart';
|
||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
import '../../core/constants/colors.dart';
|
||||
import '../../core/constants/dimensions.dart';
|
||||
@@ -96,7 +97,7 @@ class _RestoreWalletScreenState extends State<RestoreWalletScreen> {
|
||||
backgroundColor: Colors.transparent,
|
||||
elevation: 0,
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back, color: Colors.white),
|
||||
icon: const Icon(LucideIcons.arrowLeft, color: Colors.white),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
title: Text(
|
||||
@@ -174,8 +175,8 @@ class _RestoreWalletScreenState extends State<RestoreWalletScreen> {
|
||||
children: [
|
||||
Icon(
|
||||
_isValidWordCount
|
||||
? Icons.check_circle
|
||||
: Icons.info_outline,
|
||||
? LucideIcons.checkCircle
|
||||
: LucideIcons.info,
|
||||
size: 18,
|
||||
color: _isValidWordCount
|
||||
? AppColors.success
|
||||
@@ -220,7 +221,7 @@ class _RestoreWalletScreenState extends State<RestoreWalletScreen> {
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.error_outline,
|
||||
LucideIcons.alertCircle,
|
||||
color: AppColors.error,
|
||||
size: 20,
|
||||
),
|
||||
@@ -244,7 +245,7 @@ class _RestoreWalletScreenState extends State<RestoreWalletScreen> {
|
||||
|
||||
PrimaryButton(
|
||||
text: l10n.restoreWallet,
|
||||
icon: Icons.restore,
|
||||
icon: LucideIcons.rotateCcw,
|
||||
isLoading: _isRestoring,
|
||||
onPressed: _isValidWordCount && !_isRestoring
|
||||
? _restoreWallet
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:lucide_icons/lucide_icons.dart';
|
||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
import '../../core/constants/colors.dart';
|
||||
import '../../core/constants/dimensions.dart';
|
||||
import '../../providers/settings_provider.dart';
|
||||
import '../../widgets/common/gradient_background.dart';
|
||||
import '../../widgets/common/primary_button.dart';
|
||||
import '../../widgets/common/secondary_button.dart';
|
||||
@@ -12,10 +15,104 @@ import 'restore_wallet_screen.dart';
|
||||
class WelcomeScreen extends StatelessWidget {
|
||||
const WelcomeScreen({super.key});
|
||||
|
||||
static const _languages = [
|
||||
{'code': 'es', 'flag': '🇪🇸', 'name': 'Español'},
|
||||
{'code': 'en', 'flag': '🇬🇧', 'name': 'English'},
|
||||
];
|
||||
|
||||
void _showLanguageSelector(BuildContext context) {
|
||||
final settingsProvider = context.read<SettingsProvider>();
|
||||
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
backgroundColor: Colors.transparent,
|
||||
builder: (context) => Container(
|
||||
padding: const EdgeInsets.all(AppDimensions.paddingMedium),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.deepVoidPurple,
|
||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(24)),
|
||||
border: Border.all(
|
||||
color: Colors.white.withValues(alpha: 0.1),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Handle
|
||||
Container(
|
||||
margin: const EdgeInsets.only(bottom: 16),
|
||||
width: 40,
|
||||
height: 4,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: 0.3),
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
// Opciones de idioma
|
||||
..._languages.map((lang) {
|
||||
final isSelected = settingsProvider.locale == lang['code'];
|
||||
return GestureDetector(
|
||||
onTap: () {
|
||||
settingsProvider.setLocale(lang['code']!);
|
||||
Navigator.pop(context);
|
||||
},
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected
|
||||
? AppColors.primaryAction.withValues(alpha: 0.2)
|
||||
: Colors.white.withValues(alpha: 0.05),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: isSelected
|
||||
? AppColors.primaryAction.withValues(alpha: 0.5)
|
||||
: Colors.white.withValues(alpha: 0.1),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
lang['flag']!,
|
||||
style: const TextStyle(fontSize: 24),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Text(
|
||||
lang['name']!,
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 16,
|
||||
fontWeight: isSelected ? FontWeight.w600 : FontWeight.w500,
|
||||
color: isSelected ? AppColors.primaryAction : Colors.white,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
if (isSelected)
|
||||
const Icon(
|
||||
LucideIcons.checkCircle,
|
||||
color: AppColors.primaryAction,
|
||||
size: 22,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = L10n.of(context)!;
|
||||
|
||||
final settingsProvider = context.watch<SettingsProvider>();
|
||||
final isSpanish = settingsProvider.locale == 'es';
|
||||
|
||||
return GradientBackground(
|
||||
child: Scaffold(
|
||||
backgroundColor: Colors.transparent,
|
||||
@@ -24,6 +121,52 @@ class WelcomeScreen extends StatelessWidget {
|
||||
padding: const EdgeInsets.all(AppDimensions.paddingLarge),
|
||||
child: Column(
|
||||
children: [
|
||||
// Selector de idioma en esquina superior derecha
|
||||
Align(
|
||||
alignment: Alignment.topRight,
|
||||
child: GestureDetector(
|
||||
onTap: () => _showLanguageSelector(context),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 8,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(
|
||||
color: Colors.white.withValues(alpha: 0.2),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
isSpanish ? '🇪🇸' : '🇬🇧',
|
||||
style: const TextStyle(fontSize: 18),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
isSpanish ? 'ES' : 'EN',
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Icon(
|
||||
LucideIcons.chevronDown,
|
||||
color: Colors.white.withValues(alpha: 0.7),
|
||||
size: 18,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const Spacer(flex: 1),
|
||||
|
||||
// Logo / Mascota
|
||||
@@ -66,7 +209,7 @@ class WelcomeScreen extends StatelessWidget {
|
||||
// Botón crear wallet
|
||||
PrimaryButton(
|
||||
text: l10n.createWallet,
|
||||
icon: Icons.add_circle_outline,
|
||||
icon: LucideIcons.plusCircle,
|
||||
onPressed: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
@@ -81,7 +224,7 @@ class WelcomeScreen extends StatelessWidget {
|
||||
// Botón restaurar wallet
|
||||
SecondaryButton(
|
||||
text: l10n.restoreWallet,
|
||||
icon: Icons.restore,
|
||||
icon: LucideIcons.rotateCcw,
|
||||
onPressed: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:lucide_icons/lucide_icons.dart';
|
||||
import 'package:flutter_gen/gen_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';
|
||||
import '../../widgets/common/gradient_background.dart';
|
||||
import '../../widgets/common/glass_card.dart';
|
||||
import '../../widgets/common/animated_action_button.dart';
|
||||
@@ -17,6 +19,7 @@ import '../7_melt/melt_screen.dart';
|
||||
import '../8_settings/settings_screen.dart';
|
||||
import '../8_settings/mints_screen.dart';
|
||||
import '../9_history/history_screen.dart';
|
||||
import '../10_scanner/scan_screen.dart';
|
||||
|
||||
/// Pantalla principal - Home
|
||||
/// Muestra balance, acciones principales e historial
|
||||
@@ -34,12 +37,51 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
// Controller para el efecto confeti
|
||||
final CashuConfettiController _confettiController = CashuConfettiController();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// Verificar tokens pendientes al iniciar
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_checkPendingTokens();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_confettiController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// Verifica y reclama automáticamente tokens pendientes
|
||||
Future<void> _checkPendingTokens() async {
|
||||
final walletProvider = context.read<WalletProvider>();
|
||||
if (!walletProvider.hasPendingTokens) return;
|
||||
|
||||
try {
|
||||
final result = await walletProvider.checkPendingTokens();
|
||||
final claimed = (result['claimed'] as int?) ?? 0;
|
||||
final totalClaimed = result['totalClaimed'] as BigInt? ?? BigInt.zero;
|
||||
final unit = (result['unit'] as String?) ?? walletProvider.activeUnit;
|
||||
|
||||
if (claimed > 0 && mounted) {
|
||||
// Disparar confetti
|
||||
_confettiController.fire();
|
||||
|
||||
// Mostrar snackbar
|
||||
final l10n = L10n.of(context)!;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(l10n.pendingTokensClaimed(claimed, totalClaimed.toString(), unit)),
|
||||
backgroundColor: AppColors.success,
|
||||
duration: const Duration(seconds: 3),
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Error checking pending tokens: $e');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return CashuConfetti(
|
||||
@@ -212,9 +254,10 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
final activeMintUrl = walletProvider.activeMintUrl;
|
||||
|
||||
// Extraer nombre del mint para mostrar
|
||||
final l10n = L10n.of(context)!;
|
||||
final displayMint = activeMintUrl != null
|
||||
? UnitFormatter.getMintDisplayName(activeMintUrl)
|
||||
: 'Sin mint';
|
||||
: l10n.noMint;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: AppDimensions.paddingSmall),
|
||||
@@ -312,6 +355,7 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
}
|
||||
|
||||
Widget _buildActions() {
|
||||
final l10n = L10n.of(context)!;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(AppDimensions.paddingMedium),
|
||||
child: Row(
|
||||
@@ -319,16 +363,19 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
// Enviar (primero) - acción crítica que mueve dinero
|
||||
Expanded(
|
||||
child: AnimatedActionButton(
|
||||
label: 'Enviar ↗',
|
||||
label: l10n.sendAction,
|
||||
type: ButtonType.criticalAction,
|
||||
onTap: _showSendOptions,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: AppDimensions.paddingMedium),
|
||||
const SizedBox(width: AppDimensions.paddingSmall),
|
||||
// Botón scan circular (centro)
|
||||
_buildScanButton(),
|
||||
const SizedBox(width: AppDimensions.paddingSmall),
|
||||
// Recibir (segundo) - acción importante pero segura
|
||||
Expanded(
|
||||
child: AnimatedActionButton(
|
||||
label: '↘ Recibir',
|
||||
label: l10n.receiveAction,
|
||||
type: ButtonType.primaryAction,
|
||||
onTap: _showReceiveOptions,
|
||||
),
|
||||
@@ -338,17 +385,57 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildScanButton() {
|
||||
return GestureDetector(
|
||||
onTap: _openScanner,
|
||||
child: Container(
|
||||
width: 52,
|
||||
height: 52,
|
||||
decoration: BoxDecoration(
|
||||
gradient: const LinearGradient(
|
||||
colors: AppColors.buttonGradient,
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
),
|
||||
shape: BoxShape.circle,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: AppColors.primaryAction.withValues(alpha: 0.3),
|
||||
blurRadius: 12,
|
||||
offset: const Offset(0, 4),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: const Icon(
|
||||
LucideIcons.scan,
|
||||
color: Colors.white,
|
||||
size: 24,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _openScanner() {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => const ScanScreen(mode: ScanMode.any),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showReceiveOptions() {
|
||||
final l10n = L10n.of(context)!;
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
backgroundColor: Colors.transparent,
|
||||
builder: (context) => _MethodSelectorModal(
|
||||
title: 'Recibir',
|
||||
title: l10n.receive,
|
||||
options: [
|
||||
_MethodOption(
|
||||
icon: LucideIcons.bean,
|
||||
label: 'Cashu',
|
||||
description: 'Pegar token ecash',
|
||||
label: l10n.cashu,
|
||||
description: l10n.pasteEcashToken,
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
Navigator.push(
|
||||
@@ -359,8 +446,8 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
),
|
||||
_MethodOption(
|
||||
icon: LucideIcons.zap,
|
||||
label: 'Lightning',
|
||||
description: 'Generar invoice para depositar',
|
||||
label: l10n.lightning,
|
||||
description: l10n.generateInvoiceToDeposit,
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
Navigator.push(
|
||||
@@ -375,16 +462,17 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
}
|
||||
|
||||
void _showSendOptions() {
|
||||
final l10n = L10n.of(context)!;
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
backgroundColor: Colors.transparent,
|
||||
builder: (context) => _MethodSelectorModal(
|
||||
title: 'Enviar',
|
||||
title: l10n.send,
|
||||
options: [
|
||||
_MethodOption(
|
||||
icon: LucideIcons.bean,
|
||||
label: 'Cashu',
|
||||
description: 'Crear token ecash',
|
||||
label: l10n.cashu,
|
||||
description: l10n.createEcashToken,
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
Navigator.push(
|
||||
@@ -395,8 +483,8 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
),
|
||||
_MethodOption(
|
||||
icon: LucideIcons.zap,
|
||||
label: 'Lightning',
|
||||
description: 'Pagar invoice Lightning',
|
||||
label: l10n.lightning,
|
||||
description: l10n.payLightningInvoice,
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
Navigator.push(
|
||||
@@ -411,19 +499,59 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
}
|
||||
|
||||
Widget _buildHistoryButton() {
|
||||
final l10n = L10n.of(context)!;
|
||||
final walletProvider = context.watch<WalletProvider>();
|
||||
final pendingCount = walletProvider.pendingTokenCount;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(AppDimensions.paddingMedium),
|
||||
child: AnimatedActionButton(
|
||||
label: 'Historial',
|
||||
type: ButtonType.navigation,
|
||||
icon: LucideIcons.history,
|
||||
showIcon: true,
|
||||
onTap: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(builder: (context) => const HistoryScreen()),
|
||||
);
|
||||
},
|
||||
child: Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
AnimatedActionButton(
|
||||
label: l10n.history,
|
||||
type: ButtonType.navigation,
|
||||
icon: LucideIcons.history,
|
||||
showIcon: true,
|
||||
onTap: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(builder: (context) => const HistoryScreen()),
|
||||
);
|
||||
},
|
||||
),
|
||||
// Badge de tokens pendientes
|
||||
if (pendingCount > 0)
|
||||
Positioned(
|
||||
right: -4,
|
||||
top: -4,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(6),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.warning,
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(
|
||||
color: AppColors.deepVoidPurple,
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
constraints: const BoxConstraints(
|
||||
minWidth: 20,
|
||||
minHeight: 20,
|
||||
),
|
||||
child: Text(
|
||||
pendingCount > 9 ? '9+' : pendingCount.toString(),
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.black,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -499,7 +627,8 @@ class _MethodSelectorModal extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: AppDimensions.paddingSmall),
|
||||
// Espacio para la barra de navegación del sistema
|
||||
SizedBox(height: MediaQuery.of(context).padding.bottom + AppDimensions.paddingSmall),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -2,18 +2,24 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:lucide_icons/lucide_icons.dart';
|
||||
import 'package:flutter_gen/gen_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 '../../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 '../10_scanner/scan_screen.dart';
|
||||
|
||||
/// Pantalla para recibir tokens Cashu
|
||||
class ReceiveScreen extends StatefulWidget {
|
||||
const ReceiveScreen({super.key});
|
||||
/// Token inicial (pre-cargado desde QR scanner o deep link)
|
||||
final String? initialToken;
|
||||
|
||||
const ReceiveScreen({super.key, this.initialToken});
|
||||
|
||||
@override
|
||||
State<ReceiveScreen> createState() => _ReceiveScreenState();
|
||||
@@ -32,6 +38,18 @@ class _ReceiveScreenState extends State<ReceiveScreen> {
|
||||
TokenInfo? _tokenInfo;
|
||||
String? _errorMessage;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// 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() {
|
||||
_tokenController.dispose();
|
||||
@@ -53,9 +71,9 @@ class _ReceiveScreenState extends State<ReceiveScreen> {
|
||||
icon: const Icon(LucideIcons.arrowLeft, color: Colors.white),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
title: const Text(
|
||||
'Recibir Cashu',
|
||||
style: TextStyle(
|
||||
title: Text(
|
||||
L10n.of(context)!.receiveCashu,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.white,
|
||||
@@ -71,6 +89,7 @@ class _ReceiveScreenState extends State<ReceiveScreen> {
|
||||
}
|
||||
|
||||
Widget _buildReceiveForm() {
|
||||
final l10n = L10n.of(context)!;
|
||||
return Column(
|
||||
children: [
|
||||
// Contenido scrolleable
|
||||
@@ -82,7 +101,7 @@ class _ReceiveScreenState extends State<ReceiveScreen> {
|
||||
children: [
|
||||
// Instrucciones
|
||||
Text(
|
||||
'Pega el token Cashu:',
|
||||
l10n.pasteTheCashuToken,
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 16,
|
||||
@@ -97,8 +116,8 @@ class _ReceiveScreenState extends State<ReceiveScreen> {
|
||||
|
||||
const SizedBox(height: AppDimensions.paddingMedium),
|
||||
|
||||
// Botón pegar del portapapeles
|
||||
_buildPasteButton(),
|
||||
// Botones pegar y escanear
|
||||
_buildActionButtons(),
|
||||
|
||||
const SizedBox(height: AppDimensions.paddingLarge),
|
||||
|
||||
@@ -112,10 +131,10 @@ class _ReceiveScreenState extends State<ReceiveScreen> {
|
||||
),
|
||||
),
|
||||
|
||||
// Botón reclamar (fijo abajo)
|
||||
// Botón único "Recibir" (fijo abajo)
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(AppDimensions.paddingMedium),
|
||||
child: _buildClaimButton(),
|
||||
child: _buildReceiveButton(),
|
||||
),
|
||||
],
|
||||
);
|
||||
@@ -163,7 +182,7 @@ class _ReceiveScreenState extends State<ReceiveScreen> {
|
||||
const SizedBox(height: AppDimensions.paddingSmall),
|
||||
|
||||
Text(
|
||||
'Tokens recibidos',
|
||||
L10n.of(context)!.tokensReceived,
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 18,
|
||||
@@ -175,7 +194,7 @@ class _ReceiveScreenState extends State<ReceiveScreen> {
|
||||
|
||||
// Botón volver (fijo abajo)
|
||||
PrimaryButton(
|
||||
text: 'Volver al inicio',
|
||||
text: L10n.of(context)!.backToHome,
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
],
|
||||
@@ -209,6 +228,18 @@ class _ReceiveScreenState extends State<ReceiveScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildActionButtons() {
|
||||
return Row(
|
||||
children: [
|
||||
// Botón pegar (expandido)
|
||||
Expanded(child: _buildPasteButton()),
|
||||
const SizedBox(width: 12),
|
||||
// Botón escanear QR
|
||||
_buildScanButton(),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPasteButton() {
|
||||
return GestureDetector(
|
||||
onTap: _pasteFromClipboard,
|
||||
@@ -235,7 +266,7 @@ class _ReceiveScreenState extends State<ReceiveScreen> {
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'Pegar del portapapeles',
|
||||
L10n.of(context)!.pasteFromClipboard,
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 16,
|
||||
@@ -249,6 +280,40 @@ class _ReceiveScreenState extends State<ReceiveScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -278,7 +343,7 @@ class _ReceiveScreenState extends State<ReceiveScreen> {
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'Token válido',
|
||||
L10n.of(context)!.validToken,
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 14,
|
||||
@@ -295,7 +360,7 @@ class _ReceiveScreenState extends State<ReceiveScreen> {
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
'Monto:',
|
||||
L10n.of(context)!.amount,
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 14,
|
||||
@@ -333,7 +398,7 @@ class _ReceiveScreenState extends State<ReceiveScreen> {
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
'Mint:',
|
||||
L10n.of(context)!.mint,
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 14,
|
||||
@@ -392,10 +457,12 @@ class _ReceiveScreenState extends State<ReceiveScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildClaimButton() {
|
||||
Widget _buildReceiveButton() {
|
||||
final l10n = L10n.of(context)!;
|
||||
// Botón único "Recibir" - auto-detecta conectividad
|
||||
return PrimaryButton(
|
||||
text: _isProcessing ? 'Reclamando...' : 'Reclamar tokens',
|
||||
onPressed: _isValidToken && !_isProcessing ? _claimToken : null,
|
||||
text: _isProcessing ? l10n.claiming : l10n.receive,
|
||||
onPressed: _isValidToken && !_isProcessing ? _receiveToken : null,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -428,23 +495,121 @@ class _ReceiveScreenState extends State<ReceiveScreen> {
|
||||
} else {
|
||||
_isValidToken = false;
|
||||
_tokenInfo = null;
|
||||
_errorMessage = 'Token inválido o malformado';
|
||||
_errorMessage = L10n.of(context)!.invalidToken;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _claimToken() async {
|
||||
/// 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 walletProvider = context.read<WalletProvider>();
|
||||
final mintUrl = _tokenInfo?.mintUrl;
|
||||
if (mintUrl == null) {
|
||||
throw Exception(l10n.invalidToken);
|
||||
}
|
||||
|
||||
// Guardar unidad detectada del token ANTES de reclamar
|
||||
final detectedUnit = _tokenInfo?.unit ?? walletProvider.activeUnit;
|
||||
// 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>();
|
||||
|
||||
// Guardar unidad detectada del token ANTES de reclamar
|
||||
final detectedUnit = _tokenInfo?.unit ?? walletProvider.activeUnit;
|
||||
|
||||
try {
|
||||
// Reclamar token (usa unidad detectada internamente)
|
||||
final amountReceived = await walletProvider.receiveToken(
|
||||
_tokenController.text.trim(),
|
||||
@@ -453,7 +618,7 @@ class _ReceiveScreenState extends State<ReceiveScreen> {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_receivedAmount = amountReceived;
|
||||
_receivedUnit = detectedUnit; // Usar unidad del token
|
||||
_receivedUnit = detectedUnit;
|
||||
_showSuccess = true;
|
||||
_isProcessing = false;
|
||||
});
|
||||
@@ -462,23 +627,22 @@ class _ReceiveScreenState extends State<ReceiveScreen> {
|
||||
_confettiController.fire();
|
||||
}
|
||||
} catch (e) {
|
||||
setState(() {
|
||||
// Mensajes de error más amigables
|
||||
final errorStr = e.toString().toLowerCase();
|
||||
if (errorStr.contains('already spent') || errorStr.contains('token already')) {
|
||||
_errorMessage = 'Este token ya fue reclamado';
|
||||
} else if (errorStr.contains('unknown mint') || errorStr.contains('mint not found')) {
|
||||
_errorMessage = 'Token de un mint desconocido';
|
||||
} else {
|
||||
_errorMessage = 'Error al reclamar: $e';
|
||||
}
|
||||
});
|
||||
} finally {
|
||||
if (mounted && !_showSuccess) {
|
||||
setState(() {
|
||||
_isProcessing = false;
|
||||
});
|
||||
if (!mounted) return;
|
||||
final l10n = L10n.of(context)!;
|
||||
// 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 {
|
||||
errorMessage = l10n.claimError(e.toString());
|
||||
}
|
||||
setState(() {
|
||||
_errorMessage = errorMessage;
|
||||
_isProcessing = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:lucide_icons/lucide_icons.dart';
|
||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
import '../../core/constants/colors.dart';
|
||||
import '../../core/constants/dimensions.dart';
|
||||
import '../../core/models/proof.dart';
|
||||
@@ -64,7 +65,7 @@ class _OfflineSendScreenState extends State<OfflineSendScreen> {
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_errorMessage = 'Error cargando proofs: $e';
|
||||
_errorMessage = L10n.of(context)!.loadingProofsError(e.toString());
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
@@ -120,9 +121,9 @@ class _OfflineSendScreenState extends State<OfflineSendScreen> {
|
||||
icon: const Icon(LucideIcons.arrowLeft, color: Colors.white),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
title: const Text(
|
||||
'Envio Offline',
|
||||
style: TextStyle(
|
||||
title: Text(
|
||||
L10n.of(context)!.offlineSend,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.white,
|
||||
@@ -201,7 +202,7 @@ class _OfflineSendScreenState extends State<OfflineSendScreen> {
|
||||
|
||||
// Instrucciones
|
||||
Text(
|
||||
'Selecciona las notas que deseas enviar:',
|
||||
L10n.of(context)!.selectNotesToSend,
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 14,
|
||||
@@ -235,7 +236,7 @@ class _OfflineSendScreenState extends State<OfflineSendScreen> {
|
||||
|
||||
// Botón crear token
|
||||
PrimaryButton(
|
||||
text: _isCreating ? 'Creando...' : 'Crear token',
|
||||
text: _isCreating ? L10n.of(context)!.creatingToken : L10n.of(context)!.createToken,
|
||||
onPressed: _selectedIds.isNotEmpty && !_isCreating
|
||||
? _createOfflineToken
|
||||
: null,
|
||||
@@ -246,12 +247,13 @@ class _OfflineSendScreenState extends State<OfflineSendScreen> {
|
||||
}
|
||||
|
||||
Widget _buildTotalDisplay() {
|
||||
final l10n = L10n.of(context)!;
|
||||
return GlassCard(
|
||||
padding: const EdgeInsets.all(AppDimensions.paddingMedium),
|
||||
child: Column(
|
||||
children: [
|
||||
Text(
|
||||
'Total a enviar',
|
||||
l10n.totalToSend,
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 14,
|
||||
@@ -265,7 +267,7 @@ class _OfflineSendScreenState extends State<OfflineSendScreen> {
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'${_selectedIds.length} notas seleccionadas',
|
||||
l10n.notesSelected(_selectedIds.length),
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 12,
|
||||
@@ -290,7 +292,7 @@ class _OfflineSendScreenState extends State<OfflineSendScreen> {
|
||||
color: Colors.white,
|
||||
),
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Memo (opcional)',
|
||||
hintText: L10n.of(context)!.memoOptional,
|
||||
hintStyle: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 16,
|
||||
@@ -341,8 +343,10 @@ class _OfflineSendScreenState extends State<OfflineSendScreen> {
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
final errorMessage = L10n.of(context)!.creatingTokenError(e.toString());
|
||||
setState(() {
|
||||
_errorMessage = 'Error creando token: $e';
|
||||
_errorMessage = errorMessage;
|
||||
_isCreating = false;
|
||||
});
|
||||
}
|
||||
|
||||
+104
-123
@@ -2,12 +2,14 @@ import 'package:flutter/material.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:lucide_icons/lucide_icons.dart';
|
||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
import '../../core/constants/colors.dart';
|
||||
import '../../core/constants/dimensions.dart';
|
||||
import '../../core/utils/formatters.dart';
|
||||
import '../../widgets/common/gradient_background.dart';
|
||||
import '../../widgets/common/glass_card.dart';
|
||||
import '../../widgets/common/primary_button.dart';
|
||||
import '../../widgets/common/numpad_widget.dart';
|
||||
import '../../providers/wallet_provider.dart';
|
||||
import 'share_token_screen.dart';
|
||||
import 'offline_send_screen.dart';
|
||||
@@ -21,9 +23,9 @@ class SendScreen extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _SendScreenState extends State<SendScreen> {
|
||||
final TextEditingController _amountController = TextEditingController();
|
||||
final TextEditingController _memoController = TextEditingController();
|
||||
|
||||
String _amountValue = '';
|
||||
bool _isProcessing = false;
|
||||
String? _errorMessage;
|
||||
BigInt _availableBalance = BigInt.zero;
|
||||
@@ -48,7 +50,6 @@ class _SendScreenState extends State<SendScreen> {
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_amountController.dispose();
|
||||
_memoController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
@@ -56,8 +57,8 @@ class _SendScreenState extends State<SendScreen> {
|
||||
/// Obtiene la etiqueta de la unidad para display
|
||||
String get _unitLabel => UnitFormatter.getUnitLabel(_activeUnit);
|
||||
|
||||
/// Parsea el input del usuario a BigInt según la unidad
|
||||
BigInt get _amount => UnitFormatter.parseUserInput(_amountController.text, _activeUnit);
|
||||
/// Parsea los dígitos crudos a BigInt (ya son centavos para USD/EUR)
|
||||
BigInt get _amount => UnitFormatter.parseRawDigits(_amountValue, _activeUnit);
|
||||
|
||||
bool get _isValidAmount => _amount > BigInt.zero && _amount <= _availableBalance;
|
||||
|
||||
@@ -73,9 +74,9 @@ class _SendScreenState extends State<SendScreen> {
|
||||
icon: const Icon(LucideIcons.arrowLeft, color: Colors.white),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
title: const Text(
|
||||
'Enviar Cashu',
|
||||
style: TextStyle(
|
||||
title: Text(
|
||||
L10n.of(context)!.sendCashu,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.white,
|
||||
@@ -85,7 +86,7 @@ class _SendScreenState extends State<SendScreen> {
|
||||
// Botón para modo offline (selección manual de proofs)
|
||||
IconButton(
|
||||
icon: const Icon(LucideIcons.coins, color: AppColors.primaryAction),
|
||||
tooltip: 'Seleccionar notas manualmente',
|
||||
tooltip: L10n.of(context)!.selectNotesManually,
|
||||
onPressed: _goToOfflineMode,
|
||||
),
|
||||
],
|
||||
@@ -131,116 +132,94 @@ class _SendScreenState extends State<SendScreen> {
|
||||
|
||||
Widget _buildAmountSection() {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Monto a enviar:',
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppDimensions.paddingSmall),
|
||||
|
||||
// Input de monto
|
||||
GlassCard(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: AppDimensions.paddingMedium,
|
||||
vertical: AppDimensions.paddingSmall,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _amountController,
|
||||
keyboardType: TextInputType.number,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 32,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.white,
|
||||
),
|
||||
decoration: InputDecoration(
|
||||
hintText: '0',
|
||||
hintStyle: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 32,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.white.withValues(alpha: 0.3),
|
||||
),
|
||||
border: InputBorder.none,
|
||||
),
|
||||
onChanged: (_) => setState(() {
|
||||
_errorMessage = null;
|
||||
}),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
_unitLabel,
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Display del monto
|
||||
_buildAmountDisplay(),
|
||||
const SizedBox(height: AppDimensions.paddingSmall),
|
||||
|
||||
// Balance disponible
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
'Disponible:',
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 14,
|
||||
color: AppColors.textSecondary.withValues(alpha: 0.7),
|
||||
),
|
||||
),
|
||||
GestureDetector(
|
||||
onTap: _setMaxAmount,
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
'${UnitFormatter.formatBalance(_availableBalance, _activeUnit)} $_unitLabel',
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.primaryAction,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'(Max)',
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 12,
|
||||
color: AppColors.primaryAction.withValues(alpha: 0.7),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
_buildBalanceRow(),
|
||||
const SizedBox(height: AppDimensions.paddingMedium),
|
||||
|
||||
// Teclado numérico
|
||||
NumpadWidget(
|
||||
value: _amountValue,
|
||||
showMaxButton: true,
|
||||
onMaxPressed: _setMaxAmount,
|
||||
onChanged: (newValue) {
|
||||
setState(() {
|
||||
_errorMessage = null;
|
||||
_amountValue = newValue;
|
||||
});
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAmountDisplay() {
|
||||
final displayAmount = UnitFormatter.formatRawDigitsForDisplay(_amountValue, _activeUnit);
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
Text(
|
||||
displayAmount,
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 48,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: _isValidAmount || _amountValue.isEmpty
|
||||
? Colors.white
|
||||
: AppColors.error,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
_unitLabel,
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 18,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBalanceRow() {
|
||||
final l10n = L10n.of(context)!;
|
||||
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
'${l10n.available} ',
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 14,
|
||||
color: AppColors.textSecondary.withValues(alpha: 0.7),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'${UnitFormatter.formatBalance(_availableBalance, _activeUnit)} $_unitLabel',
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.primaryAction,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMemoSection() {
|
||||
final l10n = L10n.of(context)!;
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Memo (opcional):',
|
||||
l10n.memoOptional,
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 16,
|
||||
@@ -262,7 +241,7 @@ class _SendScreenState extends State<SendScreen> {
|
||||
color: Colors.white,
|
||||
),
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Ej: Para el cafe',
|
||||
hintText: l10n.memoPlaceholder,
|
||||
hintStyle: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 16,
|
||||
@@ -317,16 +296,17 @@ class _SendScreenState extends State<SendScreen> {
|
||||
}
|
||||
|
||||
Widget _buildCreateButton() {
|
||||
final l10n = L10n.of(context)!;
|
||||
return PrimaryButton(
|
||||
text: _isProcessing ? 'Creando token...' : 'Crear token',
|
||||
text: _isProcessing ? l10n.creatingToken : l10n.createToken,
|
||||
onPressed: _isValidAmount && !_isProcessing ? _showConfirmation : null,
|
||||
);
|
||||
}
|
||||
|
||||
void _setMaxAmount() {
|
||||
// Formatear el balance para el input (sin separadores de miles)
|
||||
_amountController.text = UnitFormatter.formatBalance(_availableBalance, _activeUnit).replaceAll(',', '');
|
||||
setState(() {
|
||||
// El balance ya está en la unidad base (centavos para USD/EUR, sats para SAT)
|
||||
_amountValue = _availableBalance.toString();
|
||||
_errorMessage = null;
|
||||
});
|
||||
}
|
||||
@@ -338,7 +318,7 @@ class _SendScreenState extends State<SendScreen> {
|
||||
|
||||
if (mintUrl == null) {
|
||||
setState(() {
|
||||
_errorMessage = 'No hay mint activo';
|
||||
_errorMessage = L10n.of(context)!.noActiveMint;
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -361,7 +341,7 @@ class _SendScreenState extends State<SendScreen> {
|
||||
|
||||
if (mintUrl == null) {
|
||||
setState(() {
|
||||
_errorMessage = 'No hay mint activo';
|
||||
_errorMessage = L10n.of(context)!.noActiveMint;
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -454,11 +434,12 @@ class _SendScreenState extends State<SendScreen> {
|
||||
return;
|
||||
}
|
||||
|
||||
final l10n = L10n.of(context)!;
|
||||
setState(() {
|
||||
if (errorStr.contains('insufficient') || errorStr.contains('not enough')) {
|
||||
_errorMessage = 'Balance insuficiente';
|
||||
_errorMessage = l10n.insufficientBalance;
|
||||
} else {
|
||||
_errorMessage = 'Error al crear token: $e';
|
||||
_errorMessage = l10n.tokenCreationError(e.toString());
|
||||
}
|
||||
});
|
||||
} finally {
|
||||
@@ -489,17 +470,17 @@ class _SendScreenState extends State<SendScreen> {
|
||||
|
||||
if (mintUrl == null) {
|
||||
setState(() {
|
||||
_errorMessage = 'No hay mint activo';
|
||||
_errorMessage = L10n.of(context)!.noActiveMint;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Mostrar snackbar informativo
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Sin conexion. Usando modo offline...'),
|
||||
SnackBar(
|
||||
content: Text(L10n.of(context)!.offlineModeMessage),
|
||||
backgroundColor: AppColors.primaryAction,
|
||||
duration: Duration(seconds: 2),
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -575,9 +556,9 @@ class _ConfirmationModal extends StatelessWidget {
|
||||
const SizedBox(height: AppDimensions.paddingMedium),
|
||||
|
||||
// Titulo
|
||||
const Text(
|
||||
'Confirmar envio',
|
||||
style: TextStyle(
|
||||
Text(
|
||||
L10n.of(context)!.confirmSend,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
@@ -625,10 +606,10 @@ class _ConfirmationModal extends StatelessWidget {
|
||||
color: Colors.white.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: const Center(
|
||||
child: Center(
|
||||
child: Text(
|
||||
'Cancelar',
|
||||
style: TextStyle(
|
||||
L10n.of(context)!.cancel,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
@@ -642,7 +623,7 @@ class _ConfirmationModal extends StatelessWidget {
|
||||
const SizedBox(width: AppDimensions.paddingMedium),
|
||||
Expanded(
|
||||
child: PrimaryButton(
|
||||
text: 'Confirmar',
|
||||
text: L10n.of(context)!.confirm,
|
||||
onPressed: onConfirm,
|
||||
height: 52,
|
||||
),
|
||||
|
||||
@@ -5,6 +5,7 @@ import 'package:qr_flutter/qr_flutter.dart';
|
||||
import 'package:lucide_icons/lucide_icons.dart';
|
||||
import 'package:share_plus/share_plus.dart';
|
||||
import 'package:cdk_flutter/cdk_flutter.dart' as cdk;
|
||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
import '../../core/constants/colors.dart';
|
||||
import '../../core/constants/dimensions.dart';
|
||||
import '../../core/utils/formatters.dart';
|
||||
@@ -114,9 +115,9 @@ class _ShareTokenScreenState extends State<ShareTokenScreen> {
|
||||
icon: const Icon(LucideIcons.x, color: Colors.white),
|
||||
onPressed: () => _goToHome(context),
|
||||
),
|
||||
title: const Text(
|
||||
'Token creado',
|
||||
style: TextStyle(
|
||||
title: Text(
|
||||
L10n.of(context)!.tokenCreated,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.white,
|
||||
@@ -170,7 +171,7 @@ class _ShareTokenScreenState extends State<ShareTokenScreen> {
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(AppDimensions.paddingMedium),
|
||||
child: PrimaryButton(
|
||||
text: 'Volver al inicio',
|
||||
text: L10n.of(context)!.backToHome,
|
||||
onPressed: () => _goToHome(context),
|
||||
),
|
||||
),
|
||||
@@ -334,6 +335,7 @@ class _ShareTokenScreenState extends State<ShareTokenScreen> {
|
||||
}
|
||||
|
||||
Widget _buildTokenTextDisplay() {
|
||||
final l10n = L10n.of(context)!;
|
||||
final displayToken = widget.token.length > 50
|
||||
? '${widget.token.substring(0, 25)}...${widget.token.substring(widget.token.length - 20)}'
|
||||
: widget.token;
|
||||
@@ -351,8 +353,8 @@ class _ShareTokenScreenState extends State<ShareTokenScreen> {
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
isAnimated
|
||||
? 'Token Cashu (QR animado - ${_urFragments.length} fragmentos UR)'
|
||||
: 'Token Cashu',
|
||||
? l10n.tokenCashuAnimatedQr(_urFragments.length)
|
||||
: l10n.tokenCashu,
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 12,
|
||||
@@ -398,9 +400,9 @@ class _ShareTokenScreenState extends State<ShareTokenScreen> {
|
||||
children: [
|
||||
Icon(LucideIcons.copy, color: Colors.white, size: 20),
|
||||
const SizedBox(width: 8),
|
||||
const Text(
|
||||
'Copiar',
|
||||
style: TextStyle(
|
||||
Text(
|
||||
L10n.of(context)!.copy,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
@@ -430,9 +432,9 @@ class _ShareTokenScreenState extends State<ShareTokenScreen> {
|
||||
children: [
|
||||
Icon(LucideIcons.share2, color: Colors.white, size: 20),
|
||||
const SizedBox(width: 8),
|
||||
const Text(
|
||||
'Compartir',
|
||||
style: TextStyle(
|
||||
Text(
|
||||
L10n.of(context)!.share,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
@@ -465,7 +467,7 @@ class _ShareTokenScreenState extends State<ShareTokenScreen> {
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Guarda este token hasta que el receptor lo reclame. Si lo pierdes, perderas los fondos.',
|
||||
L10n.of(context)!.keepTokenWarning,
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 14,
|
||||
@@ -482,7 +484,7 @@ class _ShareTokenScreenState extends State<ShareTokenScreen> {
|
||||
Clipboard.setData(ClipboardData(text: widget.token));
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: const Text('Token copiado al portapapeles'),
|
||||
content: Text(L10n.of(context)!.tokenCopiedToClipboard),
|
||||
backgroundColor: AppColors.success,
|
||||
behavior: SnackBarBehavior.floating,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||
|
||||
@@ -5,6 +5,7 @@ import 'package:provider/provider.dart';
|
||||
import 'package:qr_flutter/qr_flutter.dart';
|
||||
import 'package:lucide_icons/lucide_icons.dart';
|
||||
import 'package:cdk_flutter/cdk_flutter.dart' hide WalletProvider;
|
||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
import '../../core/constants/colors.dart';
|
||||
import '../../core/constants/dimensions.dart';
|
||||
import '../../core/utils/formatters.dart';
|
||||
@@ -85,7 +86,7 @@ class _InvoiceScreenState extends State<InvoiceScreen> {
|
||||
default:
|
||||
// Cualquier otro estado se trata como error
|
||||
_status = MintStatus.error;
|
||||
_errorMessage = 'Estado desconocido';
|
||||
_errorMessage = L10n.of(context)!.unknownState;
|
||||
}
|
||||
});
|
||||
},
|
||||
@@ -112,12 +113,15 @@ class _InvoiceScreenState extends State<InvoiceScreen> {
|
||||
// Esperar a que termine el confetti antes de navegar
|
||||
Future.delayed(const Duration(seconds: 3), () {
|
||||
if (mounted) {
|
||||
final l10n = L10n.of(context)!;
|
||||
final formattedAmount = '+${UnitFormatter.formatBalance(widget.amount, widget.unit)}';
|
||||
final unitLabel = UnitFormatter.getUnitLabel(widget.unit);
|
||||
// Volver al home con mensaje de éxito
|
||||
Navigator.popUntil(context, (route) => route.isFirst);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
'+${UnitFormatter.formatBalance(widget.amount, widget.unit)} ${UnitFormatter.getUnitLabel(widget.unit)} depositados',
|
||||
l10n.deposited(formattedAmount, unitLabel),
|
||||
),
|
||||
backgroundColor: AppColors.success,
|
||||
duration: const Duration(seconds: 3),
|
||||
@@ -148,9 +152,9 @@ class _InvoiceScreenState extends State<InvoiceScreen> {
|
||||
onPressed: () => Navigator.pop(context),
|
||||
)
|
||||
: null,
|
||||
title: const Text(
|
||||
'Pagar invoice',
|
||||
style: TextStyle(
|
||||
title: Text(
|
||||
L10n.of(context)!.payInvoiceTitle,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.white,
|
||||
@@ -160,9 +164,9 @@ class _InvoiceScreenState extends State<InvoiceScreen> {
|
||||
if (_status == MintStatus.unpaid)
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text(
|
||||
'Cancelar',
|
||||
style: TextStyle(
|
||||
child: Text(
|
||||
L10n.of(context)!.cancel,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 14,
|
||||
color: AppColors.textSecondary,
|
||||
@@ -197,17 +201,17 @@ class _InvoiceScreenState extends State<InvoiceScreen> {
|
||||
}
|
||||
|
||||
Widget _buildLoading() {
|
||||
return const Center(
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
CircularProgressIndicator(
|
||||
const CircularProgressIndicator(
|
||||
color: AppColors.primaryAction,
|
||||
),
|
||||
SizedBox(height: AppDimensions.paddingMedium),
|
||||
const SizedBox(height: AppDimensions.paddingMedium),
|
||||
Text(
|
||||
'Generando invoice...',
|
||||
style: TextStyle(
|
||||
L10n.of(context)!.generatingInvoice,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 16,
|
||||
color: AppColors.textSecondary,
|
||||
@@ -285,9 +289,9 @@ class _InvoiceScreenState extends State<InvoiceScreen> {
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppDimensions.paddingMedium),
|
||||
const Text(
|
||||
'Error',
|
||||
style: TextStyle(
|
||||
Text(
|
||||
L10n.of(context)!.error,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
@@ -298,7 +302,7 @@ class _InvoiceScreenState extends State<InvoiceScreen> {
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 32),
|
||||
child: Text(
|
||||
_errorMessage ?? 'Error desconocido',
|
||||
_errorMessage ?? L10n.of(context)!.unknownError,
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 14,
|
||||
@@ -316,9 +320,9 @@ class _InvoiceScreenState extends State<InvoiceScreen> {
|
||||
color: Colors.white.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: const Text(
|
||||
'Volver',
|
||||
style: TextStyle(
|
||||
child: Text(
|
||||
L10n.of(context)!.back,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
@@ -333,13 +337,14 @@ class _InvoiceScreenState extends State<InvoiceScreen> {
|
||||
}
|
||||
|
||||
Widget _buildHeader() {
|
||||
final l10n = L10n.of(context)!;
|
||||
final formattedAmount = UnitFormatter.formatBalance(widget.amount, widget.unit);
|
||||
final unitLabel = UnitFormatter.getUnitLabel(widget.unit);
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
Text(
|
||||
'Depositar $formattedAmount $unitLabel',
|
||||
l10n.depositAmountTitle(formattedAmount, unitLabel),
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 24,
|
||||
@@ -404,7 +409,7 @@ class _InvoiceScreenState extends State<InvoiceScreen> {
|
||||
Icon(LucideIcons.copy, color: AppColors.textSecondary, size: 20),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'Copiar invoice',
|
||||
L10n.of(context)!.copyInvoice,
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 16,
|
||||
@@ -419,6 +424,7 @@ class _InvoiceScreenState extends State<InvoiceScreen> {
|
||||
}
|
||||
|
||||
Widget _buildStatus() {
|
||||
final l10n = L10n.of(context)!;
|
||||
IconData icon;
|
||||
String text;
|
||||
Color color;
|
||||
@@ -426,27 +432,27 @@ class _InvoiceScreenState extends State<InvoiceScreen> {
|
||||
switch (_status) {
|
||||
case MintStatus.loading:
|
||||
icon = LucideIcons.clock;
|
||||
text = 'Generando...';
|
||||
text = l10n.generating;
|
||||
color = AppColors.textSecondary;
|
||||
break;
|
||||
case MintStatus.unpaid:
|
||||
icon = LucideIcons.clock;
|
||||
text = 'Esperando pago...';
|
||||
text = l10n.waitingForPayment;
|
||||
color = AppColors.textSecondary;
|
||||
break;
|
||||
case MintStatus.paid:
|
||||
icon = LucideIcons.checkCircle;
|
||||
text = 'Pago recibido';
|
||||
text = l10n.paymentReceived;
|
||||
color = AppColors.success;
|
||||
break;
|
||||
case MintStatus.issued:
|
||||
icon = LucideIcons.checkCircle2;
|
||||
text = 'Tokens emitidos!';
|
||||
text = l10n.tokensIssued;
|
||||
color = AppColors.success;
|
||||
break;
|
||||
case MintStatus.error:
|
||||
icon = LucideIcons.xCircle;
|
||||
text = 'Error';
|
||||
text = l10n.error;
|
||||
color = AppColors.error;
|
||||
break;
|
||||
}
|
||||
@@ -489,7 +495,7 @@ class _InvoiceScreenState extends State<InvoiceScreen> {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Descripcion:',
|
||||
L10n.of(context)!.description,
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 12,
|
||||
@@ -515,10 +521,10 @@ class _InvoiceScreenState extends State<InvoiceScreen> {
|
||||
await Clipboard.setData(ClipboardData(text: _invoice!));
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Invoice copiado al portapapeles'),
|
||||
SnackBar(
|
||||
content: Text(L10n.of(context)!.invoiceCopiedToClipboard),
|
||||
backgroundColor: AppColors.success,
|
||||
duration: Duration(seconds: 2),
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:lucide_icons/lucide_icons.dart';
|
||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
import '../../core/constants/colors.dart';
|
||||
import '../../core/constants/dimensions.dart';
|
||||
import '../../core/utils/formatters.dart';
|
||||
import '../../widgets/common/gradient_background.dart';
|
||||
import '../../widgets/common/glass_card.dart';
|
||||
import '../../widgets/common/primary_button.dart';
|
||||
import '../../widgets/common/numpad_widget.dart';
|
||||
import '../../providers/wallet_provider.dart';
|
||||
import 'invoice_screen.dart';
|
||||
|
||||
@@ -19,9 +21,9 @@ class MintScreen extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _MintScreenState extends State<MintScreen> {
|
||||
final TextEditingController _amountController = TextEditingController();
|
||||
final TextEditingController _descriptionController = TextEditingController();
|
||||
|
||||
String _amountValue = '';
|
||||
late String _activeUnit;
|
||||
|
||||
bool _isProcessing = false;
|
||||
@@ -35,7 +37,6 @@ class _MintScreenState extends State<MintScreen> {
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_amountController.dispose();
|
||||
_descriptionController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
@@ -43,8 +44,8 @@ class _MintScreenState extends State<MintScreen> {
|
||||
/// Obtiene la etiqueta de la unidad para display
|
||||
String get _unitLabel => UnitFormatter.getUnitLabel(_activeUnit);
|
||||
|
||||
/// Parsea el input del usuario a BigInt según la unidad
|
||||
BigInt get _amount => UnitFormatter.parseUserInput(_amountController.text, _activeUnit);
|
||||
/// Parsea los dígitos crudos a BigInt (ya son centavos para USD/EUR)
|
||||
BigInt get _amount => UnitFormatter.parseRawDigits(_amountValue, _activeUnit);
|
||||
|
||||
bool get _isValidAmount => _amount > BigInt.zero;
|
||||
|
||||
@@ -60,9 +61,9 @@ class _MintScreenState extends State<MintScreen> {
|
||||
icon: const Icon(LucideIcons.arrowLeft, color: Colors.white),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
title: const Text(
|
||||
'Depositar',
|
||||
style: TextStyle(
|
||||
title: Text(
|
||||
L10n.of(context)!.deposit,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.white,
|
||||
@@ -110,62 +111,48 @@ class _MintScreenState extends State<MintScreen> {
|
||||
|
||||
Widget _buildAmountSection() {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Display del monto
|
||||
_buildAmountDisplay(),
|
||||
const SizedBox(height: AppDimensions.paddingMedium),
|
||||
|
||||
// Teclado numérico
|
||||
NumpadWidget(
|
||||
value: _amountValue,
|
||||
onChanged: (newValue) {
|
||||
setState(() {
|
||||
_errorMessage = null;
|
||||
_amountValue = newValue;
|
||||
});
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAmountDisplay() {
|
||||
final displayAmount = UnitFormatter.formatRawDigitsForDisplay(_amountValue, _activeUnit);
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
Text(
|
||||
'Monto a depositar:',
|
||||
displayAmount,
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: AppColors.textSecondary,
|
||||
fontSize: 48,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: _isValidAmount || _amountValue.isEmpty
|
||||
? Colors.white
|
||||
: AppColors.error,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppDimensions.paddingSmall),
|
||||
|
||||
// Input de monto
|
||||
GlassCard(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: AppDimensions.paddingMedium,
|
||||
vertical: AppDimensions.paddingSmall,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _amountController,
|
||||
keyboardType: TextInputType.number,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 32,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.white,
|
||||
),
|
||||
decoration: InputDecoration(
|
||||
hintText: '0',
|
||||
hintStyle: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 32,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.white.withValues(alpha: 0.3),
|
||||
),
|
||||
border: InputBorder.none,
|
||||
),
|
||||
onChanged: (_) => setState(() {
|
||||
_errorMessage = null;
|
||||
}),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
_unitLabel,
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
_unitLabel,
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 18,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -173,11 +160,12 @@ class _MintScreenState extends State<MintScreen> {
|
||||
}
|
||||
|
||||
Widget _buildDescriptionSection() {
|
||||
final l10n = L10n.of(context)!;
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Descripcion (opcional):',
|
||||
l10n.descriptionOptional,
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 16,
|
||||
@@ -199,7 +187,7 @@ class _MintScreenState extends State<MintScreen> {
|
||||
color: Colors.white,
|
||||
),
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Ej: Deposito El Caju',
|
||||
hintText: l10n.depositPlaceholder,
|
||||
hintStyle: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 16,
|
||||
@@ -250,8 +238,9 @@ class _MintScreenState extends State<MintScreen> {
|
||||
}
|
||||
|
||||
Widget _buildGenerateButton() {
|
||||
final l10n = L10n.of(context)!;
|
||||
return PrimaryButton(
|
||||
text: _isProcessing ? 'Generando...' : 'Generar invoice',
|
||||
text: _isProcessing ? l10n.generating : l10n.generateInvoice,
|
||||
onPressed: _isValidAmount && !_isProcessing ? _showConfirmation : null,
|
||||
);
|
||||
}
|
||||
@@ -351,9 +340,9 @@ class _ConfirmationModal extends StatelessWidget {
|
||||
const SizedBox(height: AppDimensions.paddingMedium),
|
||||
|
||||
// Título
|
||||
const Text(
|
||||
'Depositar Lightning',
|
||||
style: TextStyle(
|
||||
Text(
|
||||
L10n.of(context)!.depositLightning,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
@@ -401,10 +390,10 @@ class _ConfirmationModal extends StatelessWidget {
|
||||
color: Colors.white.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: const Center(
|
||||
child: Center(
|
||||
child: Text(
|
||||
'Cancelar',
|
||||
style: TextStyle(
|
||||
L10n.of(context)!.cancel,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
@@ -418,7 +407,7 @@ class _ConfirmationModal extends StatelessWidget {
|
||||
const SizedBox(width: AppDimensions.paddingMedium),
|
||||
Expanded(
|
||||
child: PrimaryButton(
|
||||
text: 'Generar invoice',
|
||||
text: L10n.of(context)!.generateInvoice,
|
||||
onPressed: onConfirm,
|
||||
height: 52,
|
||||
),
|
||||
|
||||
@@ -0,0 +1,640 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:lucide_icons/lucide_icons.dart';
|
||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
import '../../core/constants/colors.dart';
|
||||
import '../../core/constants/dimensions.dart';
|
||||
import '../../core/utils/formatters.dart';
|
||||
import '../../core/services/lnurl_service.dart';
|
||||
import '../../widgets/common/gradient_background.dart';
|
||||
import '../../widgets/common/glass_card.dart';
|
||||
import '../../widgets/common/primary_button.dart';
|
||||
import '../../widgets/common/numpad_widget.dart';
|
||||
import '../../providers/wallet_provider.dart';
|
||||
import '../../providers/price_provider.dart';
|
||||
|
||||
/// Pantalla para elegir monto en pagos LNURL/Lightning Address
|
||||
class AmountScreen extends StatefulWidget {
|
||||
/// Destino (LNURL o Lightning Address)
|
||||
final String destination;
|
||||
|
||||
/// Tipo de destino
|
||||
final LnInputType destinationType;
|
||||
|
||||
/// Parámetros LNURL ya resueltos
|
||||
final LnurlPayParams params;
|
||||
|
||||
const AmountScreen({
|
||||
super.key,
|
||||
required this.destination,
|
||||
required this.destinationType,
|
||||
required this.params,
|
||||
});
|
||||
|
||||
@override
|
||||
State<AmountScreen> createState() => _AmountScreenState();
|
||||
}
|
||||
|
||||
class _AmountScreenState extends State<AmountScreen> {
|
||||
String _amount = '';
|
||||
bool _isProcessing = false;
|
||||
String? _errorMessage;
|
||||
BigInt _availableBalance = BigInt.zero;
|
||||
late String _activeUnit;
|
||||
|
||||
// Equivalente en la otra unidad (para mostrar)
|
||||
String? _equivalentDisplay;
|
||||
|
||||
// Contador para evitar race conditions en _updateEquivalent
|
||||
int _equivalentGeneration = 0;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_activeUnit = context.read<WalletProvider>().activeUnit;
|
||||
_loadBalance();
|
||||
}
|
||||
|
||||
Future<void> _loadBalance() async {
|
||||
final walletProvider = context.read<WalletProvider>();
|
||||
final balance = await walletProvider.getBalance();
|
||||
if (mounted) {
|
||||
setState(() => _availableBalance = balance);
|
||||
}
|
||||
}
|
||||
|
||||
/// Obtiene el monto ingresado en la unidad base (centavos para USD, sats para sat)
|
||||
BigInt get _amountInBaseUnit {
|
||||
return UnitFormatter.parseRawDigits(_amount, _activeUnit);
|
||||
}
|
||||
|
||||
/// Verifica si la unidad activa es fiat (necesita conversión)
|
||||
bool get _isFiatUnit {
|
||||
return _activeUnit.toLowerCase() == 'usd' || _activeUnit.toLowerCase() == 'eur';
|
||||
}
|
||||
|
||||
/// Convierte el monto a sats para LNURL
|
||||
Future<BigInt> _getAmountInSats() async {
|
||||
if (!_isFiatUnit) {
|
||||
// Ya está en sats
|
||||
return _amountInBaseUnit;
|
||||
}
|
||||
|
||||
// Convertir fiat a sats usando PriceProvider
|
||||
final priceProvider = context.read<PriceProvider>();
|
||||
return await priceProvider.fiatCentsToSats(_amountInBaseUnit, _activeUnit);
|
||||
}
|
||||
|
||||
/// Actualiza el equivalente mostrado
|
||||
Future<void> _updateEquivalent() async {
|
||||
final gen = ++_equivalentGeneration;
|
||||
|
||||
if (_amount.isEmpty || _amountInBaseUnit == BigInt.zero) {
|
||||
setState(() => _equivalentDisplay = null);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
final priceProvider = context.read<PriceProvider>();
|
||||
|
||||
if (_isFiatUnit) {
|
||||
// Mostrar equivalente en sats
|
||||
final sats = await priceProvider.fiatCentsToSats(_amountInBaseUnit, _activeUnit);
|
||||
if (gen != _equivalentGeneration) return; // Descartar si hay llamada más reciente
|
||||
setState(() {
|
||||
_equivalentDisplay = '≈ ${UnitFormatter.formatBalance(sats, 'sat')} sat';
|
||||
});
|
||||
} else {
|
||||
// Mostrar equivalente en USD si hay precio
|
||||
if (priceProvider.hasPrice) {
|
||||
final usdCents = await priceProvider.satsToFiatCents(_amountInBaseUnit, 'usd');
|
||||
if (gen != _equivalentGeneration) return; // Descartar si hay llamada más reciente
|
||||
setState(() {
|
||||
_equivalentDisplay = '≈ ${UnitFormatter.formatBalance(usdCents, 'usd')} USD';
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (_) {
|
||||
if (gen != _equivalentGeneration) return;
|
||||
setState(() => _equivalentDisplay = null);
|
||||
}
|
||||
}
|
||||
|
||||
bool get _isAmountValid => _amountInBaseUnit > BigInt.zero;
|
||||
|
||||
bool get _canPay {
|
||||
return _isAmountValid && !_isProcessing && _amountInBaseUnit <= _availableBalance;
|
||||
}
|
||||
|
||||
String get _destinationLabel {
|
||||
return widget.destinationType == LnInputType.lightningAddress
|
||||
? 'Lightning Address'
|
||||
: 'LNURL';
|
||||
}
|
||||
|
||||
@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.send,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
body: SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(AppDimensions.paddingMedium),
|
||||
child: Column(
|
||||
children: [
|
||||
// Destino
|
||||
_buildDestinationCard(),
|
||||
const SizedBox(height: AppDimensions.paddingLarge),
|
||||
|
||||
// Display del monto
|
||||
_buildAmountDisplay(),
|
||||
const SizedBox(height: AppDimensions.paddingSmall),
|
||||
|
||||
// Rango permitido (siempre en sats porque es LNURL)
|
||||
_buildRangeInfo(),
|
||||
const SizedBox(height: AppDimensions.paddingLarge),
|
||||
|
||||
// Teclado numérico
|
||||
_buildNumpad(),
|
||||
|
||||
// Error message
|
||||
if (_errorMessage != null) ...[
|
||||
const SizedBox(height: AppDimensions.paddingMedium),
|
||||
_buildErrorMessage(),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Balance y botón pagar
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(AppDimensions.paddingMedium),
|
||||
child: Column(
|
||||
children: [
|
||||
_buildBalanceInfo(),
|
||||
const SizedBox(height: AppDimensions.paddingMedium),
|
||||
_buildPayButton(),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDestinationCard() {
|
||||
return GlassCard(
|
||||
padding: const EdgeInsets.all(AppDimensions.paddingMedium),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(10),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.primaryAction.withValues(alpha: 0.2),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(
|
||||
widget.destinationType == LnInputType.lightningAddress
|
||||
? LucideIcons.atSign
|
||||
: LucideIcons.link,
|
||||
color: AppColors.primaryAction,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
_destinationLabel,
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 12,
|
||||
color: AppColors.textSecondary.withValues(alpha: 0.7),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
'Payment to ${widget.destination}',
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Colors.white,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAmountDisplay() {
|
||||
final displayAmount = UnitFormatter.formatRawDigitsForDisplay(_amount, _activeUnit);
|
||||
final unitLabel = UnitFormatter.getUnitLabel(_activeUnit);
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
Text(
|
||||
displayAmount,
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 56,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: _isAmountValid || _amount.isEmpty
|
||||
? Colors.white
|
||||
: AppColors.error,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
unitLabel,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 18,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
// Mostrar equivalente en la otra unidad
|
||||
if (_equivalentDisplay != null) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
_equivalentDisplay!,
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 14,
|
||||
color: AppColors.textSecondary.withValues(alpha: 0.7),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildRangeInfo() {
|
||||
// LNURL min/max siempre en sats
|
||||
final min = widget.params.minSats;
|
||||
final max = widget.params.maxSats;
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: 0.05),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Text(
|
||||
'Min: $min • Max: $max sat',
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 13,
|
||||
color: AppColors.textSecondary.withValues(alpha: 0.7),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildNumpad() {
|
||||
return NumpadWidget(
|
||||
value: _amount,
|
||||
onChanged: (newValue) {
|
||||
setState(() {
|
||||
_errorMessage = null;
|
||||
_amount = newValue;
|
||||
});
|
||||
// Actualizar equivalente de forma asíncrona
|
||||
_updateEquivalent();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBalanceInfo() {
|
||||
final unitLabel = UnitFormatter.getUnitLabel(_activeUnit);
|
||||
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
'${L10n.of(context)!.available} ',
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 14,
|
||||
color: AppColors.textSecondary.withValues(alpha: 0.7),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'${UnitFormatter.formatBalance(_availableBalance, _activeUnit)} $unitLabel',
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.primaryAction,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPayButton() {
|
||||
final l10n = L10n.of(context)!;
|
||||
|
||||
return PrimaryButton(
|
||||
text: _isProcessing ? l10n.paying : l10n.payInvoice,
|
||||
onPressed: _canPay ? _processPayment : null,
|
||||
);
|
||||
}
|
||||
|
||||
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),
|
||||
),
|
||||
),
|
||||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _processPayment() async {
|
||||
if (!_canPay) return;
|
||||
|
||||
setState(() {
|
||||
_isProcessing = true;
|
||||
_errorMessage = null;
|
||||
});
|
||||
|
||||
try {
|
||||
// 1. Convertir monto a sats para LNURL
|
||||
final amountSats = await _getAmountInSats();
|
||||
|
||||
// Validar contra límites LNURL
|
||||
if (!widget.params.isAmountValid(amountSats)) {
|
||||
setState(() {
|
||||
_isProcessing = false;
|
||||
_errorMessage = L10n.of(context)!.amountOutOfRange;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. Obtener invoice desde LNURL callback
|
||||
final invoiceResult = await LnurlService.fetchInvoice(
|
||||
widget.params.callback,
|
||||
amountSats,
|
||||
);
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
// 3. Obtener quote del mint (en la unidad del mint)
|
||||
final walletProvider = context.read<WalletProvider>();
|
||||
final quote = await walletProvider.getMeltQuote(invoiceResult.invoice);
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
final total = quote.amount + quote.feeReserve;
|
||||
|
||||
// 4. Verificar balance suficiente
|
||||
if (total > _availableBalance) {
|
||||
setState(() {
|
||||
_isProcessing = false;
|
||||
_errorMessage = L10n.of(context)!.insufficientBalance;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 5. Mostrar confirmación (montos del mint, en activeUnit)
|
||||
final confirmed = await _showConfirmation(quote.amount, quote.feeReserve, total);
|
||||
|
||||
if (!confirmed) {
|
||||
if (mounted) setState(() => _isProcessing = false);
|
||||
return;
|
||||
}
|
||||
if (!mounted) return;
|
||||
|
||||
// 6. Ejecutar pago
|
||||
final totalPaid = await walletProvider.melt(quote);
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
// 7. Mostrar éxito y volver
|
||||
final l10n = L10n.of(context)!;
|
||||
final unitLabel = UnitFormatter.getUnitLabel(_activeUnit);
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(l10n.sent('-${UnitFormatter.formatBalance(totalPaid, _activeUnit)}', unitLabel)),
|
||||
backgroundColor: AppColors.success,
|
||||
duration: const Duration(seconds: 3),
|
||||
),
|
||||
);
|
||||
|
||||
// Volver a home (pop dos veces: AmountScreen y MeltScreen)
|
||||
Navigator.pop(context);
|
||||
Navigator.pop(context);
|
||||
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isProcessing = false;
|
||||
_errorMessage = _parseError(e.toString());
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
String _parseError(String error) {
|
||||
final lower = error.toLowerCase();
|
||||
final l10n = L10n.of(context)!;
|
||||
|
||||
if (lower.contains('insufficient') || lower.contains('not enough')) {
|
||||
return l10n.insufficientBalance;
|
||||
} else if (lower.contains('expired')) {
|
||||
return l10n.invoiceExpired;
|
||||
} else if (lower.contains('min') || lower.contains('max')) {
|
||||
return l10n.amountOutOfRange;
|
||||
} else if (lower.contains('precio') || lower.contains('price')) {
|
||||
return 'No se pudo obtener el precio de BTC';
|
||||
}
|
||||
|
||||
return error.replaceFirst('Exception: ', '');
|
||||
}
|
||||
|
||||
Future<bool> _showConfirmation(BigInt amount, BigInt fee, BigInt total) async {
|
||||
final unitLabel = UnitFormatter.getUnitLabel(_activeUnit);
|
||||
|
||||
final result = await showModalBottomSheet<bool>(
|
||||
context: context,
|
||||
backgroundColor: Colors.transparent,
|
||||
builder: (context) => Container(
|
||||
padding: const EdgeInsets.all(AppDimensions.paddingMedium),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.deepVoidPurple,
|
||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(24)),
|
||||
border: Border.all(
|
||||
color: Colors.white.withValues(alpha: 0.1),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Handle
|
||||
Container(
|
||||
margin: const EdgeInsets.only(bottom: 16),
|
||||
width: 40,
|
||||
height: 4,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: 0.3),
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
|
||||
// Icono
|
||||
Container(
|
||||
width: 64,
|
||||
height: 64,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.primaryAction.withValues(alpha: 0.2),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Icon(
|
||||
LucideIcons.zap,
|
||||
color: AppColors.primaryAction,
|
||||
size: 32,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppDimensions.paddingMedium),
|
||||
|
||||
// Título
|
||||
Text(
|
||||
L10n.of(context)!.confirmPayment,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppDimensions.paddingSmall),
|
||||
|
||||
// Monto (en la unidad del mint)
|
||||
Text(
|
||||
'${UnitFormatter.formatBalance(amount, _activeUnit)} $unitLabel',
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 28,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'+ ~${UnitFormatter.formatBalance(fee, _activeUnit)} $unitLabel fee',
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 14,
|
||||
color: AppColors.textSecondary.withValues(alpha: 0.7),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppDimensions.paddingSmall),
|
||||
Text(
|
||||
'Total: ${UnitFormatter.formatBalance(total, _activeUnit)} $unitLabel',
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.primaryAction,
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: AppDimensions.paddingLarge),
|
||||
|
||||
// Botones
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: GestureDetector(
|
||||
onTap: () => Navigator.pop(context, false),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
L10n.of(context)!.cancel,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: AppDimensions.paddingMedium),
|
||||
Expanded(
|
||||
child: PrimaryButton(
|
||||
text: L10n.of(context)!.pay,
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
height: 52,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: AppDimensions.paddingSmall),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
return result ?? false;
|
||||
}
|
||||
}
|
||||
@@ -3,17 +3,25 @@ import 'package:flutter/services.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:lucide_icons/lucide_icons.dart';
|
||||
import 'package:cdk_flutter/cdk_flutter.dart' hide WalletProvider;
|
||||
import 'package:flutter_gen/gen_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';
|
||||
import '../../core/services/lnurl_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 '../10_scanner/scan_screen.dart';
|
||||
import 'amount_screen.dart';
|
||||
|
||||
/// Pantalla para retirar sats a Lightning (Melt)
|
||||
class MeltScreen extends StatefulWidget {
|
||||
const MeltScreen({super.key});
|
||||
/// Invoice inicial (pre-cargado desde QR scanner o deep link)
|
||||
final String? initialInvoice;
|
||||
|
||||
const MeltScreen({super.key, this.initialInvoice});
|
||||
|
||||
@override
|
||||
State<MeltScreen> createState() => _MeltScreenState();
|
||||
@@ -34,11 +42,22 @@ class _MeltScreenState extends State<MeltScreen> {
|
||||
BigInt _availableBalance = BigInt.zero;
|
||||
String? _errorMessage;
|
||||
|
||||
// Estado para LNURL/Lightning Address (solo para mostrar loading)
|
||||
LnInputType _inputType = LnInputType.unknown;
|
||||
bool _isResolvingLnurl = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_activeUnit = context.read<WalletProvider>().activeUnit;
|
||||
_loadBalance();
|
||||
// Pre-cargar invoice inicial si existe
|
||||
if (widget.initialInvoice != null && widget.initialInvoice!.isNotEmpty) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_invoiceController.text = widget.initialInvoice!;
|
||||
_onInvoiceChanged(widget.initialInvoice!);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Obtiene la etiqueta de la unidad para display
|
||||
@@ -72,9 +91,9 @@ class _MeltScreenState extends State<MeltScreen> {
|
||||
icon: const Icon(LucideIcons.arrowLeft, color: Colors.white),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
title: const Text(
|
||||
'Retirar',
|
||||
style: TextStyle(
|
||||
title: Text(
|
||||
L10n.of(context)!.withdraw,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.white,
|
||||
@@ -93,7 +112,7 @@ class _MeltScreenState extends State<MeltScreen> {
|
||||
children: [
|
||||
// Instrucciones
|
||||
Text(
|
||||
'Pega el invoice Lightning:',
|
||||
L10n.of(context)!.pasteLightningInvoice,
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 16,
|
||||
@@ -108,11 +127,14 @@ class _MeltScreenState extends State<MeltScreen> {
|
||||
|
||||
const SizedBox(height: AppDimensions.paddingMedium),
|
||||
|
||||
// Botón pegar del portapapeles
|
||||
_buildPasteButton(),
|
||||
// Botones pegar y escanear
|
||||
_buildActionButtons(),
|
||||
|
||||
const SizedBox(height: AppDimensions.paddingLarge),
|
||||
|
||||
// Loading LNURL resolve
|
||||
if (_isResolvingLnurl) _buildResolvingLnurl(),
|
||||
|
||||
// Loading quote
|
||||
if (_isLoadingQuote) _buildLoadingQuote(),
|
||||
|
||||
@@ -161,7 +183,7 @@ class _MeltScreenState extends State<MeltScreen> {
|
||||
color: Colors.white,
|
||||
),
|
||||
decoration: InputDecoration(
|
||||
hintText: 'lnbc1000n1pj...',
|
||||
hintText: 'lnbc..., lnurl1..., user@domain.com',
|
||||
hintStyle: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 14,
|
||||
@@ -175,6 +197,18 @@ class _MeltScreenState extends State<MeltScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildActionButtons() {
|
||||
return Row(
|
||||
children: [
|
||||
// Botón pegar (expandido)
|
||||
Expanded(child: _buildPasteButton()),
|
||||
const SizedBox(width: 12),
|
||||
// Botón escanear QR
|
||||
_buildScanButton(),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPasteButton() {
|
||||
return GestureDetector(
|
||||
onTap: _pasteFromClipboard,
|
||||
@@ -201,7 +235,7 @@ class _MeltScreenState extends State<MeltScreen> {
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'Pegar del portapapeles',
|
||||
L10n.of(context)!.pasteFromClipboard,
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 16,
|
||||
@@ -215,6 +249,72 @@ class _MeltScreenState extends State<MeltScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
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.invoiceOnly),
|
||||
),
|
||||
);
|
||||
|
||||
if (result != null && result.isNotEmpty && mounted) {
|
||||
_invoiceController.text = result;
|
||||
_onInvoiceChanged(result);
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildResolvingLnurl() {
|
||||
final typeLabel = _inputType == LnInputType.lightningAddress
|
||||
? 'Lightning Address'
|
||||
: 'LNURL';
|
||||
|
||||
return GlassCard(
|
||||
padding: const EdgeInsets.all(AppDimensions.paddingMedium),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: AppColors.primaryAction,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Text(
|
||||
L10n.of(context)!.resolvingType(typeLabel),
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 14,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildLoadingQuote() {
|
||||
return GlassCard(
|
||||
padding: const EdgeInsets.all(AppDimensions.paddingMedium),
|
||||
@@ -231,7 +331,7 @@ class _MeltScreenState extends State<MeltScreen> {
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Text(
|
||||
'Obteniendo quote...',
|
||||
L10n.of(context)!.gettingQuote,
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 14,
|
||||
@@ -267,7 +367,7 @@ class _MeltScreenState extends State<MeltScreen> {
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'Invoice válido',
|
||||
L10n.of(context)!.validInvoice,
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 14,
|
||||
@@ -284,7 +384,7 @@ class _MeltScreenState extends State<MeltScreen> {
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
'Monto:',
|
||||
L10n.of(context)!.amount,
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 14,
|
||||
@@ -309,7 +409,7 @@ class _MeltScreenState extends State<MeltScreen> {
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
'Fee reservado:',
|
||||
L10n.of(context)!.feeReserved,
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 14,
|
||||
@@ -336,9 +436,9 @@ class _MeltScreenState extends State<MeltScreen> {
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Text(
|
||||
'Total:',
|
||||
style: TextStyle(
|
||||
Text(
|
||||
L10n.of(context)!.total,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
@@ -362,7 +462,7 @@ class _MeltScreenState extends State<MeltScreen> {
|
||||
if (!hasEnoughBalance) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Balance insuficiente',
|
||||
L10n.of(context)!.insufficientBalance,
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 12,
|
||||
@@ -410,7 +510,7 @@ class _MeltScreenState extends State<MeltScreen> {
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
'Disponible: ',
|
||||
'${L10n.of(context)!.available} ',
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 14,
|
||||
@@ -431,18 +531,46 @@ class _MeltScreenState extends State<MeltScreen> {
|
||||
}
|
||||
|
||||
Widget _buildPayButton() {
|
||||
final canPay = _isValidInvoice &&
|
||||
_quote != null &&
|
||||
!_isProcessing &&
|
||||
!_isLoadingQuote &&
|
||||
_total <= _availableBalance;
|
||||
final l10n = L10n.of(context)!;
|
||||
final hasInput = _invoiceController.text.trim().isNotEmpty;
|
||||
|
||||
// Para BOLT11 con quote válido: botón Pagar
|
||||
final hasValidQuote = _isValidInvoice && _quote != null && _total <= _availableBalance;
|
||||
|
||||
// Para LNURL/Address, unknown, o BOLT11 sin quote válido: botón Continuar
|
||||
final needsProcessing = _inputType == LnInputType.lnurl ||
|
||||
_inputType == LnInputType.lightningAddress ||
|
||||
_inputType == LnInputType.unknown ||
|
||||
(_inputType == LnInputType.bolt11Invoice && !hasValidQuote);
|
||||
|
||||
// Determinar estado del botón
|
||||
final canPay = !_isProcessing && !_isLoadingQuote && !_isResolvingLnurl && hasValidQuote;
|
||||
final canContinue = !_isProcessing && !_isLoadingQuote && !_isResolvingLnurl &&
|
||||
hasInput && needsProcessing;
|
||||
|
||||
// Texto del botón
|
||||
String buttonText;
|
||||
if (_isProcessing) {
|
||||
buttonText = l10n.paying;
|
||||
} else if (needsProcessing && !hasValidQuote) {
|
||||
buttonText = l10n.continue_;
|
||||
} else {
|
||||
buttonText = l10n.payInvoice;
|
||||
}
|
||||
|
||||
return PrimaryButton(
|
||||
text: _isProcessing ? 'Pagando...' : 'Pagar invoice',
|
||||
onPressed: canPay ? _showConfirmation : null,
|
||||
text: buttonText,
|
||||
onPressed: canPay ? _handlePay : (canContinue ? _processInput : null),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _handlePay() async {
|
||||
// Cerrar teclado
|
||||
FocusScope.of(context).unfocus();
|
||||
// Flujo normal con invoice directo
|
||||
_showConfirmation();
|
||||
}
|
||||
|
||||
Future<void> _pasteFromClipboard() async {
|
||||
final clipboardData = await Clipboard.getData(Clipboard.kTextPlain);
|
||||
if (clipboardData?.text != null) {
|
||||
@@ -458,6 +586,7 @@ class _MeltScreenState extends State<MeltScreen> {
|
||||
_isValidInvoice = false;
|
||||
|
||||
if (value.isEmpty) {
|
||||
_inputType = LnInputType.unknown;
|
||||
_invoiceAmount = BigInt.zero;
|
||||
_feeReserve = BigInt.zero;
|
||||
_total = BigInt.zero;
|
||||
@@ -465,19 +594,126 @@ class _MeltScreenState extends State<MeltScreen> {
|
||||
}
|
||||
});
|
||||
|
||||
// Verificar formato básico
|
||||
final trimmed = value.trim().toLowerCase();
|
||||
if (!trimmed.startsWith('lnbc') &&
|
||||
!trimmed.startsWith('lntb') &&
|
||||
!trimmed.startsWith('lnbcrt')) {
|
||||
setState(() {
|
||||
_errorMessage = 'Invoice inválido';
|
||||
});
|
||||
return;
|
||||
}
|
||||
// Detectar tipo de input (sin mostrar error, solo detectar)
|
||||
final inputType = LnurlService.detectType(value);
|
||||
setState(() => _inputType = inputType);
|
||||
|
||||
// Obtener quote real
|
||||
_getQuote(value.trim());
|
||||
// Solo procesar automáticamente invoices BOLT11
|
||||
// LNURL y Lightning Address requieren botón explícito
|
||||
if (inputType == LnInputType.bolt11Invoice) {
|
||||
// BOLT11 invoices son 200+ chars; evitar llamar API con input parcial
|
||||
final cleaned = LnurlService.cleanInput(value);
|
||||
if (cleaned.length > 50) {
|
||||
_getQuote(cleaned);
|
||||
}
|
||||
}
|
||||
// No mostrar error para unknown - el usuario puede estar escribiendo
|
||||
}
|
||||
|
||||
/// Procesa el input actual (llamado por botón Continuar)
|
||||
void _processInput() {
|
||||
final value = _invoiceController.text.trim();
|
||||
if (value.isEmpty) return;
|
||||
|
||||
// Cerrar teclado
|
||||
FocusScope.of(context).unfocus();
|
||||
|
||||
switch (_inputType) {
|
||||
case LnInputType.bolt11Invoice:
|
||||
if (_quote != null) {
|
||||
// Quote válido, mostrar confirmación
|
||||
_showConfirmation();
|
||||
} else {
|
||||
// Sin quote, intentar obtenerlo o mostrar error
|
||||
final cleaned = LnurlService.cleanInput(value);
|
||||
if (cleaned.length > 50) {
|
||||
_getQuote(cleaned);
|
||||
} else {
|
||||
setState(() {
|
||||
_errorMessage = L10n.of(context)!.invalidInvoiceMalformed;
|
||||
});
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case LnInputType.lnurl:
|
||||
_resolveLnurl(value);
|
||||
break;
|
||||
|
||||
case LnInputType.lightningAddress:
|
||||
_resolveLightningAddress(value);
|
||||
break;
|
||||
|
||||
case LnInputType.unknown:
|
||||
setState(() {
|
||||
_errorMessage = L10n.of(context)!.invalidInvoice;
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _resolveLnurl(String lnurl) async {
|
||||
setState(() {
|
||||
_isResolvingLnurl = true;
|
||||
_errorMessage = null;
|
||||
});
|
||||
|
||||
try {
|
||||
final params = await LnurlService.resolveLnurl(lnurl);
|
||||
if (mounted) {
|
||||
setState(() => _isResolvingLnurl = false);
|
||||
// Navegar a AmountScreen
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => AmountScreen(
|
||||
destination: lnurl,
|
||||
destinationType: LnInputType.lnurl,
|
||||
params: params,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isResolvingLnurl = false;
|
||||
_errorMessage = L10n.of(context)!.paymentError(e.toString().replaceFirst('Exception: ', ''));
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _resolveLightningAddress(String address) async {
|
||||
setState(() {
|
||||
_isResolvingLnurl = true;
|
||||
_errorMessage = null;
|
||||
});
|
||||
|
||||
try {
|
||||
final params = await LnurlService.resolveLightningAddress(address);
|
||||
if (mounted) {
|
||||
setState(() => _isResolvingLnurl = false);
|
||||
// Navegar a AmountScreen
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => AmountScreen(
|
||||
destination: address,
|
||||
destinationType: LnInputType.lightningAddress,
|
||||
params: params,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isResolvingLnurl = false;
|
||||
_errorMessage = L10n.of(context)!.paymentError(e.toString().replaceFirst('Exception: ', ''));
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _getQuote(String invoice) async {
|
||||
@@ -502,15 +738,16 @@ class _MeltScreenState extends State<MeltScreen> {
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
final l10n = L10n.of(context)!;
|
||||
setState(() {
|
||||
_isLoadingQuote = false;
|
||||
final errorStr = e.toString().toLowerCase();
|
||||
if (errorStr.contains('expired')) {
|
||||
_errorMessage = 'Invoice expirado';
|
||||
_errorMessage = l10n.invoiceExpired;
|
||||
} else if (errorStr.contains('invalid') || errorStr.contains('decode')) {
|
||||
_errorMessage = 'Invoice inválido o malformado';
|
||||
_errorMessage = l10n.invalidInvoiceMalformed;
|
||||
} else {
|
||||
_errorMessage = 'Error al obtener quote: $e';
|
||||
_errorMessage = l10n.paymentError(e.toString());
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -550,10 +787,11 @@ class _MeltScreenState extends State<MeltScreen> {
|
||||
final totalPaid = await walletProvider.melt(_quote!);
|
||||
|
||||
if (mounted) {
|
||||
final l10n = L10n.of(context)!;
|
||||
// Mostrar éxito y volver
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('-${UnitFormatter.formatBalance(totalPaid, _activeUnit)} $_unitLabel enviados'),
|
||||
content: Text(l10n.sent('-${UnitFormatter.formatBalance(totalPaid, _activeUnit)}', _unitLabel)),
|
||||
backgroundColor: AppColors.success,
|
||||
duration: const Duration(seconds: 3),
|
||||
),
|
||||
@@ -561,17 +799,21 @@ class _MeltScreenState extends State<MeltScreen> {
|
||||
Navigator.pop(context);
|
||||
}
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
final l10n = L10n.of(context)!;
|
||||
final errorStr = e.toString().toLowerCase();
|
||||
String errorMessage;
|
||||
if (errorStr.contains('insufficient') || errorStr.contains('not enough')) {
|
||||
errorMessage = l10n.insufficientBalance;
|
||||
} else if (errorStr.contains('expired')) {
|
||||
errorMessage = l10n.invoiceExpired;
|
||||
} else if (errorStr.contains('already paid')) {
|
||||
errorMessage = l10n.invoiceAlreadyPaid;
|
||||
} else {
|
||||
errorMessage = l10n.paymentError(e.toString());
|
||||
}
|
||||
setState(() {
|
||||
final errorStr = e.toString().toLowerCase();
|
||||
if (errorStr.contains('insufficient') || errorStr.contains('not enough')) {
|
||||
_errorMessage = 'Balance insuficiente';
|
||||
} else if (errorStr.contains('expired')) {
|
||||
_errorMessage = 'Invoice expirado';
|
||||
} else if (errorStr.contains('already paid')) {
|
||||
_errorMessage = 'Invoice ya fue pagado';
|
||||
} else {
|
||||
_errorMessage = 'Error al pagar: $e';
|
||||
}
|
||||
_errorMessage = errorMessage;
|
||||
});
|
||||
} finally {
|
||||
if (mounted) {
|
||||
@@ -644,9 +886,9 @@ class _ConfirmationModal extends StatelessWidget {
|
||||
const SizedBox(height: AppDimensions.paddingMedium),
|
||||
|
||||
// Título
|
||||
const Text(
|
||||
'Confirmar pago',
|
||||
style: TextStyle(
|
||||
Text(
|
||||
L10n.of(context)!.confirmPayment,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
@@ -698,10 +940,10 @@ class _ConfirmationModal extends StatelessWidget {
|
||||
color: Colors.white.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: const Center(
|
||||
child: Center(
|
||||
child: Text(
|
||||
'Cancelar',
|
||||
style: TextStyle(
|
||||
L10n.of(context)!.cancel,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
@@ -715,7 +957,7 @@ class _ConfirmationModal extends StatelessWidget {
|
||||
const SizedBox(width: AppDimensions.paddingMedium),
|
||||
Expanded(
|
||||
child: PrimaryButton(
|
||||
text: 'Pagar',
|
||||
text: L10n.of(context)!.pay,
|
||||
onPressed: onConfirm,
|
||||
height: 52,
|
||||
),
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:lucide_icons/lucide_icons.dart';
|
||||
import 'package:cdk_flutter/cdk_flutter.dart' show MintInfo, ContactInfo;
|
||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
import '../../core/constants/colors.dart';
|
||||
import '../../core/utils/formatters.dart';
|
||||
import '../../widgets/common/gradient_background.dart';
|
||||
@@ -50,9 +51,10 @@ class _MintDetailScreenState extends State<MintDetailScreen> {
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
),
|
||||
body: SingleChildScrollView(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
body: SafeArea(
|
||||
child: SingleChildScrollView(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
child: Column(
|
||||
children: [
|
||||
// Header: Logo + Nombre
|
||||
@@ -93,6 +95,7 @@ class _MintDetailScreenState extends State<MintDetailScreen> {
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -148,9 +151,9 @@ class _MintDetailScreenState extends State<MintDetailScreen> {
|
||||
color: AppColors.success.withValues(alpha: 0.2),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: const Text(
|
||||
'Mint activo',
|
||||
style: TextStyle(
|
||||
child: Text(
|
||||
L10n.of(context)!.activeMint,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
@@ -187,9 +190,9 @@ class _MintDetailScreenState extends State<MintDetailScreen> {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'Mint Message',
|
||||
style: TextStyle(
|
||||
Text(
|
||||
L10n.of(context)!.mintMessage,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
@@ -296,6 +299,7 @@ class _MintDetailScreenState extends State<MintDetailScreen> {
|
||||
}
|
||||
|
||||
Widget _buildMintDetails(MintInfo? info) {
|
||||
final l10n = L10n.of(context)!;
|
||||
// Obtener currency de los balances
|
||||
final currencies = widget.balances.keys.join(', ').toUpperCase();
|
||||
final currencyDisplay = currencies.isNotEmpty ? currencies : 'SAT';
|
||||
@@ -303,24 +307,24 @@ class _MintDetailScreenState extends State<MintDetailScreen> {
|
||||
// Versión del mint
|
||||
final version = info?.version != null
|
||||
? '${info!.version!.name}/${info.version!.version}'
|
||||
: 'Unknown';
|
||||
: l10n.unknown;
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
_buildDetailRow(
|
||||
icon: LucideIcons.link,
|
||||
label: 'URL',
|
||||
label: l10n.url,
|
||||
value: widget.mintUrl,
|
||||
canCopy: true,
|
||||
),
|
||||
_buildDetailRow(
|
||||
icon: LucideIcons.coins,
|
||||
label: 'Currency',
|
||||
label: l10n.currency,
|
||||
value: currencyDisplay,
|
||||
),
|
||||
_buildDetailRow(
|
||||
icon: LucideIcons.box,
|
||||
label: 'Version',
|
||||
label: l10n.version,
|
||||
value: version,
|
||||
),
|
||||
],
|
||||
@@ -424,13 +428,14 @@ class _MintDetailScreenState extends State<MintDetailScreen> {
|
||||
}
|
||||
|
||||
Widget _buildActions() {
|
||||
final l10n = L10n.of(context)!;
|
||||
return Column(
|
||||
children: [
|
||||
// Usar este mint (si no es el activo)
|
||||
if (!widget.isActive && widget.onSetActive != null)
|
||||
_buildActionButton(
|
||||
icon: LucideIcons.star,
|
||||
label: 'Usar este mint',
|
||||
label: l10n.useThisMint,
|
||||
onTap: () {
|
||||
widget.onSetActive!();
|
||||
Navigator.pop(context);
|
||||
@@ -440,15 +445,15 @@ class _MintDetailScreenState extends State<MintDetailScreen> {
|
||||
// Copiar URL
|
||||
_buildActionButton(
|
||||
icon: LucideIcons.copy,
|
||||
label: 'Copiar URL del mint',
|
||||
onTap: () => _copyToClipboard(widget.mintUrl, 'URL'),
|
||||
label: l10n.copyMintUrl,
|
||||
onTap: () => _copyToClipboard(widget.mintUrl, l10n.url),
|
||||
),
|
||||
|
||||
// Eliminar mint
|
||||
if (widget.onDelete != null)
|
||||
_buildActionButton(
|
||||
icon: LucideIcons.trash2,
|
||||
label: 'Eliminar mint',
|
||||
label: l10n.deleteMint,
|
||||
isDestructive: true,
|
||||
onTap: () => _showDeleteConfirmation(),
|
||||
),
|
||||
@@ -501,7 +506,7 @@ class _MintDetailScreenState extends State<MintDetailScreen> {
|
||||
Clipboard.setData(ClipboardData(text: text));
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('$label copiado'),
|
||||
content: Text(L10n.of(context)!.copied(label)),
|
||||
backgroundColor: AppColors.success,
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
@@ -509,6 +514,7 @@ class _MintDetailScreenState extends State<MintDetailScreen> {
|
||||
}
|
||||
|
||||
void _showDeleteConfirmation() {
|
||||
final l10n = L10n.of(context)!;
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
@@ -516,16 +522,16 @@ class _MintDetailScreenState extends State<MintDetailScreen> {
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
title: const Text(
|
||||
'Eliminar mint',
|
||||
style: TextStyle(
|
||||
title: Text(
|
||||
l10n.deleteMintConfirmTitle,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
content: Text(
|
||||
'Si tienes balance en este mint, se perderá. ¿Estás seguro?',
|
||||
l10n.deleteMintConfirmMessage,
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
color: Colors.white.withValues(alpha: 0.8),
|
||||
@@ -534,9 +540,9 @@ class _MintDetailScreenState extends State<MintDetailScreen> {
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text(
|
||||
'Cancelar',
|
||||
style: TextStyle(color: AppColors.textSecondary),
|
||||
child: Text(
|
||||
l10n.cancel,
|
||||
style: const TextStyle(color: AppColors.textSecondary),
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
@@ -545,9 +551,9 @@ class _MintDetailScreenState extends State<MintDetailScreen> {
|
||||
Navigator.pop(context); // Volver a lista
|
||||
widget.onDelete!();
|
||||
},
|
||||
child: const Text(
|
||||
'Eliminar',
|
||||
style: TextStyle(color: AppColors.error),
|
||||
child: Text(
|
||||
l10n.delete,
|
||||
style: const TextStyle(color: AppColors.error),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:lucide_icons/lucide_icons.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:cdk_flutter/cdk_flutter.dart' show MintInfo;
|
||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
import '../../core/constants/colors.dart';
|
||||
import '../../core/constants/dimensions.dart';
|
||||
import '../../core/utils/formatters.dart';
|
||||
@@ -32,9 +33,9 @@ class _MintsScreenState extends State<MintsScreen> {
|
||||
icon: const Icon(LucideIcons.arrowLeft, color: Colors.white),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
title: const Text(
|
||||
'Mints conectados',
|
||||
style: TextStyle(
|
||||
title: Text(
|
||||
L10n.of(context)!.connectedMints,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.white,
|
||||
@@ -100,6 +101,7 @@ class _MintsScreenState extends State<MintsScreen> {
|
||||
}
|
||||
|
||||
Widget _buildEmptyState() {
|
||||
final l10n = L10n.of(context)!;
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
@@ -111,7 +113,7 @@ class _MintsScreenState extends State<MintsScreen> {
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'No hay mints conectados',
|
||||
l10n.noConnectedMints,
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 18,
|
||||
@@ -121,7 +123,7 @@ class _MintsScreenState extends State<MintsScreen> {
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Agrega un mint para comenzar',
|
||||
l10n.addMintToStart,
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 14,
|
||||
@@ -356,13 +358,14 @@ class _MintsScreenState extends State<MintsScreen> {
|
||||
|
||||
/// Elimina un mint (llamado desde pantalla de detalles)
|
||||
Future<void> _deleteMint(String mintUrl, WalletProvider walletProvider) async {
|
||||
final l10n = L10n.of(context)!;
|
||||
try {
|
||||
await walletProvider.removeMint(mintUrl);
|
||||
if (mounted) {
|
||||
setState(() {});
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Mint eliminado'),
|
||||
SnackBar(
|
||||
content: Text(l10n.mintDeleted),
|
||||
backgroundColor: AppColors.success,
|
||||
),
|
||||
);
|
||||
@@ -371,7 +374,7 @@ class _MintsScreenState extends State<MintsScreen> {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Error: $e'),
|
||||
content: Text('${l10n.error}: $e'),
|
||||
backgroundColor: AppColors.error,
|
||||
),
|
||||
);
|
||||
@@ -380,24 +383,26 @@ class _MintsScreenState extends State<MintsScreen> {
|
||||
}
|
||||
|
||||
Widget _buildAddMintButton(WalletProvider walletProvider) {
|
||||
final l10n = L10n.of(context)!;
|
||||
return PrimaryButton(
|
||||
text: 'Agregar mint',
|
||||
text: l10n.addMint,
|
||||
icon: LucideIcons.plus,
|
||||
onPressed: () => _showAddMintDialog(walletProvider),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _setActiveMint(String mintUrl, WalletProvider walletProvider) async {
|
||||
final l10n = L10n.of(context)!;
|
||||
try {
|
||||
await walletProvider.setActiveMint(mintUrl);
|
||||
|
||||
if (mounted) {
|
||||
setState(() {}); // Rebuild para actualizar UI
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Mint activo actualizado'),
|
||||
SnackBar(
|
||||
content: Text(l10n.activeMintUpdated),
|
||||
backgroundColor: AppColors.success,
|
||||
duration: Duration(seconds: 2),
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -405,7 +410,7 @@ class _MintsScreenState extends State<MintsScreen> {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Error: $e'),
|
||||
content: Text('${l10n.error}: $e'),
|
||||
backgroundColor: AppColors.error,
|
||||
duration: const Duration(seconds: 3),
|
||||
),
|
||||
@@ -456,12 +461,13 @@ class _AddMintDialogState extends State<_AddMintDialog> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = L10n.of(context)!;
|
||||
return AlertDialog(
|
||||
backgroundColor: AppColors.deepVoidPurple,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)),
|
||||
title: const Text(
|
||||
'Agregar mint',
|
||||
style: TextStyle(
|
||||
title: Text(
|
||||
l10n.addMint,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
@@ -472,9 +478,9 @@ class _AddMintDialogState extends State<_AddMintDialog> {
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const Text(
|
||||
'URL del mint:',
|
||||
style: TextStyle(
|
||||
Text(
|
||||
l10n.mintUrl,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 14,
|
||||
color: AppColors.textSecondary,
|
||||
@@ -491,7 +497,7 @@ class _AddMintDialogState extends State<_AddMintDialog> {
|
||||
color: Colors.white,
|
||||
),
|
||||
decoration: InputDecoration(
|
||||
hintText: 'https://mint.example.com',
|
||||
hintText: l10n.mintUrlPlaceholder,
|
||||
hintStyle: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 16,
|
||||
@@ -527,7 +533,7 @@ class _AddMintDialogState extends State<_AddMintDialog> {
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Text(
|
||||
'Conectando al mint...',
|
||||
l10n.connectingToMint,
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 14,
|
||||
@@ -543,7 +549,7 @@ class _AddMintDialogState extends State<_AddMintDialog> {
|
||||
TextButton(
|
||||
onPressed: _isAdding ? null : () => Navigator.pop(context),
|
||||
child: Text(
|
||||
'Cancelar',
|
||||
l10n.cancel,
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 16,
|
||||
@@ -563,9 +569,9 @@ class _AddMintDialogState extends State<_AddMintDialog> {
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
child: const Text(
|
||||
'Agregar',
|
||||
style: TextStyle(
|
||||
child: Text(
|
||||
l10n.add,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
@@ -577,6 +583,7 @@ class _AddMintDialogState extends State<_AddMintDialog> {
|
||||
}
|
||||
|
||||
void _validateUrl(String value) {
|
||||
final l10n = L10n.of(context)!;
|
||||
setState(() {
|
||||
_errorMessage = null;
|
||||
});
|
||||
@@ -595,7 +602,7 @@ class _AddMintDialogState extends State<_AddMintDialog> {
|
||||
if (!isValidBasic) {
|
||||
setState(() {
|
||||
_isValid = false;
|
||||
_errorMessage = 'La URL debe comenzar con https://';
|
||||
_errorMessage = l10n.urlMustStartWithHttps;
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -606,6 +613,7 @@ class _AddMintDialogState extends State<_AddMintDialog> {
|
||||
}
|
||||
|
||||
Future<void> _addMint() async {
|
||||
final l10n = L10n.of(context)!;
|
||||
final url = _controller.text.trim();
|
||||
if (url.isEmpty) return;
|
||||
|
||||
@@ -623,10 +631,10 @@ class _AddMintDialogState extends State<_AddMintDialog> {
|
||||
widget.onSuccess();
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Mint agregado correctamente'),
|
||||
SnackBar(
|
||||
content: Text(l10n.mintAddedSuccessfully),
|
||||
backgroundColor: AppColors.success,
|
||||
duration: Duration(seconds: 2),
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -634,7 +642,7 @@ class _AddMintDialogState extends State<_AddMintDialog> {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isAdding = false;
|
||||
_errorMessage = 'No se pudo conectar al mint';
|
||||
_errorMessage = l10n.couldNotConnectToMint;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:lucide_icons/lucide_icons.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
import '../../core/constants/colors.dart';
|
||||
import '../../core/constants/dimensions.dart';
|
||||
import '../../core/utils/formatters.dart';
|
||||
@@ -23,6 +24,7 @@ class SettingsScreen extends StatefulWidget {
|
||||
class _SettingsScreenState extends State<SettingsScreen> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = L10n.of(context)!;
|
||||
return GradientBackground(
|
||||
child: Scaffold(
|
||||
backgroundColor: Colors.transparent,
|
||||
@@ -33,9 +35,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
icon: const Icon(LucideIcons.arrowLeft, color: Colors.white),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
title: const Text(
|
||||
'Configuración',
|
||||
style: TextStyle(
|
||||
title: Text(
|
||||
l10n.settings,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.white,
|
||||
@@ -51,18 +53,18 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// Sección WALLET
|
||||
_buildSectionHeader('WALLET'),
|
||||
_buildSectionHeader(l10n.walletSection),
|
||||
const SizedBox(height: AppDimensions.paddingSmall),
|
||||
_buildSettingTile(
|
||||
icon: LucideIcons.key,
|
||||
title: 'Backup seed phrase',
|
||||
subtitle: 'Ver tus palabras de recuperación',
|
||||
title: l10n.backupSeedPhrase,
|
||||
subtitle: l10n.viewRecoveryWords,
|
||||
onTap: () => _showBackupSeed(context, settingsProvider),
|
||||
),
|
||||
_buildSettingTile(
|
||||
icon: LucideIcons.landmark,
|
||||
title: 'Mints conectados',
|
||||
subtitle: 'Gestionar tus mints Cashu',
|
||||
title: l10n.connectedMints,
|
||||
subtitle: l10n.manageCashuMints,
|
||||
onTap: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
@@ -74,10 +76,10 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
),
|
||||
_buildSettingTile(
|
||||
icon: LucideIcons.lock,
|
||||
title: 'PIN de acceso',
|
||||
title: l10n.pinAccess,
|
||||
subtitle: settingsProvider.pinEnabled
|
||||
? 'Activado'
|
||||
: 'Proteger la app con PIN',
|
||||
? l10n.pinEnabled
|
||||
: l10n.protectWithPin,
|
||||
trailing: Switch(
|
||||
value: settingsProvider.pinEnabled,
|
||||
onChanged: (value) =>
|
||||
@@ -87,19 +89,19 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
),
|
||||
_buildSettingTile(
|
||||
icon: LucideIcons.refreshCw,
|
||||
title: 'Recuperar tokens',
|
||||
subtitle: 'Escanear mints con seed phrase',
|
||||
title: l10n.recoverTokens,
|
||||
subtitle: l10n.scanMintsWithSeed,
|
||||
onTap: () => _showRecoverTokensDialog(context, settingsProvider),
|
||||
),
|
||||
|
||||
const SizedBox(height: AppDimensions.paddingLarge),
|
||||
|
||||
// Sección APARIENCIA
|
||||
_buildSectionHeader('APARIENCIA'),
|
||||
// Sección IDIOMA
|
||||
_buildSectionHeader(l10n.appearanceSection),
|
||||
const SizedBox(height: AppDimensions.paddingSmall),
|
||||
_buildSettingTile(
|
||||
icon: LucideIcons.globe,
|
||||
title: 'Idioma',
|
||||
title: l10n.language,
|
||||
subtitle: _getLanguageName(settingsProvider.locale),
|
||||
onTap: () => _showLanguageSelector(context, settingsProvider),
|
||||
),
|
||||
@@ -107,16 +109,16 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
const SizedBox(height: AppDimensions.paddingLarge),
|
||||
|
||||
// Sección INFORMACIÓN
|
||||
_buildSectionHeader('INFORMACIÓN'),
|
||||
_buildSectionHeader(l10n.informationSection),
|
||||
const SizedBox(height: AppDimensions.paddingSmall),
|
||||
_buildInfoTile(
|
||||
icon: LucideIcons.tag,
|
||||
title: 'Versión',
|
||||
title: l10n.version,
|
||||
subtitle: '0.0.1',
|
||||
),
|
||||
_buildSettingTile(
|
||||
icon: LucideIcons.info,
|
||||
title: 'Acerca de',
|
||||
title: l10n.about,
|
||||
onTap: () => _showAboutDialog(context),
|
||||
),
|
||||
_buildSettingTile(
|
||||
@@ -267,6 +269,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
}
|
||||
|
||||
Widget _buildDangerButton(BuildContext context, SettingsProvider settingsProvider) {
|
||||
final l10n = L10n.of(context)!;
|
||||
return GestureDetector(
|
||||
onTap: () => _showDeleteWalletDialog(context, settingsProvider),
|
||||
child: Container(
|
||||
@@ -284,9 +287,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
children: [
|
||||
Icon(LucideIcons.trash2, color: AppColors.error, size: 20),
|
||||
const SizedBox(width: 8),
|
||||
const Text(
|
||||
'Borrar wallet',
|
||||
style: TextStyle(
|
||||
Text(
|
||||
l10n.deleteWallet,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
@@ -304,13 +307,22 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
// ============================================================
|
||||
|
||||
String _getLanguageName(String locale) {
|
||||
final l10n = L10n.of(context)!;
|
||||
switch (locale) {
|
||||
case 'es':
|
||||
return 'Español';
|
||||
return l10n.spanish;
|
||||
case 'en':
|
||||
return 'English';
|
||||
return l10n.english;
|
||||
case 'pt':
|
||||
return l10n.portuguese;
|
||||
case 'fr':
|
||||
return l10n.french;
|
||||
case 'ru':
|
||||
return l10n.russian;
|
||||
case 'de':
|
||||
return l10n.german;
|
||||
default:
|
||||
return 'Español';
|
||||
return l10n.spanish;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -328,8 +340,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
if (mnemonic == null) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('No se encontró el mnemonic'),
|
||||
SnackBar(
|
||||
content: Text(L10n.of(context)!.mnemonicNotFound),
|
||||
backgroundColor: AppColors.error,
|
||||
),
|
||||
);
|
||||
@@ -361,8 +373,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
await settingsProvider.setPin(pin);
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('PIN activado'),
|
||||
SnackBar(
|
||||
content: Text(L10n.of(context)!.pinActivated),
|
||||
backgroundColor: AppColors.success,
|
||||
),
|
||||
);
|
||||
@@ -375,8 +387,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
await settingsProvider.removePin();
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('PIN desactivado'),
|
||||
SnackBar(
|
||||
content: Text(L10n.of(context)!.pinDeactivated),
|
||||
backgroundColor: AppColors.success,
|
||||
),
|
||||
);
|
||||
@@ -386,6 +398,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
}
|
||||
|
||||
Future<String?> _showCreatePinDialog(BuildContext context) async {
|
||||
final l10n = L10n.of(context)!;
|
||||
String? firstPin;
|
||||
|
||||
// Primer ingreso
|
||||
@@ -393,8 +406,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (context) => _PinDialog(
|
||||
title: 'Crear PIN',
|
||||
subtitle: 'Ingresa un PIN de 4 dígitos',
|
||||
title: l10n.createPin,
|
||||
subtitle: l10n.enterPinDigits,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -405,8 +418,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (context) => _PinDialog(
|
||||
title: 'Confirmar PIN',
|
||||
subtitle: 'Ingresa el PIN nuevamente',
|
||||
title: l10n.confirmPin,
|
||||
subtitle: l10n.enterPinAgain,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -415,8 +428,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
if (firstPin != confirmPin) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Los PIN no coinciden'),
|
||||
SnackBar(
|
||||
content: Text(l10n.pinMismatch),
|
||||
backgroundColor: AppColors.error,
|
||||
),
|
||||
);
|
||||
@@ -429,12 +442,13 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
|
||||
Future<bool> _verifyPinDialog(
|
||||
BuildContext context, SettingsProvider settingsProvider) async {
|
||||
final l10n = L10n.of(context)!;
|
||||
final pin = await showDialog<String>(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (context) => _PinDialog(
|
||||
title: 'Verificar PIN',
|
||||
subtitle: 'Ingresa tu PIN actual',
|
||||
title: l10n.verifyPin,
|
||||
subtitle: l10n.enterCurrentPin,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -445,8 +459,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
} else {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('PIN incorrecto'),
|
||||
SnackBar(
|
||||
content: Text(l10n.incorrectPin),
|
||||
backgroundColor: AppColors.error,
|
||||
),
|
||||
);
|
||||
@@ -458,6 +472,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
/// 3. Selector de idioma
|
||||
void _showLanguageSelector(
|
||||
BuildContext context, SettingsProvider settingsProvider) {
|
||||
final l10n = L10n.of(context)!;
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
backgroundColor: Colors.transparent,
|
||||
@@ -484,9 +499,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
const Text(
|
||||
'Seleccionar idioma',
|
||||
style: TextStyle(
|
||||
Text(
|
||||
l10n.selectLanguage,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
@@ -498,15 +513,43 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
context,
|
||||
settingsProvider,
|
||||
'es',
|
||||
'Español',
|
||||
l10n.spanish,
|
||||
'🇪🇸',
|
||||
),
|
||||
_buildLanguageOption(
|
||||
context,
|
||||
settingsProvider,
|
||||
'en',
|
||||
'English',
|
||||
'🇺🇸',
|
||||
l10n.english,
|
||||
'🇬🇧',
|
||||
),
|
||||
_buildLanguageOption(
|
||||
context,
|
||||
settingsProvider,
|
||||
'pt',
|
||||
l10n.portuguese,
|
||||
'🇵🇹',
|
||||
),
|
||||
_buildLanguageOption(
|
||||
context,
|
||||
settingsProvider,
|
||||
'fr',
|
||||
l10n.french,
|
||||
'🇫🇷',
|
||||
),
|
||||
_buildLanguageOption(
|
||||
context,
|
||||
settingsProvider,
|
||||
'ru',
|
||||
l10n.russian,
|
||||
'🇷🇺',
|
||||
),
|
||||
_buildLanguageOption(
|
||||
context,
|
||||
settingsProvider,
|
||||
'de',
|
||||
l10n.german,
|
||||
'🇩🇪',
|
||||
),
|
||||
const SizedBox(height: AppDimensions.paddingSmall),
|
||||
],
|
||||
@@ -531,7 +574,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
Navigator.pop(context);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Idioma cambiado a $name'),
|
||||
content: Text(L10n.of(context)!.languageChanged(name)),
|
||||
backgroundColor: AppColors.success,
|
||||
),
|
||||
);
|
||||
@@ -625,7 +668,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Tu wallet de ecash privado',
|
||||
L10n.of(context)!.aboutTagline,
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 16,
|
||||
@@ -635,7 +678,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Cashu wallet con identidad cubana, hermana de La Chispa.',
|
||||
L10n.of(context)!.aboutDescription,
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 14,
|
||||
@@ -676,9 +719,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text(
|
||||
'Cerrar',
|
||||
style: TextStyle(
|
||||
child: Text(
|
||||
L10n.of(context)!.close,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
@@ -697,8 +740,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('No se pudo abrir el enlace'),
|
||||
SnackBar(
|
||||
content: Text(L10n.of(context)!.couldNotOpenLink),
|
||||
backgroundColor: AppColors.error,
|
||||
),
|
||||
);
|
||||
@@ -757,7 +800,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Error al borrar: $e'),
|
||||
content: Text(L10n.of(context)!.deleteError(e.toString())),
|
||||
backgroundColor: AppColors.error,
|
||||
),
|
||||
);
|
||||
@@ -982,6 +1025,8 @@ class _DeleteWalletModalState extends State<_DeleteWalletModal> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = L10n.of(context)!;
|
||||
final confirmWord = l10n.deleteConfirmWord;
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(
|
||||
bottom: MediaQuery.of(context).viewInsets.bottom,
|
||||
@@ -1027,9 +1072,9 @@ class _DeleteWalletModalState extends State<_DeleteWalletModal> {
|
||||
const SizedBox(height: AppDimensions.paddingMedium),
|
||||
|
||||
// Título
|
||||
const Text(
|
||||
'¿Borrar wallet?',
|
||||
style: TextStyle(
|
||||
Text(
|
||||
l10n.deleteWalletQuestion,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
@@ -1056,10 +1101,10 @@ class _DeleteWalletModalState extends State<_DeleteWalletModal> {
|
||||
size: 16,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
const Expanded(
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Esta acción es irreversible',
|
||||
style: TextStyle(
|
||||
l10n.actionIrreversible,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
@@ -1071,7 +1116,7 @@ class _DeleteWalletModalState extends State<_DeleteWalletModal> {
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Se eliminarán todos los datos incluyendo tu seed phrase y tokens. Asegúrate de tener un backup.',
|
||||
l10n.deleteWalletWarning,
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 12,
|
||||
@@ -1091,7 +1136,7 @@ class _DeleteWalletModalState extends State<_DeleteWalletModal> {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Escribe "BORRAR" para confirmar:',
|
||||
l10n.typeDeleteToConfirm,
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 14,
|
||||
@@ -1107,7 +1152,7 @@ class _DeleteWalletModalState extends State<_DeleteWalletModal> {
|
||||
color: Colors.white,
|
||||
),
|
||||
decoration: InputDecoration(
|
||||
hintText: 'BORRAR',
|
||||
hintText: confirmWord,
|
||||
hintStyle: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 16,
|
||||
@@ -1122,7 +1167,7 @@ class _DeleteWalletModalState extends State<_DeleteWalletModal> {
|
||||
),
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_canDelete = value == 'BORRAR';
|
||||
_canDelete = value == confirmWord;
|
||||
});
|
||||
},
|
||||
),
|
||||
@@ -1144,10 +1189,10 @@ class _DeleteWalletModalState extends State<_DeleteWalletModal> {
|
||||
color: Colors.white.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: const Center(
|
||||
child: Center(
|
||||
child: Text(
|
||||
'Cancelar',
|
||||
style: TextStyle(
|
||||
l10n.cancel,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
@@ -1172,7 +1217,7 @@ class _DeleteWalletModalState extends State<_DeleteWalletModal> {
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
'Borrar wallet',
|
||||
l10n.deleteWallet,
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 16,
|
||||
@@ -1293,9 +1338,9 @@ class _RecoverTokensModalState extends State<_RecoverTokensModal> {
|
||||
const SizedBox(height: AppDimensions.paddingMedium),
|
||||
|
||||
// Título
|
||||
const Text(
|
||||
'Recuperar tokens',
|
||||
style: TextStyle(
|
||||
Text(
|
||||
L10n.of(context)!.recoverTokensTitle,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
@@ -1306,7 +1351,7 @@ class _RecoverTokensModalState extends State<_RecoverTokensModal> {
|
||||
|
||||
// Descripción
|
||||
Text(
|
||||
'Escanea los mints para recuperar tokens asociados a tu seed phrase (NUT-13)',
|
||||
L10n.of(context)!.recoverTokensDescription,
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 14,
|
||||
@@ -1345,6 +1390,7 @@ class _RecoverTokensModalState extends State<_RecoverTokensModal> {
|
||||
}
|
||||
|
||||
Widget _buildMnemonicOptions() {
|
||||
final l10n = L10n.of(context)!;
|
||||
return Column(
|
||||
children: [
|
||||
// Opción: Usar mnemonic actual
|
||||
@@ -1379,9 +1425,9 @@ class _RecoverTokensModalState extends State<_RecoverTokensModal> {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'Usar mi seed phrase actual',
|
||||
style: TextStyle(
|
||||
Text(
|
||||
l10n.useCurrentSeedPhrase,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500,
|
||||
@@ -1389,7 +1435,7 @@ class _RecoverTokensModalState extends State<_RecoverTokensModal> {
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'Escanear mints con las 12 palabras guardadas',
|
||||
l10n.scanWithSavedWords,
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 12,
|
||||
@@ -1438,9 +1484,9 @@ class _RecoverTokensModalState extends State<_RecoverTokensModal> {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'Usar otra seed phrase',
|
||||
style: TextStyle(
|
||||
Text(
|
||||
l10n.useOtherSeedPhrase,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500,
|
||||
@@ -1448,7 +1494,7 @@ class _RecoverTokensModalState extends State<_RecoverTokensModal> {
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'Recuperar tokens de otras 12 palabras',
|
||||
l10n.recoverFromOtherWords,
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 12,
|
||||
@@ -1467,6 +1513,7 @@ class _RecoverTokensModalState extends State<_RecoverTokensModal> {
|
||||
}
|
||||
|
||||
Widget _buildMintSelector() {
|
||||
final l10n = L10n.of(context)!;
|
||||
if (_isLoadingMints) {
|
||||
return const Padding(
|
||||
padding: EdgeInsets.all(16),
|
||||
@@ -1487,7 +1534,7 @@ class _RecoverTokensModalState extends State<_RecoverTokensModal> {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Mints a escanear:',
|
||||
l10n.mintsToScan,
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 14,
|
||||
@@ -1524,7 +1571,7 @@ class _RecoverTokensModalState extends State<_RecoverTokensModal> {
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Text(
|
||||
'Todos los mints (${_availableMints.length})',
|
||||
l10n.allMints(_availableMints.length),
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 14,
|
||||
@@ -1565,9 +1612,9 @@ class _RecoverTokensModalState extends State<_RecoverTokensModal> {
|
||||
size: 18,
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
const Text(
|
||||
'Un mint específico',
|
||||
style: TextStyle(
|
||||
Text(
|
||||
l10n.specificMint,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 14,
|
||||
color: Colors.white,
|
||||
@@ -1639,7 +1686,7 @@ class _RecoverTokensModalState extends State<_RecoverTokensModal> {
|
||||
color: Colors.white,
|
||||
),
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Ingresa las 12 palabras separadas por espacios...',
|
||||
hintText: L10n.of(context)!.enterMnemonicWords,
|
||||
hintStyle: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 14,
|
||||
@@ -1721,9 +1768,9 @@ class _RecoverTokensModalState extends State<_RecoverTokensModal> {
|
||||
strokeWidth: 2,
|
||||
),
|
||||
)
|
||||
: const Text(
|
||||
'Escanear mints',
|
||||
style: TextStyle(
|
||||
: Text(
|
||||
L10n.of(context)!.scanMints,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
@@ -1738,6 +1785,7 @@ class _RecoverTokensModalState extends State<_RecoverTokensModal> {
|
||||
|
||||
Future<void> _startRecover() async {
|
||||
if (!mounted) return;
|
||||
final l10n = L10n.of(context)!;
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
_result = null;
|
||||
@@ -1758,7 +1806,6 @@ class _RecoverTokensModalState extends State<_RecoverTokensModal> {
|
||||
final recoveredDetails = <String>[];
|
||||
|
||||
for (final mintEntry in results.entries) {
|
||||
final mintUrl = mintEntry.key;
|
||||
final unitBalances = mintEntry.value;
|
||||
bool hasError = false;
|
||||
BigInt mintTotal = BigInt.zero;
|
||||
@@ -1788,12 +1835,12 @@ class _RecoverTokensModalState extends State<_RecoverTokensModal> {
|
||||
setState(() {
|
||||
_isSuccess = true;
|
||||
if (recoveredDetails.isNotEmpty) {
|
||||
_result = '¡Recuperados ${recoveredDetails.join(", ")} de $mintsScanned mint(s)!';
|
||||
_result = l10n.recoveredTokens(recoveredDetails.join(", "), mintsScanned);
|
||||
} else {
|
||||
_result = 'Escaneo completado. No se encontraron tokens nuevos.';
|
||||
_result = l10n.scanCompleteNoTokens;
|
||||
}
|
||||
if (mintsWithError > 0) {
|
||||
_result = '$_result ($mintsWithError mint(s) con error)';
|
||||
_result = '$_result ${l10n.mintsWithError(mintsWithError)}';
|
||||
}
|
||||
});
|
||||
} else {
|
||||
@@ -1802,7 +1849,7 @@ class _RecoverTokensModalState extends State<_RecoverTokensModal> {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_isSuccess = false;
|
||||
_result = 'Selecciona un mint para escanear';
|
||||
_result = l10n.selectMintToScan;
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -1825,9 +1872,9 @@ class _RecoverTokensModalState extends State<_RecoverTokensModal> {
|
||||
setState(() {
|
||||
_isSuccess = true;
|
||||
if (recoveredDetails.isNotEmpty) {
|
||||
_result = '¡Recuperados ${recoveredDetails.join(", ")} de $mintHost!';
|
||||
_result = l10n.recoveredFromMint(recoveredDetails.join(", "), mintHost);
|
||||
} else {
|
||||
_result = 'No se encontraron tokens en $mintHost.';
|
||||
_result = l10n.noTokensFoundInMint(mintHost);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1840,7 +1887,7 @@ class _RecoverTokensModalState extends State<_RecoverTokensModal> {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_isSuccess = false;
|
||||
_result = 'El mnemonic debe tener 12 o 24 palabras';
|
||||
_result = l10n.mnemonicMustHaveWords;
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -1852,7 +1899,7 @@ class _RecoverTokensModalState extends State<_RecoverTokensModal> {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_isSuccess = false;
|
||||
_result = 'No hay mints conectados para escanear';
|
||||
_result = l10n.noConnectedMintsToScan;
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -1870,9 +1917,9 @@ class _RecoverTokensModalState extends State<_RecoverTokensModal> {
|
||||
final activeUnit = walletProvider.activeUnit;
|
||||
final formatted = UnitFormatter.formatBalance(recovered, activeUnit);
|
||||
final label = UnitFormatter.getUnitLabel(activeUnit);
|
||||
_result = '¡Recuperados y transferidos $formatted $label a tu wallet!';
|
||||
_result = l10n.recoveredAndTransferred(formatted, label);
|
||||
} else {
|
||||
_result = 'No se encontraron tokens asociados a ese mnemonic.';
|
||||
_result = l10n.noTokensForMnemonic;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1880,7 +1927,7 @@ class _RecoverTokensModalState extends State<_RecoverTokensModal> {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_isSuccess = false;
|
||||
_result = 'Error: $e';
|
||||
_result = '${l10n.error}: $e';
|
||||
});
|
||||
} finally {
|
||||
if (mounted) {
|
||||
|
||||
@@ -4,12 +4,15 @@ import 'package:flutter/services.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:lucide_icons/lucide_icons.dart';
|
||||
import 'package:qr_flutter/qr_flutter.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:cdk_flutter/cdk_flutter.dart' as cdk;
|
||||
import 'package:cdk_flutter/cdk_flutter.dart' show Transaction, TransactionDirection, TransactionStatus;
|
||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
import '../../core/constants/colors.dart';
|
||||
import '../../core/constants/dimensions.dart';
|
||||
import '../../core/utils/formatters.dart';
|
||||
import '../../data/transaction_meta_storage.dart';
|
||||
import '../../data/pending_token.dart';
|
||||
import '../../providers/wallet_provider.dart';
|
||||
import '../../widgets/common/gradient_background.dart';
|
||||
|
||||
@@ -17,6 +20,7 @@ import '../../widgets/common/gradient_background.dart';
|
||||
enum HistoryFilter {
|
||||
all,
|
||||
pending,
|
||||
toReceive, // Tokens pendientes de reclamar (Receive Later)
|
||||
cashu,
|
||||
lightning,
|
||||
}
|
||||
@@ -46,9 +50,9 @@ class _HistoryScreenState extends State<HistoryScreen> {
|
||||
icon: const Icon(LucideIcons.arrowLeft, color: Colors.white),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
title: const Text(
|
||||
'Historial',
|
||||
style: TextStyle(
|
||||
title: Text(
|
||||
L10n.of(context)!.history,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w600,
|
||||
@@ -73,22 +77,28 @@ class _HistoryScreenState extends State<HistoryScreen> {
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
// Filtros
|
||||
_buildFilters(),
|
||||
body: SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
// Filtros
|
||||
_buildFilters(),
|
||||
|
||||
// Lista de transacciones
|
||||
Expanded(
|
||||
child: _buildTransactionList(),
|
||||
),
|
||||
],
|
||||
// Lista de transacciones
|
||||
Expanded(
|
||||
child: _buildTransactionList(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildFilters() {
|
||||
final l10n = L10n.of(context)!;
|
||||
final walletProvider = context.watch<WalletProvider>();
|
||||
final pendingCount = walletProvider.pendingTokenCount;
|
||||
|
||||
return SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
@@ -98,28 +108,36 @@ class _HistoryScreenState extends State<HistoryScreen> {
|
||||
child: Row(
|
||||
children: [
|
||||
_FilterChip(
|
||||
label: 'Todos',
|
||||
label: l10n.filterAll,
|
||||
icon: LucideIcons.list,
|
||||
isSelected: _currentFilter == HistoryFilter.all,
|
||||
onTap: () => _setFilter(HistoryFilter.all),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_FilterChip(
|
||||
label: 'Pendientes',
|
||||
label: l10n.filterPending,
|
||||
icon: LucideIcons.clock,
|
||||
isSelected: _currentFilter == HistoryFilter.pending,
|
||||
onTap: () => _setFilter(HistoryFilter.pending),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_FilterChipWithBadge(
|
||||
label: l10n.filterToReceive,
|
||||
icon: LucideIcons.download,
|
||||
isSelected: _currentFilter == HistoryFilter.toReceive,
|
||||
badgeCount: pendingCount,
|
||||
onTap: () => _setFilter(HistoryFilter.toReceive),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_FilterChip(
|
||||
label: 'Ecash',
|
||||
label: l10n.filterEcash,
|
||||
icon: LucideIcons.coins,
|
||||
isSelected: _currentFilter == HistoryFilter.cashu,
|
||||
onTap: () => _setFilter(HistoryFilter.cashu),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_FilterChip(
|
||||
label: 'Lightning',
|
||||
label: l10n.filterLightning,
|
||||
icon: LucideIcons.zap,
|
||||
isSelected: _currentFilter == HistoryFilter.lightning,
|
||||
onTap: () => _setFilter(HistoryFilter.lightning),
|
||||
@@ -132,6 +150,11 @@ class _HistoryScreenState extends State<HistoryScreen> {
|
||||
Widget _buildTransactionList() {
|
||||
final walletProvider = context.watch<WalletProvider>();
|
||||
|
||||
// Si el filtro es "toReceive", mostrar pending tokens
|
||||
if (_currentFilter == HistoryFilter.toReceive) {
|
||||
return _buildPendingTokensList(walletProvider);
|
||||
}
|
||||
|
||||
return FutureBuilder<List<Transaction>>(
|
||||
future: walletProvider.getAllTransactions(),
|
||||
builder: (context, snapshot) {
|
||||
@@ -171,6 +194,71 @@ class _HistoryScreenState extends State<HistoryScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPendingTokensList(WalletProvider walletProvider) {
|
||||
final pendingTokens = walletProvider.listPendingTokens();
|
||||
|
||||
if (pendingTokens.isEmpty) {
|
||||
return _buildEmptyState();
|
||||
}
|
||||
|
||||
return RefreshIndicator(
|
||||
onRefresh: _refreshTransactions,
|
||||
color: AppColors.primaryAction,
|
||||
child: ListView.builder(
|
||||
padding: const EdgeInsets.all(AppDimensions.paddingMedium),
|
||||
itemCount: pendingTokens.length,
|
||||
itemBuilder: (context, index) {
|
||||
final token = pendingTokens[index];
|
||||
return _PendingTokenTile(
|
||||
token: token,
|
||||
walletProvider: walletProvider,
|
||||
onClaim: () => _claimPendingToken(token),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _claimPendingToken(PendingToken token) async {
|
||||
final l10n = L10n.of(context)!;
|
||||
final walletProvider = context.read<WalletProvider>();
|
||||
|
||||
try {
|
||||
final amount = await walletProvider.claimPendingToken(token.id);
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(l10n.pendingTokenClaimedSuccess(
|
||||
UnitFormatter.formatBalance(amount, token.unit ?? 'sat'),
|
||||
token.unit ?? 'sat',
|
||||
)),
|
||||
backgroundColor: AppColors.success,
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
final errorStr = e.toString().toLowerCase();
|
||||
String message;
|
||||
Color bgColor = AppColors.error;
|
||||
if (errorStr.contains('already spent') || errorStr.contains('token already')) {
|
||||
message = l10n.tokenAlreadyClaimed;
|
||||
} else if (errorStr.contains('no connection')) {
|
||||
message = l10n.noConnectionTryLater;
|
||||
bgColor = AppColors.warning;
|
||||
} else {
|
||||
message = l10n.claimError(e.toString());
|
||||
}
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(message),
|
||||
backgroundColor: bgColor,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
List<Transaction> _applyFilter(List<Transaction> transactions, WalletProvider walletProvider) {
|
||||
switch (_currentFilter) {
|
||||
case HistoryFilter.all:
|
||||
@@ -181,6 +269,10 @@ class _HistoryScreenState extends State<HistoryScreen> {
|
||||
.where((tx) => tx.status == TransactionStatus.pending)
|
||||
.toList();
|
||||
|
||||
case HistoryFilter.toReceive:
|
||||
// Pending tokens se manejan por separado en _buildPendingTokensList
|
||||
return [];
|
||||
|
||||
case HistoryFilter.cashu:
|
||||
return transactions
|
||||
.where((tx) => walletProvider.getTransactionType(tx) == TransactionType.cashu)
|
||||
@@ -194,25 +286,30 @@ class _HistoryScreenState extends State<HistoryScreen> {
|
||||
}
|
||||
|
||||
Widget _buildEmptyState() {
|
||||
final l10n = L10n.of(context)!;
|
||||
String message;
|
||||
String submessage;
|
||||
|
||||
switch (_currentFilter) {
|
||||
case HistoryFilter.all:
|
||||
message = 'Sin transacciones aún';
|
||||
submessage = 'Recibe tokens Cashu para empezar';
|
||||
message = l10n.noTransactions;
|
||||
submessage = l10n.receiveTokensToStart;
|
||||
break;
|
||||
case HistoryFilter.pending:
|
||||
message = 'Sin transacciones pendientes';
|
||||
submessage = 'Todas tus transacciones están completadas';
|
||||
message = l10n.noPendingTransactions;
|
||||
submessage = l10n.allTransactionsCompleted;
|
||||
break;
|
||||
case HistoryFilter.toReceive:
|
||||
message = l10n.noPendingTokens;
|
||||
submessage = l10n.noPendingTokensHint;
|
||||
break;
|
||||
case HistoryFilter.cashu:
|
||||
message = 'Sin transacciones Ecash';
|
||||
submessage = 'Envía o recibe tokens Cashu';
|
||||
message = l10n.noEcashTransactions;
|
||||
submessage = l10n.sendOrReceiveTokens;
|
||||
break;
|
||||
case HistoryFilter.lightning:
|
||||
message = 'Sin transacciones Lightning';
|
||||
submessage = 'Deposita o retira via Lightning';
|
||||
message = l10n.noLightningTransactions;
|
||||
submessage = l10n.depositOrWithdrawLightning;
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -351,7 +448,7 @@ class _HistoryTransactionTile extends StatelessWidget {
|
||||
);
|
||||
|
||||
// Formatear fecha
|
||||
final dateStr = _formatDate(timestamp);
|
||||
final dateStr = _formatDate(timestamp, context);
|
||||
|
||||
// Estado (pending o settled)
|
||||
final isPending = transaction.status == TransactionStatus.pending;
|
||||
@@ -425,9 +522,9 @@ class _HistoryTransactionTile extends StatelessWidget {
|
||||
color: AppColors.warning.withValues(alpha: 0.2),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: const Text(
|
||||
'Pendiente',
|
||||
style: TextStyle(
|
||||
child: Text(
|
||||
L10n.of(context)!.pendingStatus,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.w500,
|
||||
@@ -449,7 +546,7 @@ class _HistoryTransactionTile extends StatelessWidget {
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
isIncoming ? 'Recibido' : 'Enviado',
|
||||
isIncoming ? L10n.of(context)!.receivedStatus : L10n.of(context)!.sentStatus,
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 12,
|
||||
@@ -543,20 +640,22 @@ class _HistoryTransactionTile extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
String _formatDate(DateTime date) {
|
||||
String _formatDate(DateTime date, BuildContext context) {
|
||||
final l10n = L10n.of(context)!;
|
||||
final now = DateTime.now();
|
||||
final diff = now.difference(date);
|
||||
|
||||
if (diff.inMinutes < 1) {
|
||||
return 'Ahora';
|
||||
return l10n.now;
|
||||
} else if (diff.inHours < 1) {
|
||||
return 'Hace ${diff.inMinutes} min';
|
||||
return l10n.agoMinutes(diff.inMinutes);
|
||||
} else if (diff.inDays < 1) {
|
||||
return 'Hace ${diff.inHours} h';
|
||||
return l10n.agoHours(diff.inHours);
|
||||
} else if (diff.inDays < 7) {
|
||||
return 'Hace ${diff.inDays} días';
|
||||
return l10n.agoDays(diff.inDays);
|
||||
} else {
|
||||
return '${date.day}/${date.month}/${date.year}';
|
||||
final locale = Localizations.localeOf(context).toString();
|
||||
return DateFormat.yMd(locale).format(date);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -677,9 +776,10 @@ class _TransactionDetailScreenState extends State<_TransactionDetailScreen> {
|
||||
final isPending = widget.transaction.status == TransactionStatus.pending;
|
||||
|
||||
// Título del estado (como cashu.me)
|
||||
final l10n = L10n.of(context)!;
|
||||
final statusTitle = _isLightning
|
||||
? 'Lightning Invoice'
|
||||
: (_isIncoming ? 'Received Ecash' : 'Sent Ecash');
|
||||
? l10n.lightningInvoice
|
||||
: (_isIncoming ? l10n.receivedEcash : l10n.sentEcash);
|
||||
|
||||
return GradientBackground(
|
||||
child: Scaffold(
|
||||
@@ -738,7 +838,7 @@ class _TransactionDetailScreenState extends State<_TransactionDetailScreen> {
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'Pago Lightning Saliente',
|
||||
l10n.outgoingLightningPayment,
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 16,
|
||||
@@ -768,7 +868,7 @@ class _TransactionDetailScreenState extends State<_TransactionDetailScreen> {
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
_isLightning ? 'Invoice no disponible' : 'Token no disponible',
|
||||
_isLightning ? l10n.invoiceNotAvailable : l10n.tokenNotAvailable,
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 14,
|
||||
@@ -878,7 +978,7 @@ class _TransactionDetailScreenState extends State<_TransactionDetailScreen> {
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
'SPEED: $_speedLabel',
|
||||
'${L10n.of(context)!.speed} $_speedLabel',
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 13,
|
||||
@@ -898,6 +998,7 @@ class _TransactionDetailScreenState extends State<_TransactionDetailScreen> {
|
||||
required String mintDisplay,
|
||||
required bool isPending,
|
||||
}) {
|
||||
final l10n = L10n.of(context)!;
|
||||
final fee = widget.transaction.fee;
|
||||
final feeFormatted = fee > BigInt.zero
|
||||
? UnitFormatter.formatBalance(fee, widget.transaction.unit)
|
||||
@@ -909,34 +1010,34 @@ class _TransactionDetailScreenState extends State<_TransactionDetailScreen> {
|
||||
if (feeFormatted != null)
|
||||
_MinimalDetailRow(
|
||||
icon: LucideIcons.arrowUpDown,
|
||||
label: 'Fee',
|
||||
label: l10n.fee,
|
||||
value: feeFormatted,
|
||||
),
|
||||
// Unit
|
||||
_MinimalDetailRow(
|
||||
icon: LucideIcons.coins,
|
||||
label: 'Unit',
|
||||
label: l10n.unit,
|
||||
value: widget.transaction.unit.toUpperCase(),
|
||||
),
|
||||
// Mint
|
||||
_MinimalDetailRow(
|
||||
icon: LucideIcons.landmark,
|
||||
label: 'Mint',
|
||||
label: l10n.mint,
|
||||
value: mintDisplay,
|
||||
),
|
||||
// Estado (si pendiente)
|
||||
if (isPending)
|
||||
_MinimalDetailRow(
|
||||
icon: LucideIcons.clock,
|
||||
label: 'Status',
|
||||
value: 'Pending',
|
||||
label: l10n.status,
|
||||
value: l10n.pending,
|
||||
valueColor: AppColors.warning,
|
||||
),
|
||||
// Memo (si existe)
|
||||
if (widget.transaction.memo != null && widget.transaction.memo!.isNotEmpty)
|
||||
_MinimalDetailRow(
|
||||
icon: LucideIcons.messageSquare,
|
||||
label: 'Memo',
|
||||
label: l10n.memo,
|
||||
value: widget.transaction.memo!,
|
||||
),
|
||||
],
|
||||
@@ -944,7 +1045,8 @@ class _TransactionDetailScreenState extends State<_TransactionDetailScreen> {
|
||||
}
|
||||
|
||||
Widget _buildCopyButton() {
|
||||
final label = _isLightning ? 'COPY INVOICE' : 'COPY';
|
||||
final l10n = L10n.of(context)!;
|
||||
final label = _isLightning ? l10n.copyInvoiceButton : l10n.copyButton;
|
||||
|
||||
return SizedBox(
|
||||
width: double.infinity,
|
||||
@@ -953,7 +1055,7 @@ class _TransactionDetailScreenState extends State<_TransactionDetailScreen> {
|
||||
Clipboard.setData(ClipboardData(text: _tokenOrInvoice!));
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(_isLightning ? 'Invoice copiado' : 'Token copiado'),
|
||||
content: Text(_isLightning ? l10n.invoiceCopied : l10n.tokenCopied),
|
||||
backgroundColor: AppColors.success,
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
@@ -982,6 +1084,327 @@ class _TransactionDetailScreenState extends State<_TransactionDetailScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Chip de filtro con badge
|
||||
class _FilterChipWithBadge extends StatelessWidget {
|
||||
final String label;
|
||||
final IconData icon;
|
||||
final bool isSelected;
|
||||
final int badgeCount;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _FilterChipWithBadge({
|
||||
required this.label,
|
||||
required this.icon,
|
||||
required this.isSelected,
|
||||
required this.badgeCount,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected
|
||||
? AppColors.warning.withValues(alpha: 0.2)
|
||||
: Colors.white.withValues(alpha: 0.05),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(
|
||||
color: isSelected
|
||||
? AppColors.warning.withValues(alpha: 0.5)
|
||||
: Colors.white.withValues(alpha: 0.1),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
icon,
|
||||
size: 16,
|
||||
color: isSelected ? AppColors.warning : Colors.white70,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 14,
|
||||
fontWeight: isSelected ? FontWeight.w600 : FontWeight.w500,
|
||||
color: isSelected ? AppColors.warning : Colors.white70,
|
||||
),
|
||||
),
|
||||
if (badgeCount > 0) ...[
|
||||
const SizedBox(width: 6),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.warning,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Text(
|
||||
badgeCount > 9 ? '9+' : badgeCount.toString(),
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.black,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Tile para un token pendiente de reclamar
|
||||
class _PendingTokenTile extends StatefulWidget {
|
||||
final PendingToken token;
|
||||
final WalletProvider walletProvider;
|
||||
final Future<void> Function() onClaim;
|
||||
|
||||
const _PendingTokenTile({
|
||||
required this.token,
|
||||
required this.walletProvider,
|
||||
required this.onClaim,
|
||||
});
|
||||
|
||||
@override
|
||||
State<_PendingTokenTile> createState() => _PendingTokenTileState();
|
||||
}
|
||||
|
||||
class _PendingTokenTileState extends State<_PendingTokenTile> {
|
||||
bool _isClaiming = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = L10n.of(context)!;
|
||||
final token = widget.token;
|
||||
final unit = token.unit ?? 'sat';
|
||||
final formattedAmount = UnitFormatter.formatBalance(token.amount, unit);
|
||||
final unitLabel = UnitFormatter.getUnitLabel(unit);
|
||||
final mintDisplay = UnitFormatter.getMintDisplayName(token.mintUrl);
|
||||
final daysRemaining = token.daysRemaining;
|
||||
|
||||
// Color según días restantes
|
||||
Color daysColor;
|
||||
if (daysRemaining <= 3) {
|
||||
daysColor = AppColors.error;
|
||||
} else if (daysRemaining <= 7) {
|
||||
daysColor = AppColors.warning;
|
||||
} else {
|
||||
daysColor = AppColors.success;
|
||||
}
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: AppDimensions.paddingSmall),
|
||||
padding: const EdgeInsets.all(AppDimensions.paddingMedium),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: 0.05),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: AppColors.warning.withValues(alpha: 0.3),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
// Primera fila: icono, info, monto
|
||||
Row(
|
||||
children: [
|
||||
// Icono con badge PENDING
|
||||
Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
Container(
|
||||
width: 44,
|
||||
height: 44,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.warning.withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: const Icon(
|
||||
LucideIcons.clock,
|
||||
color: AppColors.warning,
|
||||
size: 22,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(width: AppDimensions.paddingMedium),
|
||||
|
||||
// Info del token
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 6,
|
||||
vertical: 2,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.warning.withValues(alpha: 0.2),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Text(
|
||||
l10n.pendingBadge,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.warning,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
mintDisplay,
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 14,
|
||||
color: AppColors.textSecondary.withValues(alpha: 0.7),
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
LucideIcons.timer,
|
||||
size: 12,
|
||||
color: daysColor,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
l10n.expiresInDays(daysRemaining),
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 12,
|
||||
color: daysColor,
|
||||
),
|
||||
),
|
||||
if (token.hasError) ...[
|
||||
const SizedBox(width: 8),
|
||||
Icon(
|
||||
LucideIcons.alertTriangle,
|
||||
size: 12,
|
||||
color: AppColors.error,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
l10n.retryCount(token.retryCount),
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 12,
|
||||
color: AppColors.error,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Monto
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Text(
|
||||
formattedAmount,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
unitLabel,
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 12,
|
||||
color: AppColors.textSecondary.withValues(alpha: 0.6),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: AppDimensions.paddingMedium),
|
||||
|
||||
// Botón reclamar (ancho completo)
|
||||
GestureDetector(
|
||||
onTap: _isClaiming
|
||||
? null
|
||||
: () async {
|
||||
setState(() => _isClaiming = true);
|
||||
try {
|
||||
await widget.onClaim();
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() => _isClaiming = false);
|
||||
}
|
||||
}
|
||||
},
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
gradient: _isClaiming
|
||||
? null
|
||||
: const LinearGradient(colors: AppColors.buttonGradient),
|
||||
color: _isClaiming ? Colors.grey : null,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
if (_isClaiming)
|
||||
const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: Colors.white,
|
||||
),
|
||||
)
|
||||
else
|
||||
const Icon(
|
||||
LucideIcons.download,
|
||||
size: 16,
|
||||
color: Colors.white,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
_isClaiming ? l10n.claiming : l10n.claimNow,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Fila de detalle minimalista (estilo cashu.me)
|
||||
class _MinimalDetailRow extends StatelessWidget {
|
||||
final IconData icon;
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:lucide_icons/lucide_icons.dart';
|
||||
import '../../core/constants/colors.dart';
|
||||
|
||||
/// Callback cuando el valor cambia
|
||||
typedef NumpadCallback = void Function(String value);
|
||||
|
||||
/// Teclado numérico reutilizable
|
||||
class NumpadWidget extends StatelessWidget {
|
||||
/// Valor actual
|
||||
final String value;
|
||||
|
||||
/// Callback cuando el valor cambia
|
||||
final NumpadCallback onChanged;
|
||||
|
||||
/// Máximo de dígitos permitidos
|
||||
final int maxDigits;
|
||||
|
||||
/// Mostrar botón MAX (para enviar todo el balance)
|
||||
final bool showMaxButton;
|
||||
|
||||
/// Callback para botón MAX
|
||||
final VoidCallback? onMaxPressed;
|
||||
|
||||
const NumpadWidget({
|
||||
super.key,
|
||||
required this.value,
|
||||
required this.onChanged,
|
||||
this.maxDigits = 12,
|
||||
this.showMaxButton = false,
|
||||
this.onMaxPressed,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Detectar si es móvil
|
||||
final screenWidth = MediaQuery.of(context).size.width;
|
||||
final isMobile = screenWidth < 768;
|
||||
|
||||
final verticalSpacing = isMobile ? 8.0 : 12.0;
|
||||
final horizontalSpacing = isMobile ? 8.0 : 12.0;
|
||||
final containerPadding = isMobile ? 12.0 : 16.0;
|
||||
|
||||
return Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: containerPadding),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Fila 1: 1, 2, 3, del
|
||||
_buildRow(['1', '2', '3', 'del'], horizontalSpacing, isMobile),
|
||||
SizedBox(height: verticalSpacing),
|
||||
// Fila 2: 4, 5, 6, 00
|
||||
_buildRow(['4', '5', '6', '00'], horizontalSpacing, isMobile),
|
||||
SizedBox(height: verticalSpacing),
|
||||
// Fila 3: 7, 8, 9, 000
|
||||
_buildRow(['7', '8', '9', '000'], horizontalSpacing, isMobile),
|
||||
SizedBox(height: verticalSpacing),
|
||||
// Fila 4: MAX/vacío, 0, vacío, C
|
||||
_buildRow([showMaxButton ? 'MAX' : '', '0', '', 'C'], horizontalSpacing, isMobile),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildRow(List<String> keys, double spacing, bool isMobile) {
|
||||
final List<Widget> children = [];
|
||||
|
||||
for (int i = 0; i < keys.length; i++) {
|
||||
if (i > 0) {
|
||||
children.add(SizedBox(width: spacing));
|
||||
}
|
||||
|
||||
if (keys[i].isEmpty) {
|
||||
children.add(Expanded(child: Container()));
|
||||
} else {
|
||||
children.add(Expanded(
|
||||
child: _buildKey(keys[i], isMobile),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
return Row(children: children);
|
||||
}
|
||||
|
||||
Widget _buildKey(String key, bool isMobile) {
|
||||
final isDelete = key == 'del';
|
||||
final isClear = key == 'C';
|
||||
final isMax = key == 'MAX';
|
||||
final isZeroShortcut = key == '00' || key == '000';
|
||||
|
||||
final buttonHeight = isMobile ? 48.0 : 56.0;
|
||||
|
||||
Color bgColor;
|
||||
Color textColor;
|
||||
|
||||
if (isDelete || isClear) {
|
||||
bgColor = Colors.white.withValues(alpha: 0.08);
|
||||
textColor = isClear ? AppColors.error : AppColors.textSecondary;
|
||||
} else if (isZeroShortcut || isMax) {
|
||||
bgColor = AppColors.primaryAction.withValues(alpha: 0.15);
|
||||
textColor = AppColors.primaryAction;
|
||||
} else {
|
||||
bgColor = Colors.white.withValues(alpha: 0.05);
|
||||
textColor = Colors.white;
|
||||
}
|
||||
|
||||
return GestureDetector(
|
||||
onTap: () => _onKeyPress(key),
|
||||
child: Container(
|
||||
height: buttonHeight,
|
||||
decoration: BoxDecoration(
|
||||
color: bgColor,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Center(
|
||||
child: isDelete
|
||||
? Icon(
|
||||
LucideIcons.delete,
|
||||
color: textColor,
|
||||
size: isMobile ? 20 : 24,
|
||||
)
|
||||
: Text(
|
||||
key,
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: (isZeroShortcut || isMax)
|
||||
? (isMobile ? 16 : 18)
|
||||
: (isMobile ? 24 : 28),
|
||||
fontWeight: FontWeight.w500,
|
||||
color: textColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _onKeyPress(String key) {
|
||||
String newValue = value;
|
||||
|
||||
if (key == 'del') {
|
||||
if (newValue.isNotEmpty) {
|
||||
newValue = newValue.substring(0, newValue.length - 1);
|
||||
}
|
||||
} else if (key == 'C') {
|
||||
newValue = '';
|
||||
} else if (key == 'MAX') {
|
||||
onMaxPressed?.call();
|
||||
return;
|
||||
} else if (key == '00') {
|
||||
if (newValue.isNotEmpty && newValue != '0') {
|
||||
newValue += '00';
|
||||
} else if (newValue.isEmpty) {
|
||||
newValue = '0';
|
||||
}
|
||||
} else if (key == '000') {
|
||||
if (newValue.isNotEmpty && newValue != '0') {
|
||||
newValue += '000';
|
||||
} else if (newValue.isEmpty) {
|
||||
newValue = '0';
|
||||
}
|
||||
} else {
|
||||
if (newValue == '0' && key == '0') return;
|
||||
if (newValue == '0' && key != '0') {
|
||||
newValue = key;
|
||||
} else {
|
||||
newValue += key;
|
||||
}
|
||||
}
|
||||
|
||||
if (newValue.length > maxDigits) {
|
||||
newValue = newValue.substring(0, maxDigits);
|
||||
}
|
||||
|
||||
onChanged(newValue);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,451 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:mobile_scanner/mobile_scanner.dart';
|
||||
import 'package:lucide_icons/lucide_icons.dart';
|
||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
import 'package:cdk_flutter/cdk_flutter.dart';
|
||||
import '../../core/constants/colors.dart';
|
||||
import '../../core/utils/incoming_data_parser.dart';
|
||||
|
||||
/// Widget reutilizable para escanear QR codes
|
||||
/// Soporta QR estáticos y animados (UR multipartes)
|
||||
class QrScannerWidget extends StatefulWidget {
|
||||
/// Callback cuando se detecta un código completo
|
||||
final void Function(String data) onDetect;
|
||||
|
||||
/// Callback para mostrar errores
|
||||
final void Function(String error)? onError;
|
||||
|
||||
/// Mostrar controles de flash (default: true)
|
||||
final bool showFlashControl;
|
||||
|
||||
/// Mostrar botón para cambiar cámara (default: false)
|
||||
final bool showCameraSwitch;
|
||||
|
||||
const QrScannerWidget({
|
||||
super.key,
|
||||
required this.onDetect,
|
||||
this.onError,
|
||||
this.showFlashControl = true,
|
||||
this.showCameraSwitch = false,
|
||||
});
|
||||
|
||||
@override
|
||||
State<QrScannerWidget> createState() => _QrScannerWidgetState();
|
||||
}
|
||||
|
||||
class _QrScannerWidgetState extends State<QrScannerWidget> {
|
||||
late MobileScannerController _controller;
|
||||
|
||||
// Estado para QR animados (UR multipartes) usando TokenDecoder de cdk-flutter
|
||||
TokenDecoder? _urDecoder;
|
||||
bool _isCapturingUr = false;
|
||||
final Set<String> _urFragmentsSeen = {}; // Trackear fragmentos únicos
|
||||
|
||||
// Evitar procesar el mismo código múltiples veces
|
||||
String? _lastProcessedCode;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = MobileScannerController(
|
||||
detectionSpeed: DetectionSpeed.noDuplicates,
|
||||
facing: CameraFacing.back,
|
||||
torchEnabled: false,
|
||||
// Usar resolución más alta para QRs densos
|
||||
cameraResolution: const Size(1920, 1080),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onDetect(BarcodeCapture capture) {
|
||||
final List<Barcode> barcodes = capture.barcodes;
|
||||
if (barcodes.isEmpty) return;
|
||||
|
||||
final barcode = barcodes.first;
|
||||
final String? rawValue = barcode.rawValue;
|
||||
if (rawValue == null || rawValue.isEmpty) return;
|
||||
|
||||
// Evitar procesar el mismo código consecutivamente
|
||||
if (rawValue == _lastProcessedCode) return;
|
||||
_lastProcessedCode = rawValue;
|
||||
|
||||
// Verificar si es un fragmento UR (ur:bytes/, ur:cashu/, etc.)
|
||||
if (rawValue.toLowerCase().startsWith('ur:')) {
|
||||
_handleUrFragment(rawValue);
|
||||
} else {
|
||||
// QR simple - emitir directamente
|
||||
_resetUrState();
|
||||
widget.onDetect(rawValue);
|
||||
}
|
||||
}
|
||||
|
||||
void _handleUrFragment(String fragment) {
|
||||
// Inicializar decoder si es necesario
|
||||
if (_urDecoder == null) {
|
||||
_urDecoder = TokenDecoder();
|
||||
_isCapturingUr = true;
|
||||
_urFragmentsSeen.clear();
|
||||
}
|
||||
|
||||
// Ignorar fragmentos ya vistos
|
||||
if (_urFragmentsSeen.contains(fragment)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Pasar fragmento al decoder (usa fountain codes internamente)
|
||||
try {
|
||||
_urDecoder!.receive(input: fragment);
|
||||
_urFragmentsSeen.add(fragment);
|
||||
} catch (e) {
|
||||
// Fragmento inválido o incompatible, ignorar
|
||||
return;
|
||||
}
|
||||
|
||||
// Actualizar UI
|
||||
if (mounted) setState(() {});
|
||||
|
||||
// Verificar si está completo
|
||||
if (_urDecoder!.isComplete()) {
|
||||
try {
|
||||
final token = _urDecoder!.value();
|
||||
if (token != null) {
|
||||
final encodedToken = token.encoded;
|
||||
_resetUrState();
|
||||
widget.onDetect(encodedToken);
|
||||
} else {
|
||||
widget.onError?.call('Error: token vacío');
|
||||
_resetUrState();
|
||||
}
|
||||
} catch (e) {
|
||||
widget.onError?.call('Error decodificando UR: $e');
|
||||
_resetUrState();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _resetUrState() {
|
||||
_urDecoder = null;
|
||||
_isCapturingUr = false;
|
||||
_urFragmentsSeen.clear();
|
||||
_lastProcessedCode = null;
|
||||
}
|
||||
|
||||
void _toggleFlash() async {
|
||||
await _controller.toggleTorch();
|
||||
}
|
||||
|
||||
void _switchCamera() async {
|
||||
await _controller.switchCamera();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Stack(
|
||||
children: [
|
||||
// Scanner
|
||||
MobileScanner(
|
||||
controller: _controller,
|
||||
onDetect: _onDetect,
|
||||
errorBuilder: (context, error, child) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
LucideIcons.cameraOff,
|
||||
color: AppColors.error,
|
||||
size: 48,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
error.errorDetails?.message ??
|
||||
L10n.of(context)!.cameraPermissionDenied,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 16,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
// Overlay con visor
|
||||
_buildOverlay(),
|
||||
|
||||
// Indicador de progreso UR (si está capturando)
|
||||
if (_isCapturingUr) _buildUrProgress(),
|
||||
|
||||
// Controles
|
||||
_buildControls(),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildOverlay() {
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final scannerSize = constraints.maxWidth * 0.7;
|
||||
final horizontalPadding = (constraints.maxWidth - scannerSize) / 2;
|
||||
final verticalPadding = (constraints.maxHeight - scannerSize) / 2;
|
||||
|
||||
return Stack(
|
||||
children: [
|
||||
// Sombra superior
|
||||
Positioned(
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: verticalPadding,
|
||||
child: Container(color: Colors.black54),
|
||||
),
|
||||
// Sombra inferior
|
||||
Positioned(
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: verticalPadding,
|
||||
child: Container(color: Colors.black54),
|
||||
),
|
||||
// Sombra izquierda
|
||||
Positioned(
|
||||
top: verticalPadding,
|
||||
left: 0,
|
||||
width: horizontalPadding,
|
||||
height: scannerSize,
|
||||
child: Container(color: Colors.black54),
|
||||
),
|
||||
// Sombra derecha
|
||||
Positioned(
|
||||
top: verticalPadding,
|
||||
right: 0,
|
||||
width: horizontalPadding,
|
||||
height: scannerSize,
|
||||
child: Container(color: Colors.black54),
|
||||
),
|
||||
// Marco del visor
|
||||
Positioned(
|
||||
top: verticalPadding,
|
||||
left: horizontalPadding,
|
||||
child: Container(
|
||||
width: scannerSize,
|
||||
height: scannerSize,
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(
|
||||
color: _isCapturingUr
|
||||
? AppColors.warning
|
||||
: AppColors.primaryAction,
|
||||
width: 2,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Stack(
|
||||
children: [
|
||||
// Esquinas decorativas
|
||||
_buildCorner(Alignment.topLeft),
|
||||
_buildCorner(Alignment.topRight),
|
||||
_buildCorner(Alignment.bottomLeft),
|
||||
_buildCorner(Alignment.bottomRight),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCorner(Alignment alignment) {
|
||||
final color = _isCapturingUr ? AppColors.warning : AppColors.primaryAction;
|
||||
const size = 24.0;
|
||||
const thickness = 4.0;
|
||||
|
||||
return Positioned(
|
||||
top: alignment == Alignment.topLeft || alignment == Alignment.topRight
|
||||
? 0
|
||||
: null,
|
||||
bottom:
|
||||
alignment == Alignment.bottomLeft || alignment == Alignment.bottomRight
|
||||
? 0
|
||||
: null,
|
||||
left: alignment == Alignment.topLeft || alignment == Alignment.bottomLeft
|
||||
? 0
|
||||
: null,
|
||||
right:
|
||||
alignment == Alignment.topRight || alignment == Alignment.bottomRight
|
||||
? 0
|
||||
: null,
|
||||
child: SizedBox(
|
||||
width: size,
|
||||
height: size,
|
||||
child: CustomPaint(
|
||||
painter: _CornerPainter(
|
||||
color: color,
|
||||
thickness: thickness,
|
||||
alignment: alignment,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildUrProgress() {
|
||||
return Positioned(
|
||||
top: 16,
|
||||
left: 16,
|
||||
right: 16,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.warning.withValues(alpha: 0.9),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Text(
|
||||
'QR Animado',
|
||||
style: TextStyle(
|
||||
color: Colors.black,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'${_urFragmentsSeen.length} fragmentos',
|
||||
style: const TextStyle(
|
||||
color: Colors.black,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
// Indicador indeterminado ya que TokenDecoder no expone progreso
|
||||
const LinearProgressIndicator(
|
||||
backgroundColor: Colors.black26,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(Colors.black),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildControls() {
|
||||
return Positioned(
|
||||
bottom: 24,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
if (widget.showFlashControl)
|
||||
ValueListenableBuilder(
|
||||
valueListenable: _controller,
|
||||
builder: (context, state, child) {
|
||||
final torchEnabled = state.torchState == TorchState.on;
|
||||
return GestureDetector(
|
||||
onTap: _toggleFlash,
|
||||
child: Container(
|
||||
width: 56,
|
||||
height: 56,
|
||||
decoration: BoxDecoration(
|
||||
color: torchEnabled
|
||||
? AppColors.warning
|
||||
: Colors.white.withValues(alpha: 0.2),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(
|
||||
torchEnabled ? LucideIcons.zapOff : LucideIcons.zap,
|
||||
color: torchEnabled ? Colors.black : Colors.white,
|
||||
size: 24,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
if (widget.showFlashControl && widget.showCameraSwitch)
|
||||
const SizedBox(width: 24),
|
||||
if (widget.showCameraSwitch)
|
||||
GestureDetector(
|
||||
onTap: _switchCamera,
|
||||
child: Container(
|
||||
width: 56,
|
||||
height: 56,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: 0.2),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Icon(
|
||||
LucideIcons.switchCamera,
|
||||
color: Colors.white,
|
||||
size: 24,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Painter para dibujar esquinas decorativas
|
||||
class _CornerPainter extends CustomPainter {
|
||||
final Color color;
|
||||
final double thickness;
|
||||
final Alignment alignment;
|
||||
|
||||
_CornerPainter({
|
||||
required this.color,
|
||||
required this.thickness,
|
||||
required this.alignment,
|
||||
});
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final paint = Paint()
|
||||
..color = color
|
||||
..strokeWidth = thickness
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeCap = StrokeCap.round;
|
||||
|
||||
final path = Path();
|
||||
|
||||
if (alignment == Alignment.topLeft) {
|
||||
path.moveTo(0, size.height);
|
||||
path.lineTo(0, 0);
|
||||
path.lineTo(size.width, 0);
|
||||
} else if (alignment == Alignment.topRight) {
|
||||
path.moveTo(0, 0);
|
||||
path.lineTo(size.width, 0);
|
||||
path.lineTo(size.width, size.height);
|
||||
} else if (alignment == Alignment.bottomLeft) {
|
||||
path.moveTo(0, 0);
|
||||
path.lineTo(0, size.height);
|
||||
path.lineTo(size.width, size.height);
|
||||
} else if (alignment == Alignment.bottomRight) {
|
||||
path.moveTo(size.width, 0);
|
||||
path.lineTo(size.width, size.height);
|
||||
path.lineTo(0, size.height);
|
||||
}
|
||||
|
||||
canvas.drawPath(path, paint);
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(covariant _CornerPainter oldDelegate) =>
|
||||
color != oldDelegate.color;
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import FlutterMacOS
|
||||
import Foundation
|
||||
|
||||
import flutter_secure_storage_macos
|
||||
import mobile_scanner
|
||||
import path_provider_foundation
|
||||
import share_plus
|
||||
import shared_preferences_foundation
|
||||
@@ -14,6 +15,7 @@ import url_launcher_macos
|
||||
|
||||
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||
FlutterSecureStoragePlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStoragePlugin"))
|
||||
MobileScannerPlugin.register(with: registry.registrar(forPlugin: "MobileScannerPlugin"))
|
||||
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
|
||||
SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin"))
|
||||
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
||||
|
||||
+17
-1
@@ -33,6 +33,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.11.0"
|
||||
bech32:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: bech32
|
||||
sha256: "156cbace936f7720c79a79d16a03efad343b1ef17106716e04b8b8e39f99f7f7"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.2.2"
|
||||
boolean_selector:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -438,6 +446,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.0"
|
||||
mobile_scanner:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: mobile_scanner
|
||||
sha256: d234581c090526676fd8fab4ada92f35c6746e3fb4f05a399665d75a399fb760
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.2.3"
|
||||
nested:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -828,7 +844,7 @@ packages:
|
||||
source: hosted
|
||||
version: "3.1.4"
|
||||
uuid:
|
||||
dependency: transitive
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: uuid
|
||||
sha256: a11b666489b1954e01d992f3d601b1804a33937b5a8fe677bd26b8a9f96f96e8
|
||||
|
||||
@@ -39,6 +39,7 @@ dependencies:
|
||||
# QR Code
|
||||
qr_flutter: ^4.1.0
|
||||
share_plus: ^12.0.1
|
||||
mobile_scanner: ^5.1.1
|
||||
|
||||
# URL Launcher
|
||||
url_launcher: ^6.2.0
|
||||
@@ -49,6 +50,12 @@ dependencies:
|
||||
# CBOR decoder for V4 tokens
|
||||
cbor: ^6.3.0
|
||||
|
||||
# Bech32 decoder for LNURL
|
||||
bech32: ^0.2.2
|
||||
|
||||
# UUID generator for pending tokens
|
||||
uuid: ^4.3.3
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
|
||||
Reference in New Issue
Block a user