Compare commits

...
Author SHA1 Message Date
Forte11Cuba d4e5e1ce70 feat(i18n): add German translation 2026-02-11 03:05:39 -06:00
Forte11andGitHub 9b3d72fae3 Merge pull request #31 from Forte11Cuba/feature/i18n-russian
feat(i18n): add Russian translation
2026-02-11 02:56:00 -06:00
Forte11Cuba 3e86de397d feat(i18n): add Russian translation 2026-02-11 02:55:27 -06:00
Forte11andGitHub 8bd95dd9a4 Merge pull request #30 from Forte11Cuba/feature/i18n-french
feat(i18n): add French translation
2026-02-11 02:51:04 -06:00
Forte11Cuba 20c513b5fc feat(i18n): add French translation 2026-02-11 02:49:09 -06:00
Forte11andGitHub aeb26158ef Merge pull request #29 from Forte11Cuba/feature/i18n-portuguese
feat(i18n): add Portuguese translation
2026-02-11 02:42:25 -06:00
Forte11Cuba 796f488c15 fix(i18n): register Portuguese locale in supportedLocales 2026-02-11 02:40:54 -06:00
Forte11Cuba a64a09adeb feat(i18n): add Portuguese translation 2026-02-11 02:35:41 -06:00
Forte11andGitHub 905981c3c3 Merge pull request #28 from Forte11Cuba/improve/ux-copy-and-loading-messages
Improve UX copy and add dynamic loading messages
2026-02-11 01:11:12 -06:00
Forte11Cuba a4a1b929a0 Improve UX copy and add dynamic loading messages 2026-02-11 01:05:47 -06:00
Forte11andGitHub c6209b9c9f Merge pull request #27 from Forte11Cuba/fix/show-full-lightning-address
Fix: show full lightning address instead of username only
2026-02-11 00:33:19 -06:00
Forte11Cuba 73a4afe3c2 Fix: show full lightning address instead of username only 2026-02-11 00:31:16 -06:00
Forte11andGitHub c650ec2159 Merge pull request #26 from Forte11Cuba/fix/safe-area-bottom-sheet-overlap
fix: add SafeArea to screens and fix bottom sheet overlap
2026-02-11 00:21:26 -06:00
Forte11Cuba 72691dbafb fix: añadir SafeArea a pantallas y corregir overlapping en bottom sheet 2026-02-11 00:17:06 -06:00
Forte11andGitHub bdafde7512 Update README.md 2026-02-10 18:14:29 -06:00
Forte11andGitHub b9b24de611 Merge pull request #25 from Forte11Cuba/feature/numpad-and-lnurl-ux
feat: add reusable numpad widget and improve LNURL UX
2026-02-10 18:06:13 -06:00
Forte11Cuba 10114445cd fix: additional CodeRabbit review fixes 2026-02-10 18:04:30 -06:00
Forte11Cuba aef0eb414e fix: address CodeRabbit review comments 2026-02-10 17:56:52 -06:00
Forte11Cuba 9bf4aa1e81 feat: add price service for USD/EUR to sats conversion in LNURL payments 2026-02-10 17:45:44 -06:00
Forte11Cuba ccbc143eb9 feat: add reusable numpad widget and improve LNURL UX 2026-02-10 16:27:06 -06:00
Forte11andGitHub b33270a719 Merge pull request #24 from Forte11Cuba/fix/qr-scanner-animated-ur
fix: full support for animated QR scanning (UR)
2026-02-10 15:15:57 -06:00
Forte11Cuba ae06d83cea fix: full support for animated QR scanning (UR) 2026-02-10 15:13:54 -06:00
Forte11andGitHub 9de89fe9c1 Merge pull request #21 from Forte11Cuba/feature/qr-scanner
feat: add QR scanner with UR multipart support
2026-02-07 23:46:10 -06:00
Forte11Cuba 7ee12dc044 fix errors 2026-02-07 23:45:26 -06:00
Forte11Cuba 66abf3b3c2 fix: address CodeRabbit review feedback 2026-02-07 23:38:11 -06:00
Forte11Cuba 9ec585d9a2 feat: add QR scanner with UR multipart support 2026-02-07 23:20:22 -06:00
Forte11andGitHub a25fb9d7c1 Merge pull request #20 from Forte11Cuba/feat/receive-single-button-connectivity
feat(receive): single button with mint connectivity check
2026-02-07 22:55:16 -06:00
Forte11Cuba 9e3622435b fix(receive): use localized string for invalid token error 2026-02-07 22:54:54 -06:00
Forte11Cuba b6c5b42cf2 fix(receive): add catch block for save-for-later errors 2026-02-07 22:44:46 -06:00
Forte11Cuba 835f5205c7 feat(receive): single button with mint connectivity check 2026-02-07 22:35:13 -06:00
Forte11andGitHub 0629c00c90 Merge pull request #18 from Forte11Cuba/fix/onboarding-ui-polish
fix: improve onboarding UI with language selector and icon consistency
2026-02-06 21:49:40 -06:00
Forte11Cuba f49e6a9c59 fix flags 2026-02-06 21:43:33 -06:00
Forte11Cuba a0821429dd fix: improve onboarding UI with language selector and icon consistency 2026-02-06 21:33:47 -06:00
Forte11andGitHub 2e0f360ff0 Merge pull request #17 from Forte11Cuba/feature/receive-later
feat: add Receive Later functionality for offline token storage
2026-02-06 20:58:34 -06:00
Forte11Cuba cbddc5aff0 fix some files 2026-02-06 20:56:41 -06:00
Forte11Cuba 9fee4c7ef4 feat: add Receive Later functionality for offline token storage 2026-02-06 20:47:03 -06:00
Forte11andGitHub 7035e2ef00 Merge pull request #16 from Forte11Cuba/feature/i18n
feat: complete i18n implementation for all screens
2026-02-06 20:11:01 -06:00
Forte11Cuba 989fd19dc8 fix some files 2026-02-06 20:07:31 -06:00
Forte11Cuba 5dff3bcf24 feat: complete i18n implementation for all screens 2026-02-06 19:57:17 -06:00
Forte11andGitHub f30d6bac2e Merge pull request #14 from Forte11Cuba/fix/scroll-layout-overlapping
fix: resolve overlapping in screens with keyboard
2026-02-06 18:58:51 -06:00
41 changed files with 7567 additions and 704 deletions
+4 -1
View File
@@ -1,4 +1,7 @@
# ElCaju 🥜
## ElCaju 🥜
[![Ask DeepWiki](https://deepwiki.com/badge.svg)](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"/>
+5
View File
@@ -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"
+318
View File
@@ -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');
}
}
}
+89
View File
@@ -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());
}
}
+36
View File
@@ -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) {
+188
View File
@@ -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;
}
}
}
+126
View File
@@ -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)';
}
}
+279
View File
@@ -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();
}
}
+484
View File
@@ -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
View File
@@ -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
View File
@@ -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"
}
+484
View File
@@ -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"
}
+484
View File
@@ -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"
}
+484
View File
@@ -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
View File
@@ -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(),
);
+138
View File
@@ -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();
}
}
+200
View File
@@ -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;
}
}
+365
View File
@@ -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),
),
);
}
}
+34 -2
View File
@@ -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
+145 -2
View File
@@ -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,
+155 -26
View File
@@ -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),
],
),
);
+204 -40
View File
@@ -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;
});
}
}
}
+14 -10
View File
@@ -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
View File
@@ -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,
),
+16 -14
View File
@@ -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)),
+37 -31
View File
@@ -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),
),
);
}
+56 -67
View File
@@ -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,
),
+640
View File
@@ -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;
}
}
+300 -58
View File
@@ -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,
),
+34 -28
View File
@@ -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),
),
),
],
+38 -30
View File
@@ -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;
});
}
}
+150 -103
View File
@@ -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) {
+471 -48
View File
@@ -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;
+177
View File
@@ -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);
}
}
+451
View File
@@ -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
View File
@@ -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
+7
View File
@@ -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