Compare commits

..
44 Commits
Author SHA1 Message Date
Forte11andGitHub 7ec4e4eebc Merge pull request #122 from Forte11Cuba/chore/bump-0.3.0
Release APK / build (push) Canceled after 0s
chore: bump version to 0.3.0+4
2026-04-21 05:01:46 -06:00
Forte11Cuba 92cdf4ec2d chore: bump version to 0.3.0+4 2026-04-21 05:00:56 -06:00
Forte11andGitHub b9d57b69b0 Merge pull request #121 from Forte11Cuba/chore/settings-recover-icon-and-order
chore(settings): align recover icon and reorder wallet section
2026-04-21 04:56:07 -06:00
Forte11Cuba 4f83d9c3a1 chore(settings): align recover icon and reorder wallet section 2026-04-21 04:52:12 -06:00
Forte11andGitHub 6246d09876 Merge pull request #120 from Forte11Cuba/refactor/mint-quote-recovery-by-id
refactor: recover mint quotes by quote_id (closes #118)
2026-04-19 07:43:20 -06:00
Forte11Cuba 73a0261af6 fix: run mint-quote recovery before checkAllMintQuotes 2026-04-19 06:18:51 -06:00
Forte11Cuba dd9450c0cc refactor: recover mint quotes by quote_id (closes #118) 2026-04-19 06:03:21 -06:00
Forte11andGitHub 09565aa3ed Merge pull request #119 from Forte11Cuba/fix/mint-issued-event-race-condition
fix: deliver mint Issued event before metadata await (closes #115)
2026-04-19 05:09:08 -06:00
Forte11Cuba e3fdd46df8 fix: deliver mint Issued event before metadata await (closes #115) 2026-04-19 05:00:05 -06:00
Forte11andGitHub f4d15fd2ab Merge pull request #117 from Forte11Cuba/fix/mints-list-show-all-supported-units
fix: show all supported units on mints list, dim zero-balance pills
2026-04-18 05:15:34 -06:00
Forte11Cuba 7bc9639e89 fix: show all supported units on mints list, dim zero-balance pills 2026-04-18 03:55:18 -06:00
Forte11andGitHub e11778ff0a Merge pull request #116 from Forte11Cuba/feat/observe-only-reconcile-and-settled-state
fix: observe-only reconcile + settled state for offline sends
2026-04-18 03:44:17 -06:00
Forte11Cuba d5d4b3a6ea fix: address CodeRabbit review 2026-04-18 03:29:51 -06:00
Forte11Cuba a0f9ca738b fix: observe-only reconcile + settled state for offline sends 2026-04-18 02:09:50 -06:00
Forte11andGitHub ca63c56ecf Merge pull request #114 from Forte11Cuba/feat/reclaim-pending-send-from-history
feat: add cancel/reclaim button for pending sends (online + offline)
2026-04-18 01:05:04 -06:00
Forte11Cuba 5cdce99154 fix: address CodeRabbit review on #87 2026-04-17 07:52:38 -06:00
Forte11Cuba f734b35670 feat: add cancel/reclaim button for pending sends (online + offline) 2026-04-17 07:12:33 -06:00
Forte11andGitHub 5eef9ce130 Merge pull request #113 from Forte11Cuba/feat/paste-from-clipboard-in-scanner
feat: add paste-from-clipboard button to QR scanner
2026-04-17 06:04:53 -06:00
Forte11Cuba f2951d2bef feat: add paste-from-clipboard button to QR scanner 2026-04-17 05:07:53 -06:00
Forte11andGitHub 8015b4817b Merge pull request #112 from Forte11Cuba/fix/home-action-arrows-as-icons
fix: render home send/receive arrows as Lucide icons
2026-04-17 04:58:57 -06:00
Forte11Cuba cc1531f3d5 fix: render home send/receive arrows as Lucide icons 2026-04-17 03:55:09 -06:00
Forte11andGitHub aaef4b1cdc Merge pull request #111 from Forte11Cuba/fix/modal-bottom-sheet-safe-area
fix: bottom sheet modals overlap system navigation bar
2026-04-17 03:45:46 -06:00
Forte11Cuba dacec9ea22 fix: bottom sheet modals overlap system navigation bar 2026-04-17 03:39:41 -06:00
Forte11andGitHub 4b8edf9669 Merge pull request #110 from Forte11Cuba/fix/reclaim-per-unit-and-cancel-melt
fix: aggregate reclaimPendingProofs by unit and replace broken cancel Melt/cancelSend
2026-04-15 20:05:54 -06:00
Forte11Cuba f458cfad34 fix: aggregate reclaimPendingProofs by unit and replace broken cancelMelt/cancelSend 2026-04-15 17:37:27 -06:00
Forte11Cuba 428f2c1f45 fix: aggregate reclaimPendingProofs by unit and replace broken cancelMelt/cancelSend 2026-04-15 17:19:38 -06:00
Forte11andGitHub 9a14af33a2 Merge pull request #107 from Forte11Cuba/fix/melt-proofs-lost-and-min-amount-validation
fix: recover reserved proofs on melt failure and validate minimum viable amounts
2026-04-15 06:10:08 -06:00
Forte11Cuba 770868a773 fix: add fee pre-validation to swap and log cancelMelt failures 2026-04-15 04:48:03 -06:00
Forte11Cuba dc78f8958c fix: recover reserved proofs on melt failure and validate minimum viable amounts 2026-04-15 04:20:53 -06:00
Forte11andGitHub 03ba0e532f Merge pull request #103 from Forte11Cuba/refactor/split-settings-screen
refactor: split settings_screen.dart into separate files
2026-04-12 16:34:06 -06:00
Forte11Cuba a6a4365a0d fix: remove unnecessary async from synchronous _loadMints 2026-04-12 16:33:26 -06:00
Forte11Cuba ac23574ae2 fix: count only mints that actually recovered tokens in restore message 2026-04-12 16:07:24 -06:00
Forte11Cuba 87f8e52036 fix: use addPostFrameCallback for safe context access in RecoverTokensModal 2026-04-12 15:56:26 -06:00
Forte11Cuba eb9319fca0 refactor: split settings_screen.dart into separate files 2026-04-12 15:43:08 -06:00
Forte11andGitHub a9e4272762 Merge pull request #102 from Forte11Cuba/feat/privacy-screen
feat: add privacy policy screen with humorous tone
2026-04-11 03:01:53 -06:00
Forte11Cuba f66668f0ec fix: normalize privacyBody split to filter empty lines 2026-04-11 02:32:46 -06:00
Forte11Cuba 100a4943eb fix: handle privacyConclusion lines robustly for any locale line count 2026-04-11 02:27:46 -06:00
Forte11Cuba 5b1317c3e4 fix: address CodeRabbit review Portuguese register, scrollable layout 2026-04-11 02:22:28 -06:00
Forte11Cuba d85b6562dc feat: add privacy policy screen with humorous tone 2026-04-11 01:32:43 -06:00
Forte11andGitHub 56585319cc Merge pull request #101 from Forte11Cuba/feat/swap-chart-improvements
feat: improve swap price chart — real data only, 24h stats, min/max
2026-04-11 00:54:52 -06:00
Forte11Cuba 79ca30c5ed fix: filter non-finite and zero price samples before rendering chart 2026-04-11 00:37:57 -06:00
Forte11Cuba 0bd988a7a6 fix: address CodeRabbit review handle 2 samples, use i18n template for minMax 2026-04-11 00:31:53 -06:00
Forte11Cuba 218d798d2a feat: improve swap price chart — real data only, 24h stats, min/max 2026-04-11 00:19:43 -06:00
Forte11andGitHub e8d8789413 Merge pull request #100 from Forte11Cuba/feat/swap-screen
feat: add swap screen UI (sats -> USD) with price chart
2026-04-10 04:47:37 -06:00
52 changed files with 7997 additions and 2216 deletions
+156
View File
@@ -0,0 +1,156 @@
/// Estado del envío dentro del storage propio.
///
/// - [active] : el receptor aún no reclamó; los proofs están PendingSpent
/// localmente. Mostrar con acción de cancelar/reclamar.
/// - [settled] : la reconciliación con el mint confirmó que los proofs
/// fueron gastados (receptor reclamó) o que una reconciliación
/// previa ya los procesó. Mostrar como tx saliente histórica,
/// sin acción de cancel.
enum PendingSendStatus {
active,
settled;
String get wireName => name;
static PendingSendStatus fromWire(String? raw) {
switch (raw) {
case 'settled':
return PendingSendStatus.settled;
case 'active':
case null:
default:
return PendingSendStatus.active;
}
}
}
/// Modelo para rastrear envíos offline.
///
/// Los envíos offline no crean una Transaction en CDK (porque se hacen con
/// selección manual de proofs + createOfflineToken), por lo que necesitamos
/// un storage propio para poder mostrarlos en el historial y permitir
/// cancelar/reclamar por transacción.
///
/// Los proofs se identifican por sus Y values (hex) — mismos que acepta
/// Wallet.reclaimProofsByYs() en el bridge Rust.
class PendingSend {
/// UUID único
final String id;
/// Token codificado completo (cashuA... o cashuB...)
final String encoded;
/// Monto total del envío (suma de los proofs)
final BigInt amount;
/// URL del mint
final String mintUrl;
/// Unidad (sat, usd, eur, etc.)
final String unit;
/// Y values de los proofs incluidos (hex). Se usan para reclamar.
final List<String> proofYs;
/// Fecha de creación del envío
final DateTime createdAt;
/// Memo opcional
final String? memo;
/// Estado del envío. Ver [PendingSendStatus].
final PendingSendStatus status;
/// Timestamp de cuando el envío pasó a [PendingSendStatus.settled].
/// Null mientras esté activo. Se usa para ordenar en el historial.
final DateTime? settledAt;
PendingSend({
required this.id,
required this.encoded,
required this.amount,
required this.mintUrl,
required this.unit,
required List<String> proofYs,
required this.createdAt,
this.memo,
this.status = PendingSendStatus.active,
this.settledAt,
}) : proofYs = List.unmodifiable(proofYs);
bool get isActive => status == PendingSendStatus.active;
bool get isSettled => status == PendingSendStatus.settled;
/// Para ordenar en el historial: si está settled, usar settledAt; si no,
/// usar createdAt. Condicionado en `isSettled` (no sólo en `settledAt != null`)
/// para que una corrupción futura del campo no reubique un record activo.
DateTime get effectiveTimestamp =>
isSettled ? (settledAt ?? createdAt) : createdAt;
PendingSend copyWith({
String? id,
String? encoded,
BigInt? amount,
String? mintUrl,
String? unit,
List<String>? proofYs,
DateTime? createdAt,
String? memo,
PendingSendStatus? status,
DateTime? settledAt,
}) {
return PendingSend(
id: id ?? this.id,
encoded: encoded ?? this.encoded,
amount: amount ?? this.amount,
mintUrl: mintUrl ?? this.mintUrl,
unit: unit ?? this.unit,
proofYs: proofYs ?? this.proofYs,
createdAt: createdAt ?? this.createdAt,
memo: memo ?? this.memo,
status: status ?? this.status,
settledAt: settledAt ?? this.settledAt,
);
}
Map<String, dynamic> toMap() {
return {
'id': id,
'encoded': encoded,
'amount': amount.toString(),
'mint_url': mintUrl,
'unit': unit,
'proof_ys': proofYs.join(','),
'created_at': createdAt.millisecondsSinceEpoch,
'memo': memo,
'status': status.wireName,
'settled_at': settledAt?.millisecondsSinceEpoch,
};
}
factory PendingSend.fromMap(Map<String, dynamic> map) {
final ysRaw = map['proof_ys'] as String? ?? '';
final ys = ysRaw.isEmpty
? <String>[]
: ysRaw.split(',').where((s) => s.isNotEmpty).toList();
final settledAtRaw = map['settled_at'] as int?;
return PendingSend(
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,
proofYs: ys,
createdAt: DateTime.fromMillisecondsSinceEpoch(map['created_at'] as int),
memo: map['memo'] as String?,
status: PendingSendStatus.fromWire(map['status'] as String?),
settledAt: settledAtRaw == null
? null
: DateTime.fromMillisecondsSinceEpoch(settledAtRaw),
);
}
@override
String toString() =>
'PendingSend(id: $id, amount: $amount $unit, mintUrl: $mintUrl, ys: ${proofYs.length}, status: ${status.name})';
}
+214
View File
@@ -0,0 +1,214 @@
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_send.dart';
/// Storage persistente para envíos offline.
///
/// Los envíos offline no crean una Transaction en CDK, así que necesitamos
/// rastrearlos nosotros para poder:
/// - Mostrarlos en el historial como activos (cancelables) o como
/// liquidados (settled, display-only).
/// - Reclamar las proofs por transacción si el receptor no reclamó.
///
/// Mirrors PendingTokenStorage (singleton + cache + SQLite + change stream).
class PendingSendStorage {
static const _dbName = 'pending_sends.db';
static const _tableName = 'pending_sends';
static const _schemaVersion = 2;
Database? _db;
final Map<String, PendingSend> _cache = {};
bool _isInitialized = false;
final StreamController<void> _changesController =
StreamController<void>.broadcast();
Stream<void> get changes => _changesController.stream;
static final PendingSendStorage _instance = PendingSendStorage._internal();
factory PendingSendStorage() => _instance;
PendingSendStorage._internal();
bool get isInitialized => _isInitialized;
int get count => _cache.length;
bool get hasPendingSends => _cache.isNotEmpty;
int get activeCount =>
_cache.values.where((s) => s.isActive).length;
bool get hasActivePendingSends => activeCount > 0;
Future<void> init() async {
if (_isInitialized) return;
if (Platform.isLinux || Platform.isWindows) {
sqfliteFfiInit();
databaseFactory = databaseFactoryFfi;
}
final dir = await getApplicationDocumentsDirectory();
final dbPath = '${dir.path}/$_dbName';
_db = await openDatabase(
dbPath,
version: _schemaVersion,
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 NOT NULL,
proof_ys TEXT NOT NULL,
created_at INTEGER NOT NULL,
memo TEXT,
status TEXT NOT NULL DEFAULT 'active',
settled_at INTEGER
)
''');
await db.execute(
'CREATE INDEX idx_ps_mint_unit ON $_tableName(mint_url, unit)',
);
await db.execute(
'CREATE INDEX idx_ps_created_at ON $_tableName(created_at)',
);
await db.execute(
'CREATE INDEX idx_ps_status ON $_tableName(status)',
);
},
onUpgrade: (db, oldVersion, newVersion) async {
if (oldVersion < 2) {
// v2: status + settled_at. Los records existentes quedan 'active'.
await db.execute(
"ALTER TABLE $_tableName ADD COLUMN status TEXT NOT NULL DEFAULT 'active'",
);
await db.execute(
'ALTER TABLE $_tableName ADD COLUMN settled_at INTEGER',
);
await db.execute(
'CREATE INDEX IF NOT EXISTS idx_ps_status ON $_tableName(status)',
);
}
},
);
await _loadFromDb();
_isInitialized = true;
debugPrint(
'PendingSendStorage inicializado: ${_cache.length} envíos '
'($activeCount activos)',
);
}
Future<void> _loadFromDb() async {
if (_db == null) return;
final results = await _db!.query(_tableName, orderBy: 'created_at DESC');
_cache.clear();
for (final row in results) {
final send = PendingSend.fromMap(row);
_cache[send.id] = send;
}
}
Future<PendingSend> add({
required String id,
required String encoded,
required BigInt amount,
required String mintUrl,
required String unit,
required List<String> proofYs,
String? memo,
}) async {
final send = PendingSend(
id: id,
encoded: encoded,
amount: amount,
mintUrl: mintUrl,
unit: unit,
proofYs: proofYs,
createdAt: DateTime.now(),
memo: memo,
// status default = active
);
// Persistencia primero: si SQLite falla, la cache no queda contaminada
// con un record que el reinicio no va a encontrar.
await _db?.insert(_tableName, send.toMap());
_cache[send.id] = send;
_changesController.add(null);
return send;
}
/// Marca un envío como liquidado (settled). Se usa cuando la reconciliación
/// confirmó que el receptor reclamó o el envío ya no es actionable.
/// Si no existe el record, no hace nada.
Future<void> markSettled(String id) async {
final current = _cache[id];
if (current == null) return;
if (current.isSettled) return; // idempotente
final updated = current.copyWith(
status: PendingSendStatus.settled,
settledAt: DateTime.now(),
);
// Persistencia primero: si SQLite falla, la cache queda activa y un
// próximo reconcile reintenta. Con el orden inverso, la idempotencia
// (`isSettled → early return`) impediría el retry tras una falla.
await _db?.update(
_tableName,
updated.toMap(),
where: 'id = ?',
whereArgs: [id],
);
_cache[id] = updated;
_changesController.add(null);
}
/// Borrado permanente. Reservado para "dismiss manual" (no implementado
/// hoy en UI) o cleanup explícito del wallet (`clear`). Para el caso
/// "receptor reclamó", usar `markSettled` en vez de este.
Future<void> remove(String id) async {
// Persistencia primero: igual que add/markSettled, la cache no se
// adelanta al storage.
await _db?.delete(_tableName, where: 'id = ?', whereArgs: [id]);
_cache.remove(id);
_changesController.add(null);
}
List<PendingSend> listAll() {
final list = _cache.values.toList();
list.sort((a, b) => b.createdAt.compareTo(a.createdAt));
return list;
}
/// Envíos activos (el receptor aún no reclamó). Ordenados por creación
/// descendente. Renderizables en el historial con botón cancel.
List<PendingSend> listActive() {
return _cache.values.where((s) => s.isActive).toList()
..sort((a, b) => b.createdAt.compareTo(a.createdAt));
}
/// Envíos liquidados (el receptor ya reclamó). Ordenados por `settledAt`
/// descendente — representa la línea temporal de finalización.
List<PendingSend> listSettled() {
return _cache.values.where((s) => s.isSettled).toList()
..sort((a, b) => b.effectiveTimestamp.compareTo(a.effectiveTimestamp));
}
PendingSend? get(String id) => _cache[id];
/// Filtra por mint+unit. Útil para mostrar pendientes específicos del
/// wallet activo.
List<PendingSend> listByMintUnit(String mintUrl, String unit) {
return _cache.values
.where((s) => s.mintUrl == mintUrl && s.unit == unit)
.toList()
..sort((a, b) => b.createdAt.compareTo(a.createdAt));
}
Future<void> clear() async {
_cache.clear();
await _db?.delete(_tableName);
_changesController.add(null);
}
}
+29 -3
View File
@@ -63,8 +63,8 @@
"homeTitle": "Start",
"receive": "Empfangen",
"send": "Senden",
"sendAction": "Senden",
"receiveAction": "Empfangen",
"sendAction": "Senden",
"receiveAction": "Empfangen",
"deposit": "Einzahlen",
"withdraw": "Abheben",
"lightning": "Lightning",
@@ -86,6 +86,21 @@
"receiveCashu": "Cashu empfangen",
"pasteTheCashuToken": "Füge den Cashu Token ein:",
"pasteFromClipboard": "Aus Zwischenablage einfügen",
"emptyClipboard": "Zwischenablage leer",
"cancelSend": "Senden abbrechen",
"cancelSendConfirmTitle": "Diesen Versand abbrechen?",
"cancelSendConfirmBody": "Funktioniert nur, wenn der Empfänger das Token noch nicht eingelöst hat. Falls doch, wird der Versand als abgeschlossen markiert.",
"cancelSendSuccess": "Versand abgebrochen. {amount} {unit} zurückerhalten",
"@cancelSendSuccess": {
"placeholders": {
"amount": {"type": "String"},
"unit": {"type": "String"}
}
},
"cancelSendAlreadyClaimed": "Der Empfänger hat das Token bereits eingelöst",
"pendingOfflineSend": "Ausstehender Offline-Versand",
"pendingOfflineSendsHeader": "Nicht eingelöste Offline-Versendungen",
"pendingSendDetailTitle": "Ausstehender Versand",
"validToken": "Token gültig",
"invalidToken": "Ungültiger oder fehlerhafter Token",
"amount": "Betrag:",
@@ -118,6 +133,7 @@
"confirm": "Bestätigen",
"cancel": "Abbrechen",
"insufficientBalance": "Unzureichendes Guthaben",
"feeExceedsAmount": "Die Gebühr übersteigt den Sendebetrag",
"tokenCreationError": "Fehler beim Erstellen des Tokens: {error}",
"@tokenCreationError": {
"placeholders": {
@@ -227,6 +243,8 @@
"pendingStatus": "Ausstehend",
"receivedStatus": "Empfangen",
"sentStatus": "Gesendet",
"receiving": "Wird empfangen",
"sending": "Wird gesendet",
"now": "Jetzt",
"agoMinutes": "Vor {minutes} Min",
"@agoMinutes": {
@@ -573,5 +591,13 @@
"swapSuccess": "Swap abgeschlossen",
"swapErrorInsufficient": "Unzureichendes Guthaben",
"swapErrorExpired": "Angebot abgelaufen",
"swapErrorGeneric": "Swap-Fehler: {error}"
"swapErrorGeneric": "Swap-Fehler: {error}",
"swapChartUnavailable": "Preis nicht verfügbar · Tippen zum Wiederholen",
"swapChartMinMax": "24h Min: {minPrice} — Max: {maxPrice}",
"privacyPolicy": "Datenschutz",
"privacyTitle": "WIR SAMMELN NICHTS",
"privacyGoodbye": "TSCHÜSS",
"privacyKeepReading": "(lies weiter, wenn du willst…)",
"privacyBody": "Wir wissen nicht, wer du bist\nWir wissen nicht, wie viel du hast\nWir wissen nicht, was du tust",
"privacyConclusion": "Der beste Weg, deine Daten zu schützen,\nist sie nicht zu haben"
}
+29 -3
View File
@@ -53,8 +53,8 @@
"homeTitle": "Home",
"receive": "Receive",
"send": "Send",
"sendAction": "Send",
"receiveAction": "Receive",
"sendAction": "Send",
"receiveAction": "Receive",
"deposit": "Deposit",
"withdraw": "Withdraw",
"lightning": "Lightning",
@@ -76,6 +76,21 @@
"receiveCashu": "Receive Cashu",
"pasteTheCashuToken": "Paste the Cashu token:",
"pasteFromClipboard": "Paste from clipboard",
"emptyClipboard": "Clipboard is empty",
"cancelSend": "Cancel send",
"cancelSendConfirmTitle": "Cancel this send?",
"cancelSendConfirmBody": "Only works if the recipient has not claimed the token yet. If they already did, the send will be marked as completed.",
"cancelSendSuccess": "Send canceled. Recovered {amount} {unit}",
"@cancelSendSuccess": {
"placeholders": {
"amount": {"type": "String"},
"unit": {"type": "String"}
}
},
"cancelSendAlreadyClaimed": "The recipient already claimed the token",
"pendingOfflineSend": "Pending offline send",
"pendingOfflineSendsHeader": "Unclaimed offline sends",
"pendingSendDetailTitle": "Pending send",
"validToken": "Valid token",
"invalidToken": "Invalid or malformed token",
"amount": "Amount:",
@@ -103,6 +118,7 @@
"confirm": "Confirm",
"cancel": "Cancel",
"insufficientBalance": "Insufficient balance",
"feeExceedsAmount": "Fee exceeds the amount to send",
"tokenCreationError": "Error creating token: {error}",
"tokenCreated": "Token created",
@@ -193,6 +209,8 @@
"pendingStatus": "Pending",
"receivedStatus": "Received",
"sentStatus": "Sent",
"receiving": "Receiving",
"sending": "Sending",
"now": "Now",
"agoMinutes": "{minutes} min ago",
"agoHours": "{hours} h ago",
@@ -466,5 +484,13 @@
"swapSuccess": "Swap completed",
"swapErrorInsufficient": "Insufficient balance",
"swapErrorExpired": "Quote has expired",
"swapErrorGeneric": "Swap error: {error}"
"swapErrorGeneric": "Swap error: {error}",
"swapChartUnavailable": "Price unavailable · Tap to retry",
"swapChartMinMax": "24h Min: {minPrice} — Max: {maxPrice}",
"privacyPolicy": "Privacy policy",
"privacyTitle": "WE COLLECT NOTHING",
"privacyGoodbye": "BYE",
"privacyKeepReading": "(keep reading if you want…)",
"privacyBody": "We don't know who you are\nWe don't know how much you have\nWe don't know what you do",
"privacyConclusion": "The best way to protect your data\nis not to have it"
}
+35 -3
View File
@@ -63,8 +63,8 @@
"homeTitle": "Inicio",
"receive": "Recibir",
"send": "Enviar",
"sendAction": "Enviar",
"receiveAction": "Recibir",
"sendAction": "Enviar",
"receiveAction": "Recibir",
"deposit": "Depositar",
"withdraw": "Retirar",
"lightning": "Lightning",
@@ -86,6 +86,21 @@
"receiveCashu": "Recibir Cashu",
"pasteTheCashuToken": "Pega el token Cashu:",
"pasteFromClipboard": "Pegar del portapapeles",
"emptyClipboard": "Portapapeles vacío",
"cancelSend": "Cancelar envío",
"cancelSendConfirmTitle": "¿Cancelar envío?",
"cancelSendConfirmBody": "Solo funciona si el destinatario aún no reclamó el token. Si ya lo hizo, el envío se marcará como completado.",
"cancelSendSuccess": "Envío cancelado. Recuperaste {amount} {unit}",
"@cancelSendSuccess": {
"placeholders": {
"amount": {"type": "String"},
"unit": {"type": "String"}
}
},
"cancelSendAlreadyClaimed": "El destinatario ya reclamó el token",
"pendingOfflineSend": "Envío offline pendiente",
"pendingOfflineSendsHeader": "Envíos offline no reclamados",
"pendingSendDetailTitle": "Envío pendiente",
"validToken": "Token válido",
"invalidToken": "Token inválido o malformado",
"amount": "Monto:",
@@ -118,6 +133,7 @@
"confirm": "Confirmar",
"cancel": "Cancelar",
"insufficientBalance": "Balance insuficiente",
"feeExceedsAmount": "La comisión supera el monto a enviar",
"tokenCreationError": "Error al crear token: {error}",
"@tokenCreationError": {
"placeholders": {
@@ -235,6 +251,8 @@
"pendingStatus": "Pendiente",
"receivedStatus": "Recibido",
"sentStatus": "Enviado",
"receiving": "Recibiendo",
"sending": "Enviando",
"now": "Ahora",
"agoMinutes": "Hace {minutes} min",
"@agoMinutes": {
@@ -596,5 +614,19 @@
"placeholders": {
"error": { "type": "String" }
}
}
},
"swapChartUnavailable": "Precio no disponible · Toca para reintentar",
"swapChartMinMax": "24h Mín: {minPrice} — Máx: {maxPrice}",
"@swapChartMinMax": {
"placeholders": {
"minPrice": { "type": "String" },
"maxPrice": { "type": "String" }
}
},
"privacyPolicy": "Política de privacidad",
"privacyTitle": "NO RECOPILAMOS NADA",
"privacyGoodbye": "ADIÓS",
"privacyKeepReading": "(sigue leyendo si quieres…)",
"privacyBody": "No sabemos quién eres\nNo sabemos cuánto tienes\nNo sabemos qué haces",
"privacyConclusion": "La mejor forma de proteger tus datos\nes no tenerlos"
}
+29 -3
View File
@@ -63,8 +63,8 @@
"homeTitle": "Accueil",
"receive": "Recevoir",
"send": "Envoyer",
"sendAction": "Envoyer",
"receiveAction": "Recevoir",
"sendAction": "Envoyer",
"receiveAction": "Recevoir",
"deposit": "Déposer",
"withdraw": "Retirer",
"lightning": "Lightning",
@@ -86,6 +86,21 @@
"receiveCashu": "Recevoir Cashu",
"pasteTheCashuToken": "Collez le token Cashu :",
"pasteFromClipboard": "Coller depuis le presse-papiers",
"emptyClipboard": "Presse-papiers vide",
"cancelSend": "Annuler l'envoi",
"cancelSendConfirmTitle": "Annuler cet envoi ?",
"cancelSendConfirmBody": "Ne fonctionne que si le destinataire n'a pas encore réclamé le token. S'il l'a déjà fait, l'envoi sera marqué comme terminé.",
"cancelSendSuccess": "Envoi annulé. Récupéré {amount} {unit}",
"@cancelSendSuccess": {
"placeholders": {
"amount": {"type": "String"},
"unit": {"type": "String"}
}
},
"cancelSendAlreadyClaimed": "Le destinataire a déjà réclamé le token",
"pendingOfflineSend": "Envoi hors ligne en attente",
"pendingOfflineSendsHeader": "Envois hors ligne non réclamés",
"pendingSendDetailTitle": "Envoi en attente",
"validToken": "Token valide",
"invalidToken": "Token invalide ou malformé",
"amount": "Montant :",
@@ -118,6 +133,7 @@
"confirm": "Confirmer",
"cancel": "Annuler",
"insufficientBalance": "Solde insuffisant",
"feeExceedsAmount": "Les frais dépassent le montant à envoyer",
"tokenCreationError": "Erreur de création du token : {error}",
"@tokenCreationError": {
"placeholders": {
@@ -227,6 +243,8 @@
"pendingStatus": "En attente",
"receivedStatus": "Reçu",
"sentStatus": "Envoyé",
"receiving": "En réception",
"sending": "En envoi",
"now": "Maintenant",
"agoMinutes": "Il y a {minutes} min",
"@agoMinutes": {
@@ -573,5 +591,13 @@
"swapSuccess": "Swap terminé",
"swapErrorInsufficient": "Solde insuffisant",
"swapErrorExpired": "Le devis a expiré",
"swapErrorGeneric": "Erreur de swap : {error}"
"swapErrorGeneric": "Erreur de swap : {error}",
"swapChartUnavailable": "Prix indisponible · Appuyez pour réessayer",
"swapChartMinMax": "24h Min : {minPrice} — Max : {maxPrice}",
"privacyPolicy": "Politique de confidentialité",
"privacyTitle": "NOUS NE COLLECTONS RIEN",
"privacyGoodbye": "AU REVOIR",
"privacyKeepReading": "(continue à lire si tu veux…)",
"privacyBody": "Nous ne savons pas qui tu es\nNous ne savons pas combien tu as\nNous ne savons pas ce que tu fais",
"privacyConclusion": "La meilleure façon de protéger tes données,\nc'est de ne pas les avoir"
}
+29 -3
View File
@@ -63,8 +63,8 @@
"homeTitle": "Home",
"receive": "Ricevi",
"send": "Invia",
"sendAction": "Invia",
"receiveAction": "Ricevi",
"sendAction": "Invia",
"receiveAction": "Ricevi",
"deposit": "Deposita",
"withdraw": "Preleva",
"lightning": "Lightning",
@@ -86,6 +86,21 @@
"receiveCashu": "Ricevi Cashu",
"pasteTheCashuToken": "Incolla il token Cashu:",
"pasteFromClipboard": "Incolla dagli appunti",
"emptyClipboard": "Appunti vuoti",
"cancelSend": "Annulla invio",
"cancelSendConfirmTitle": "Annullare questo invio?",
"cancelSendConfirmBody": "Funziona solo se il destinatario non ha ancora riscattato il token. Se l'ha già fatto, l'invio sarà contrassegnato come completato.",
"cancelSendSuccess": "Invio annullato. Recuperati {amount} {unit}",
"@cancelSendSuccess": {
"placeholders": {
"amount": {"type": "String"},
"unit": {"type": "String"}
}
},
"cancelSendAlreadyClaimed": "Il destinatario ha già riscattato il token",
"pendingOfflineSend": "Invio offline in sospeso",
"pendingOfflineSendsHeader": "Invii offline non riscattati",
"pendingSendDetailTitle": "Invio in sospeso",
"validToken": "Token valido",
"invalidToken": "Token non valido o malformato",
"amount": "Importo:",
@@ -118,6 +133,7 @@
"confirm": "Conferma",
"cancel": "Annulla",
"insufficientBalance": "Saldo insufficiente",
"feeExceedsAmount": "La commissione supera l'importo da inviare",
"tokenCreationError": "Errore creazione token: {error}",
"@tokenCreationError": {
"placeholders": {
@@ -227,6 +243,8 @@
"pendingStatus": "In attesa",
"receivedStatus": "Ricevuto",
"sentStatus": "Inviato",
"receiving": "In ricezione",
"sending": "In invio",
"now": "Adesso",
"agoMinutes": "{minutes} min fa",
"@agoMinutes": {
@@ -573,5 +591,13 @@
"swapSuccess": "Swap completato",
"swapErrorInsufficient": "Saldo insufficiente",
"swapErrorExpired": "Il preventivo è scaduto",
"swapErrorGeneric": "Errore swap: {error}"
"swapErrorGeneric": "Errore swap: {error}",
"swapChartUnavailable": "Prezzo non disponibile · Tocca per riprovare",
"swapChartMinMax": "24h Min: {minPrice} — Max: {maxPrice}",
"privacyPolicy": "Informativa sulla privacy",
"privacyTitle": "NON RACCOGLIAMO NULLA",
"privacyGoodbye": "CIAO",
"privacyKeepReading": "(continua a leggere se vuoi…)",
"privacyBody": "Non sappiamo chi sei\nNon sappiamo quanto hai\nNon sappiamo cosa fai",
"privacyConclusion": "Il modo migliore per proteggere i tuoi dati\nè non averli"
}
+29 -3
View File
@@ -63,8 +63,8 @@
"homeTitle": "ホーム",
"receive": "受取",
"send": "送金",
"sendAction": "送金",
"receiveAction": "受取",
"sendAction": "送金",
"receiveAction": "受取",
"deposit": "入金",
"withdraw": "出金",
"lightning": "Lightning",
@@ -86,6 +86,21 @@
"receiveCashu": "Cashuを受け取る",
"pasteTheCashuToken": "Cashuトークンを貼り付け:",
"pasteFromClipboard": "クリップボードから貼り付け",
"emptyClipboard": "クリップボードは空です",
"cancelSend": "送金をキャンセル",
"cancelSendConfirmTitle": "この送金をキャンセルしますか?",
"cancelSendConfirmBody": "受取人がまだトークンを引き換えていない場合のみ機能します。既に引き換え済みの場合、送金は完了として記録されます。",
"cancelSendSuccess": "送金をキャンセルしました。{amount} {unit}を回復しました",
"@cancelSendSuccess": {
"placeholders": {
"amount": {"type": "String"},
"unit": {"type": "String"}
}
},
"cancelSendAlreadyClaimed": "受取人は既にトークンを引き換えました",
"pendingOfflineSend": "保留中のオフライン送金",
"pendingOfflineSendsHeader": "未請求のオフライン送金",
"pendingSendDetailTitle": "保留中の送金",
"validToken": "有効なトークン",
"invalidToken": "無効または不正なトークン",
"amount": "金額:",
@@ -118,6 +133,7 @@
"confirm": "確認",
"cancel": "キャンセル",
"insufficientBalance": "残高不足",
"feeExceedsAmount": "手数料が送金額を超えています",
"tokenCreationError": "トークン作成エラー:{error}",
"@tokenCreationError": {
"placeholders": {
@@ -227,6 +243,8 @@
"pendingStatus": "保留中",
"receivedStatus": "受取済み",
"sentStatus": "送金済み",
"receiving": "受取中",
"sending": "送金中",
"now": "たった今",
"agoMinutes": "{minutes}分前",
"@agoMinutes": {
@@ -573,5 +591,13 @@
"swapSuccess": "スワップ完了",
"swapErrorInsufficient": "残高不足",
"swapErrorExpired": "見積もりの有効期限切れ",
"swapErrorGeneric": "スワップエラー: {error}"
"swapErrorGeneric": "スワップエラー: {error}",
"swapChartUnavailable": "価格を取得できません・タップで再試行",
"swapChartMinMax": "24h 安値: {minPrice} — 高値: {maxPrice}",
"privacyPolicy": "プライバシーポリシー",
"privacyTitle": "何も収集しません",
"privacyGoodbye": "さようなら",
"privacyKeepReading": "(読み続けたければどうぞ…)",
"privacyBody": "あなたが誰か知りません\nいくら持っているか知りません\n何をしているか知りません",
"privacyConclusion": "データを守る最善の方法は\nデータを持たないことです"
}
+29 -3
View File
@@ -63,8 +63,8 @@
"homeTitle": "홈",
"receive": "받기",
"send": "보내기",
"sendAction": "보내기",
"receiveAction": "받기",
"sendAction": "보내기",
"receiveAction": "받기",
"deposit": "입금",
"withdraw": "출금",
"lightning": "Lightning",
@@ -86,6 +86,21 @@
"receiveCashu": "Cashu 받기",
"pasteTheCashuToken": "Cashu 토큰을 붙여넣으세요:",
"pasteFromClipboard": "클립보드에서 붙여넣기",
"emptyClipboard": "클립보드가 비어 있음",
"cancelSend": "전송 취소",
"cancelSendConfirmTitle": "이 전송을 취소하시겠습니까?",
"cancelSendConfirmBody": "수신자가 아직 토큰을 받지 않은 경우에만 작동합니다. 이미 받았다면 전송이 완료로 표시됩니다.",
"cancelSendSuccess": "전송이 취소되었습니다. {amount} {unit}를 복구했습니다",
"@cancelSendSuccess": {
"placeholders": {
"amount": {"type": "String"},
"unit": {"type": "String"}
}
},
"cancelSendAlreadyClaimed": "수신자가 이미 토큰을 받았습니다",
"pendingOfflineSend": "대기 중인 오프라인 전송",
"pendingOfflineSendsHeader": "청구되지 않은 오프라인 전송",
"pendingSendDetailTitle": "대기 중인 전송",
"validToken": "유효한 토큰",
"invalidToken": "유효하지 않거나 손상된 토큰",
"amount": "금액:",
@@ -118,6 +133,7 @@
"confirm": "확인",
"cancel": "취소",
"insufficientBalance": "잔액 부족",
"feeExceedsAmount": "수수료가 송금액을 초과합니다",
"tokenCreationError": "토큰 생성 오류: {error}",
"@tokenCreationError": {
"placeholders": {
@@ -227,6 +243,8 @@
"pendingStatus": "대기 중",
"receivedStatus": "받음",
"sentStatus": "보냄",
"receiving": "받는 중",
"sending": "보내는 중",
"now": "방금",
"agoMinutes": "{minutes}분 전",
"@agoMinutes": {
@@ -573,5 +591,13 @@
"swapSuccess": "교환 완료",
"swapErrorInsufficient": "잔액 부족",
"swapErrorExpired": "견적이 만료되었습니다",
"swapErrorGeneric": "교환 오류: {error}"
"swapErrorGeneric": "교환 오류: {error}",
"swapChartUnavailable": "가격 불러오기 실패 · 탭하여 재시도",
"swapChartMinMax": "24h 최저: {minPrice} — 최고: {maxPrice}",
"privacyPolicy": "개인정보 처리방침",
"privacyTitle": "우리는 아무것도 수집하지 않습니다",
"privacyGoodbye": "안녕",
"privacyKeepReading": "(계속 읽고 싶으면…)",
"privacyBody": "당신이 누구인지 모릅니다\n얼마를 가지고 있는지 모릅니다\n무엇을 하는지 모릅니다",
"privacyConclusion": "데이터를 보호하는 가장 좋은 방법은\n데이터를 갖지 않는 것입니다"
}
+122 -2
View File
@@ -388,13 +388,13 @@ abstract class L10n {
/// No description provided for @sendAction.
///
/// In es, this message translates to:
/// **'Enviar'**
/// **'Enviar'**
String get sendAction;
/// No description provided for @receiveAction.
///
/// In es, this message translates to:
/// **'Recibir'**
/// **'Recibir'**
String get receiveAction;
/// No description provided for @deposit.
@@ -505,6 +505,60 @@ abstract class L10n {
/// **'Pegar del portapapeles'**
String get pasteFromClipboard;
/// No description provided for @emptyClipboard.
///
/// In es, this message translates to:
/// **'Portapapeles vacío'**
String get emptyClipboard;
/// No description provided for @cancelSend.
///
/// In es, this message translates to:
/// **'Cancelar envío'**
String get cancelSend;
/// No description provided for @cancelSendConfirmTitle.
///
/// In es, this message translates to:
/// **'¿Cancelar envío?'**
String get cancelSendConfirmTitle;
/// No description provided for @cancelSendConfirmBody.
///
/// In es, this message translates to:
/// **'Solo funciona si el destinatario aún no reclamó el token. Si ya lo hizo, el envío se marcará como completado.'**
String get cancelSendConfirmBody;
/// No description provided for @cancelSendSuccess.
///
/// In es, this message translates to:
/// **'Envío cancelado. Recuperaste {amount} {unit}'**
String cancelSendSuccess(String amount, String unit);
/// No description provided for @cancelSendAlreadyClaimed.
///
/// In es, this message translates to:
/// **'El destinatario ya reclamó el token'**
String get cancelSendAlreadyClaimed;
/// No description provided for @pendingOfflineSend.
///
/// In es, this message translates to:
/// **'Envío offline pendiente'**
String get pendingOfflineSend;
/// No description provided for @pendingOfflineSendsHeader.
///
/// In es, this message translates to:
/// **'Envíos offline no reclamados'**
String get pendingOfflineSendsHeader;
/// No description provided for @pendingSendDetailTitle.
///
/// In es, this message translates to:
/// **'Envío pendiente'**
String get pendingSendDetailTitle;
/// No description provided for @validToken.
///
/// In es, this message translates to:
@@ -661,6 +715,12 @@ abstract class L10n {
/// **'Balance insuficiente'**
String get insufficientBalance;
/// No description provided for @feeExceedsAmount.
///
/// In es, this message translates to:
/// **'La comisión supera el monto a enviar'**
String get feeExceedsAmount;
/// No description provided for @tokenCreationError.
///
/// In es, this message translates to:
@@ -1075,6 +1135,18 @@ abstract class L10n {
/// **'Enviado'**
String get sentStatus;
/// No description provided for @receiving.
///
/// In es, this message translates to:
/// **'Recibiendo'**
String get receiving;
/// No description provided for @sending.
///
/// In es, this message translates to:
/// **'Enviando'**
String get sending;
/// No description provided for @now.
///
/// In es, this message translates to:
@@ -2430,6 +2502,54 @@ abstract class L10n {
/// In es, this message translates to:
/// **'Error en el swap: {error}'**
String swapErrorGeneric(String error);
/// No description provided for @swapChartUnavailable.
///
/// In es, this message translates to:
/// **'Precio no disponible · Toca para reintentar'**
String get swapChartUnavailable;
/// No description provided for @swapChartMinMax.
///
/// In es, this message translates to:
/// **'24h Mín: {minPrice} — Máx: {maxPrice}'**
String swapChartMinMax(String minPrice, String maxPrice);
/// No description provided for @privacyPolicy.
///
/// In es, this message translates to:
/// **'Política de privacidad'**
String get privacyPolicy;
/// No description provided for @privacyTitle.
///
/// In es, this message translates to:
/// **'NO RECOPILAMOS NADA'**
String get privacyTitle;
/// No description provided for @privacyGoodbye.
///
/// In es, this message translates to:
/// **'ADIÓS'**
String get privacyGoodbye;
/// No description provided for @privacyKeepReading.
///
/// In es, this message translates to:
/// **'(sigue leyendo si quieres…)'**
String get privacyKeepReading;
/// No description provided for @privacyBody.
///
/// In es, this message translates to:
/// **'No sabemos quién eres\nNo sabemos cuánto tienes\nNo sabemos qué haces'**
String get privacyBody;
/// No description provided for @privacyConclusion.
///
/// In es, this message translates to:
/// **'La mejor forma de proteger tus datos\nes no tenerlos'**
String get privacyConclusion;
}
class _L10nDelegate extends LocalizationsDelegate<L10n> {
+72 -2
View File
@@ -156,10 +156,10 @@ class L10nDe extends L10n {
String get send => 'Senden';
@override
String get sendAction => 'Senden ↗';
String get sendAction => 'Senden';
@override
String get receiveAction => '↘ Empfangen';
String get receiveAction => 'Empfangen';
@override
String get deposit => 'Einzahlen';
@@ -215,6 +215,38 @@ class L10nDe extends L10n {
@override
String get pasteFromClipboard => 'Aus Zwischenablage einfügen';
@override
String get emptyClipboard => 'Zwischenablage leer';
@override
String get cancelSend => 'Senden abbrechen';
@override
String get cancelSendConfirmTitle => 'Diesen Versand abbrechen?';
@override
String get cancelSendConfirmBody =>
'Funktioniert nur, wenn der Empfänger das Token noch nicht eingelöst hat. Falls doch, wird der Versand als abgeschlossen markiert.';
@override
String cancelSendSuccess(String amount, String unit) {
return 'Versand abgebrochen. $amount $unit zurückerhalten';
}
@override
String get cancelSendAlreadyClaimed =>
'Der Empfänger hat das Token bereits eingelöst';
@override
String get pendingOfflineSend => 'Ausstehender Offline-Versand';
@override
String get pendingOfflineSendsHeader =>
'Nicht eingelöste Offline-Versendungen';
@override
String get pendingSendDetailTitle => 'Ausstehender Versand';
@override
String get validToken => 'Token gültig';
@@ -295,6 +327,9 @@ class L10nDe extends L10n {
@override
String get insufficientBalance => 'Unzureichendes Guthaben';
@override
String get feeExceedsAmount => 'Die Gebühr übersteigt den Sendebetrag';
@override
String tokenCreationError(String error) {
return 'Fehler beim Erstellen des Tokens: $error';
@@ -523,6 +558,12 @@ class L10nDe extends L10n {
@override
String get sentStatus => 'Gesendet';
@override
String get receiving => 'Wird empfangen';
@override
String get sending => 'Wird gesendet';
@override
String get now => 'Jetzt';
@@ -1284,4 +1325,33 @@ class L10nDe extends L10n {
String swapErrorGeneric(String error) {
return 'Swap-Fehler: $error';
}
@override
String get swapChartUnavailable =>
'Preis nicht verfügbar · Tippen zum Wiederholen';
@override
String swapChartMinMax(String minPrice, String maxPrice) {
return '24h Min: $minPrice — Max: $maxPrice';
}
@override
String get privacyPolicy => 'Datenschutz';
@override
String get privacyTitle => 'WIR SAMMELN NICHTS';
@override
String get privacyGoodbye => 'TSCHÜSS';
@override
String get privacyKeepReading => '(lies weiter, wenn du willst…)';
@override
String get privacyBody =>
'Wir wissen nicht, wer du bist\nWir wissen nicht, wie viel du hast\nWir wissen nicht, was du tust';
@override
String get privacyConclusion =>
'Der beste Weg, deine Daten zu schützen,\nist sie nicht zu haben';
}
+70 -2
View File
@@ -153,10 +153,10 @@ class L10nEn extends L10n {
String get send => 'Send';
@override
String get sendAction => 'Send ↗';
String get sendAction => 'Send';
@override
String get receiveAction => '↘ Receive';
String get receiveAction => 'Receive';
@override
String get deposit => 'Deposit';
@@ -212,6 +212,37 @@ class L10nEn extends L10n {
@override
String get pasteFromClipboard => 'Paste from clipboard';
@override
String get emptyClipboard => 'Clipboard is empty';
@override
String get cancelSend => 'Cancel send';
@override
String get cancelSendConfirmTitle => 'Cancel this send?';
@override
String get cancelSendConfirmBody =>
'Only works if the recipient has not claimed the token yet. If they already did, the send will be marked as completed.';
@override
String cancelSendSuccess(String amount, String unit) {
return 'Send canceled. Recovered $amount $unit';
}
@override
String get cancelSendAlreadyClaimed =>
'The recipient already claimed the token';
@override
String get pendingOfflineSend => 'Pending offline send';
@override
String get pendingOfflineSendsHeader => 'Unclaimed offline sends';
@override
String get pendingSendDetailTitle => 'Pending send';
@override
String get validToken => 'Valid token';
@@ -292,6 +323,9 @@ class L10nEn extends L10n {
@override
String get insufficientBalance => 'Insufficient balance';
@override
String get feeExceedsAmount => 'Fee exceeds the amount to send';
@override
String tokenCreationError(String error) {
return 'Error creating token: $error';
@@ -517,6 +551,12 @@ class L10nEn extends L10n {
@override
String get sentStatus => 'Sent';
@override
String get receiving => 'Receiving';
@override
String get sending => 'Sending';
@override
String get now => 'Now';
@@ -1265,4 +1305,32 @@ class L10nEn extends L10n {
String swapErrorGeneric(String error) {
return 'Swap error: $error';
}
@override
String get swapChartUnavailable => 'Price unavailable · Tap to retry';
@override
String swapChartMinMax(String minPrice, String maxPrice) {
return '24h Min: $minPrice — Max: $maxPrice';
}
@override
String get privacyPolicy => 'Privacy policy';
@override
String get privacyTitle => 'WE COLLECT NOTHING';
@override
String get privacyGoodbye => 'BYE';
@override
String get privacyKeepReading => '(keep reading if you want…)';
@override
String get privacyBody =>
'We don\'t know who you are\nWe don\'t know how much you have\nWe don\'t know what you do';
@override
String get privacyConclusion =>
'The best way to protect your data\nis not to have it';
}
+70 -2
View File
@@ -153,10 +153,10 @@ class L10nEs extends L10n {
String get send => 'Enviar';
@override
String get sendAction => 'Enviar ↗';
String get sendAction => 'Enviar';
@override
String get receiveAction => '↘ Recibir';
String get receiveAction => 'Recibir';
@override
String get deposit => 'Depositar';
@@ -212,6 +212,36 @@ class L10nEs extends L10n {
@override
String get pasteFromClipboard => 'Pegar del portapapeles';
@override
String get emptyClipboard => 'Portapapeles vacío';
@override
String get cancelSend => 'Cancelar envío';
@override
String get cancelSendConfirmTitle => '¿Cancelar envío?';
@override
String get cancelSendConfirmBody =>
'Solo funciona si el destinatario aún no reclamó el token. Si ya lo hizo, el envío se marcará como completado.';
@override
String cancelSendSuccess(String amount, String unit) {
return 'Envío cancelado. Recuperaste $amount $unit';
}
@override
String get cancelSendAlreadyClaimed => 'El destinatario ya reclamó el token';
@override
String get pendingOfflineSend => 'Envío offline pendiente';
@override
String get pendingOfflineSendsHeader => 'Envíos offline no reclamados';
@override
String get pendingSendDetailTitle => 'Envío pendiente';
@override
String get validToken => 'Token válido';
@@ -292,6 +322,9 @@ class L10nEs extends L10n {
@override
String get insufficientBalance => 'Balance insuficiente';
@override
String get feeExceedsAmount => 'La comisión supera el monto a enviar';
@override
String tokenCreationError(String error) {
return 'Error al crear token: $error';
@@ -518,6 +551,12 @@ class L10nEs extends L10n {
@override
String get sentStatus => 'Enviado';
@override
String get receiving => 'Recibiendo';
@override
String get sending => 'Enviando';
@override
String get now => 'Ahora';
@@ -1273,4 +1312,33 @@ class L10nEs extends L10n {
String swapErrorGeneric(String error) {
return 'Error en el swap: $error';
}
@override
String get swapChartUnavailable =>
'Precio no disponible · Toca para reintentar';
@override
String swapChartMinMax(String minPrice, String maxPrice) {
return '24h Mín: $minPrice — Máx: $maxPrice';
}
@override
String get privacyPolicy => 'Política de privacidad';
@override
String get privacyTitle => 'NO RECOPILAMOS NADA';
@override
String get privacyGoodbye => 'ADIÓS';
@override
String get privacyKeepReading => '(sigue leyendo si quieres…)';
@override
String get privacyBody =>
'No sabemos quién eres\nNo sabemos cuánto tienes\nNo sabemos qué haces';
@override
String get privacyConclusion =>
'La mejor forma de proteger tus datos\nes no tenerlos';
}
+71 -2
View File
@@ -157,10 +157,10 @@ class L10nFr extends L10n {
String get send => 'Envoyer';
@override
String get sendAction => 'Envoyer ↗';
String get sendAction => 'Envoyer';
@override
String get receiveAction => '↘ Recevoir';
String get receiveAction => 'Recevoir';
@override
String get deposit => 'Déposer';
@@ -216,6 +216,37 @@ class L10nFr extends L10n {
@override
String get pasteFromClipboard => 'Coller depuis le presse-papiers';
@override
String get emptyClipboard => 'Presse-papiers vide';
@override
String get cancelSend => 'Annuler l\'envoi';
@override
String get cancelSendConfirmTitle => 'Annuler cet envoi ?';
@override
String get cancelSendConfirmBody =>
'Ne fonctionne que si le destinataire n\'a pas encore réclamé le token. S\'il l\'a déjà fait, l\'envoi sera marqué comme terminé.';
@override
String cancelSendSuccess(String amount, String unit) {
return 'Envoi annulé. Récupéré $amount $unit';
}
@override
String get cancelSendAlreadyClaimed =>
'Le destinataire a déjà réclamé le token';
@override
String get pendingOfflineSend => 'Envoi hors ligne en attente';
@override
String get pendingOfflineSendsHeader => 'Envois hors ligne non réclamés';
@override
String get pendingSendDetailTitle => 'Envoi en attente';
@override
String get validToken => 'Token valide';
@@ -296,6 +327,9 @@ class L10nFr extends L10n {
@override
String get insufficientBalance => 'Solde insuffisant';
@override
String get feeExceedsAmount => 'Les frais dépassent le montant à envoyer';
@override
String tokenCreationError(String error) {
return 'Erreur de création du token : $error';
@@ -524,6 +558,12 @@ class L10nFr extends L10n {
@override
String get sentStatus => 'Envoyé';
@override
String get receiving => 'En réception';
@override
String get sending => 'En envoi';
@override
String get now => 'Maintenant';
@@ -1289,4 +1329,33 @@ class L10nFr extends L10n {
String swapErrorGeneric(String error) {
return 'Erreur de swap : $error';
}
@override
String get swapChartUnavailable =>
'Prix indisponible · Appuyez pour réessayer';
@override
String swapChartMinMax(String minPrice, String maxPrice) {
return '24h Min : $minPrice — Max : $maxPrice';
}
@override
String get privacyPolicy => 'Politique de confidentialité';
@override
String get privacyTitle => 'NOUS NE COLLECTONS RIEN';
@override
String get privacyGoodbye => 'AU REVOIR';
@override
String get privacyKeepReading => '(continue à lire si tu veux…)';
@override
String get privacyBody =>
'Nous ne savons pas qui tu es\nNous ne savons pas combien tu as\nNous ne savons pas ce que tu fais';
@override
String get privacyConclusion =>
'La meilleure façon de protéger tes données,\nc\'est de ne pas les avoir';
}
+71 -2
View File
@@ -154,10 +154,10 @@ class L10nIt extends L10n {
String get send => 'Invia';
@override
String get sendAction => 'Invia ↗';
String get sendAction => 'Invia';
@override
String get receiveAction => '↘ Ricevi';
String get receiveAction => 'Ricevi';
@override
String get deposit => 'Deposita';
@@ -213,6 +213,37 @@ class L10nIt extends L10n {
@override
String get pasteFromClipboard => 'Incolla dagli appunti';
@override
String get emptyClipboard => 'Appunti vuoti';
@override
String get cancelSend => 'Annulla invio';
@override
String get cancelSendConfirmTitle => 'Annullare questo invio?';
@override
String get cancelSendConfirmBody =>
'Funziona solo se il destinatario non ha ancora riscattato il token. Se l\'ha già fatto, l\'invio sarà contrassegnato come completato.';
@override
String cancelSendSuccess(String amount, String unit) {
return 'Invio annullato. Recuperati $amount $unit';
}
@override
String get cancelSendAlreadyClaimed =>
'Il destinatario ha già riscattato il token';
@override
String get pendingOfflineSend => 'Invio offline in sospeso';
@override
String get pendingOfflineSendsHeader => 'Invii offline non riscattati';
@override
String get pendingSendDetailTitle => 'Invio in sospeso';
@override
String get validToken => 'Token valido';
@@ -293,6 +324,9 @@ class L10nIt extends L10n {
@override
String get insufficientBalance => 'Saldo insufficiente';
@override
String get feeExceedsAmount => 'La commissione supera l\'importo da inviare';
@override
String tokenCreationError(String error) {
return 'Errore creazione token: $error';
@@ -519,6 +553,12 @@ class L10nIt extends L10n {
@override
String get sentStatus => 'Inviato';
@override
String get receiving => 'In ricezione';
@override
String get sending => 'In invio';
@override
String get now => 'Adesso';
@@ -1277,4 +1317,33 @@ class L10nIt extends L10n {
String swapErrorGeneric(String error) {
return 'Errore swap: $error';
}
@override
String get swapChartUnavailable =>
'Prezzo non disponibile · Tocca per riprovare';
@override
String swapChartMinMax(String minPrice, String maxPrice) {
return '24h Min: $minPrice — Max: $maxPrice';
}
@override
String get privacyPolicy => 'Informativa sulla privacy';
@override
String get privacyTitle => 'NON RACCOGLIAMO NULLA';
@override
String get privacyGoodbye => 'CIAO';
@override
String get privacyKeepReading => '(continua a leggere se vuoi…)';
@override
String get privacyBody =>
'Non sappiamo chi sei\nNon sappiamo quanto hai\nNon sappiamo cosa fai';
@override
String get privacyConclusion =>
'Il modo migliore per proteggere i tuoi dati\nè non averli';
}
+67 -2
View File
@@ -149,10 +149,10 @@ class L10nJa extends L10n {
String get send => '送金';
@override
String get sendAction => '送金';
String get sendAction => '送金';
@override
String get receiveAction => '受取';
String get receiveAction => '受取';
@override
String get deposit => '入金';
@@ -208,6 +208,36 @@ class L10nJa extends L10n {
@override
String get pasteFromClipboard => 'クリップボードから貼り付け';
@override
String get emptyClipboard => 'クリップボードは空です';
@override
String get cancelSend => '送金をキャンセル';
@override
String get cancelSendConfirmTitle => 'この送金をキャンセルしますか?';
@override
String get cancelSendConfirmBody =>
'受取人がまだトークンを引き換えていない場合のみ機能します。既に引き換え済みの場合、送金は完了として記録されます。';
@override
String cancelSendSuccess(String amount, String unit) {
return '送金をキャンセルしました。$amount $unitを回復しました';
}
@override
String get cancelSendAlreadyClaimed => '受取人は既にトークンを引き換えました';
@override
String get pendingOfflineSend => '保留中のオフライン送金';
@override
String get pendingOfflineSendsHeader => '未請求のオフライン送金';
@override
String get pendingSendDetailTitle => '保留中の送金';
@override
String get validToken => '有効なトークン';
@@ -288,6 +318,9 @@ class L10nJa extends L10n {
@override
String get insufficientBalance => '残高不足';
@override
String get feeExceedsAmount => '手数料が送金額を超えています';
@override
String tokenCreationError(String error) {
return 'トークン作成エラー:$error';
@@ -511,6 +544,12 @@ class L10nJa extends L10n {
@override
String get sentStatus => '送金済み';
@override
String get receiving => '受取中';
@override
String get sending => '送金中';
@override
String get now => 'たった今';
@@ -1250,4 +1289,30 @@ class L10nJa extends L10n {
String swapErrorGeneric(String error) {
return 'スワップエラー: $error';
}
@override
String get swapChartUnavailable => '価格を取得できません・タップで再試行';
@override
String swapChartMinMax(String minPrice, String maxPrice) {
return '24h 安値: $minPrice — 高値: $maxPrice';
}
@override
String get privacyPolicy => 'プライバシーポリシー';
@override
String get privacyTitle => '何も収集しません';
@override
String get privacyGoodbye => 'さようなら';
@override
String get privacyKeepReading => '(読み続けたければどうぞ…)';
@override
String get privacyBody => 'あなたが誰か知りません\nいくら持っているか知りません\n何をしているか知りません';
@override
String get privacyConclusion => 'データを守る最善の方法は\nデータを持たないことです';
}
+67 -2
View File
@@ -151,10 +151,10 @@ class L10nKo extends L10n {
String get send => '보내기';
@override
String get sendAction => '보내기';
String get sendAction => '보내기';
@override
String get receiveAction => '받기';
String get receiveAction => '받기';
@override
String get deposit => '입금';
@@ -210,6 +210,36 @@ class L10nKo extends L10n {
@override
String get pasteFromClipboard => '클립보드에서 붙여넣기';
@override
String get emptyClipboard => '클립보드가 비어 있음';
@override
String get cancelSend => '전송 취소';
@override
String get cancelSendConfirmTitle => '이 전송을 취소하시겠습니까?';
@override
String get cancelSendConfirmBody =>
'수신자가 아직 토큰을 받지 않은 경우에만 작동합니다. 이미 받았다면 전송이 완료로 표시됩니다.';
@override
String cancelSendSuccess(String amount, String unit) {
return '전송이 취소되었습니다. $amount $unit를 복구했습니다';
}
@override
String get cancelSendAlreadyClaimed => '수신자가 이미 토큰을 받았습니다';
@override
String get pendingOfflineSend => '대기 중인 오프라인 전송';
@override
String get pendingOfflineSendsHeader => '청구되지 않은 오프라인 전송';
@override
String get pendingSendDetailTitle => '대기 중인 전송';
@override
String get validToken => '유효한 토큰';
@@ -290,6 +320,9 @@ class L10nKo extends L10n {
@override
String get insufficientBalance => '잔액 부족';
@override
String get feeExceedsAmount => '수수료가 송금액을 초과합니다';
@override
String tokenCreationError(String error) {
return '토큰 생성 오류: $error';
@@ -513,6 +546,12 @@ class L10nKo extends L10n {
@override
String get sentStatus => '보냄';
@override
String get receiving => '받는 중';
@override
String get sending => '보내는 중';
@override
String get now => '방금';
@@ -1252,4 +1291,30 @@ class L10nKo extends L10n {
String swapErrorGeneric(String error) {
return '교환 오류: $error';
}
@override
String get swapChartUnavailable => '가격 불러오기 실패 · 탭하여 재시도';
@override
String swapChartMinMax(String minPrice, String maxPrice) {
return '24h 최저: $minPrice — 최고: $maxPrice';
}
@override
String get privacyPolicy => '개인정보 처리방침';
@override
String get privacyTitle => '우리는 아무것도 수집하지 않습니다';
@override
String get privacyGoodbye => '안녕';
@override
String get privacyKeepReading => '(계속 읽고 싶으면…)';
@override
String get privacyBody => '당신이 누구인지 모릅니다\n얼마를 가지고 있는지 모릅니다\n무엇을 하는지 모릅니다';
@override
String get privacyConclusion => '데이터를 보호하는 가장 좋은 방법은\n데이터를 갖지 않는 것입니다';
}
+70 -2
View File
@@ -154,10 +154,10 @@ class L10nPt extends L10n {
String get send => 'Enviar';
@override
String get sendAction => 'Enviar ↗';
String get sendAction => 'Enviar';
@override
String get receiveAction => '↘ Receber';
String get receiveAction => 'Receber';
@override
String get deposit => 'Depositar';
@@ -213,6 +213,36 @@ class L10nPt extends L10n {
@override
String get pasteFromClipboard => 'Colar da área de transferência';
@override
String get emptyClipboard => 'Área de transferência vazia';
@override
String get cancelSend => 'Cancelar envio';
@override
String get cancelSendConfirmTitle => 'Cancelar este envio?';
@override
String get cancelSendConfirmBody =>
'Só funciona se o destinatário ainda não resgatou o token. Se já resgatou, o envio será marcado como concluído.';
@override
String cancelSendSuccess(String amount, String unit) {
return 'Envio cancelado. Recuperou $amount $unit';
}
@override
String get cancelSendAlreadyClaimed => 'O destinatário já resgatou o token';
@override
String get pendingOfflineSend => 'Envio offline pendente';
@override
String get pendingOfflineSendsHeader => 'Envios offline não resgatados';
@override
String get pendingSendDetailTitle => 'Envio pendente';
@override
String get validToken => 'Token válido';
@@ -293,6 +323,9 @@ class L10nPt extends L10n {
@override
String get insufficientBalance => 'Saldo insuficiente';
@override
String get feeExceedsAmount => 'A taxa excede o valor a enviar';
@override
String tokenCreationError(String error) {
return 'Erro ao criar token: $error';
@@ -521,6 +554,12 @@ class L10nPt extends L10n {
@override
String get sentStatus => 'Enviado';
@override
String get receiving => 'Recebendo';
@override
String get sending => 'Enviando';
@override
String get now => 'Agora';
@@ -1277,4 +1316,33 @@ class L10nPt extends L10n {
String swapErrorGeneric(String error) {
return 'Erro no swap: $error';
}
@override
String get swapChartUnavailable =>
'Preço indisponível · Toque para tentar novamente';
@override
String swapChartMinMax(String minPrice, String maxPrice) {
return '24h Mín: $minPrice — Máx: $maxPrice';
}
@override
String get privacyPolicy => 'Política de privacidade';
@override
String get privacyTitle => 'NÃO RECOLHEMOS NADA';
@override
String get privacyGoodbye => 'ADEUS';
@override
String get privacyKeepReading => '(continue lendo se quiser…)';
@override
String get privacyBody =>
'Não sabemos quem você é\nNão sabemos quanto você tem\nNão sabemos o que você faz';
@override
String get privacyConclusion =>
'A melhor forma de proteger seus dados\né não tê-los';
}
+69 -2
View File
@@ -153,10 +153,10 @@ class L10nRu extends L10n {
String get send => 'Отправить';
@override
String get sendAction => 'Отправить';
String get sendAction => 'Отправить';
@override
String get receiveAction => 'Получить';
String get receiveAction => 'Получить';
@override
String get deposit => 'Пополнить';
@@ -212,6 +212,36 @@ class L10nRu extends L10n {
@override
String get pasteFromClipboard => 'Вставить из буфера обмена';
@override
String get emptyClipboard => 'Буфер обмена пуст';
@override
String get cancelSend => 'Отменить отправку';
@override
String get cancelSendConfirmTitle => 'Отменить эту отправку?';
@override
String get cancelSendConfirmBody =>
'Работает только если получатель еще не получил токен. Если уже получил, отправка будет помечена как завершенная.';
@override
String cancelSendSuccess(String amount, String unit) {
return 'Отправка отменена. Восстановлено $amount $unit';
}
@override
String get cancelSendAlreadyClaimed => 'Получатель уже получил токен';
@override
String get pendingOfflineSend => 'Ожидающая оффлайн-отправка';
@override
String get pendingOfflineSendsHeader => 'Неполученные оффлайн-отправки';
@override
String get pendingSendDetailTitle => 'Ожидающая отправка';
@override
String get validToken => 'Токен действителен';
@@ -292,6 +322,9 @@ class L10nRu extends L10n {
@override
String get insufficientBalance => 'Недостаточный баланс';
@override
String get feeExceedsAmount => 'Комиссия превышает сумму отправки';
@override
String tokenCreationError(String error) {
return 'Ошибка создания токена: $error';
@@ -519,6 +552,12 @@ class L10nRu extends L10n {
@override
String get sentStatus => 'Отправлено';
@override
String get receiving => 'Получение';
@override
String get sending => 'Отправка';
@override
String get now => 'Сейчас';
@@ -1273,4 +1312,32 @@ class L10nRu extends L10n {
String swapErrorGeneric(String error) {
return 'Ошибка обмена: $error';
}
@override
String get swapChartUnavailable => 'Цена недоступна · Нажмите для повтора';
@override
String swapChartMinMax(String minPrice, String maxPrice) {
return '24h Мин: $minPrice — Макс: $maxPrice';
}
@override
String get privacyPolicy => 'Политика конфиденциальности';
@override
String get privacyTitle => 'МЫ НЕ СОБИРАЕМ НИЧЕГО';
@override
String get privacyGoodbye => 'ПОКА';
@override
String get privacyKeepReading => '(читай дальше, если хочешь…)';
@override
String get privacyBody =>
'Мы не знаем, кто ты\nМы не знаем, сколько у тебя\nМы не знаем, что ты делаешь';
@override
String get privacyConclusion =>
'Лучший способ защитить твои данные —\nне иметь их';
}
+69 -2
View File
@@ -153,10 +153,10 @@ class L10nSw extends L10n {
String get send => 'Tuma';
@override
String get sendAction => 'Tuma ↗';
String get sendAction => 'Tuma';
@override
String get receiveAction => '↘ Pokea';
String get receiveAction => 'Pokea';
@override
String get deposit => 'Weka';
@@ -212,6 +212,36 @@ class L10nSw extends L10n {
@override
String get pasteFromClipboard => 'Bandika kutoka ubao';
@override
String get emptyClipboard => 'Ubao wa kunakili ni tupu';
@override
String get cancelSend => 'Ghairi kutuma';
@override
String get cancelSendConfirmTitle => 'Ghairi kutuma huku?';
@override
String get cancelSendConfirmBody =>
'Hufanya kazi tu ikiwa mpokeaji hajatumia tokeni. Ikiwa tayari alitumia, kutuma kutaonyeshwa kama kumekamilika.';
@override
String cancelSendSuccess(String amount, String unit) {
return 'Kutuma kumeghairiwa. Umerudisha $amount $unit';
}
@override
String get cancelSendAlreadyClaimed => 'Mpokeaji tayari alitumia tokeni';
@override
String get pendingOfflineSend => 'Kutuma nje ya mtandao kunasubiri';
@override
String get pendingOfflineSendsHeader => 'Kutuma nje ya mtandao bila kudaiwa';
@override
String get pendingSendDetailTitle => 'Kutuma kunakusubiri';
@override
String get validToken => 'Tokeni halali';
@@ -293,6 +323,9 @@ class L10nSw extends L10n {
@override
String get insufficientBalance => 'Salio halitoshi';
@override
String get feeExceedsAmount => 'Ada inazidi kiasi cha kutuma';
@override
String tokenCreationError(String error) {
return 'Hitilafu ya kuunda tokeni: $error';
@@ -518,6 +551,12 @@ class L10nSw extends L10n {
@override
String get sentStatus => 'Imetumwa';
@override
String get receiving => 'Inapokea';
@override
String get sending => 'Inatumwa';
@override
String get now => 'Sasa';
@@ -1274,4 +1313,32 @@ class L10nSw extends L10n {
String swapErrorGeneric(String error) {
return 'Kosa la ubadilishaji: $error';
}
@override
String get swapChartUnavailable => 'Bei haipatikani · Gusa kurudia';
@override
String swapChartMinMax(String minPrice, String maxPrice) {
return '24h Chini: $minPrice — Juu: $maxPrice';
}
@override
String get privacyPolicy => 'Sera ya faragha';
@override
String get privacyTitle => 'HATUKUSANYI CHOCHOTE';
@override
String get privacyGoodbye => 'KWAHERI';
@override
String get privacyKeepReading => '(endelea kusoma ukitaka…)';
@override
String get privacyBody =>
'Hatujui wewe ni nani\nHatujui una kiasi gani\nHatujui unafanya nini';
@override
String get privacyConclusion =>
'Njia bora ya kulinda data yako\nni kutokuwa nayo';
}
+66 -2
View File
@@ -148,10 +148,10 @@ class L10nZh extends L10n {
String get send => '付款';
@override
String get sendAction => '付款';
String get sendAction => '付款';
@override
String get receiveAction => '收款';
String get receiveAction => '收款';
@override
String get deposit => '存入';
@@ -207,6 +207,35 @@ class L10nZh extends L10n {
@override
String get pasteFromClipboard => '从剪贴板粘贴';
@override
String get emptyClipboard => '剪贴板为空';
@override
String get cancelSend => '取消发送';
@override
String get cancelSendConfirmTitle => '取消此次发送?';
@override
String get cancelSendConfirmBody => '仅当接收方尚未领取代币时有效。如已领取,发送将标记为已完成。';
@override
String cancelSendSuccess(String amount, String unit) {
return '发送已取消。已恢复 $amount $unit';
}
@override
String get cancelSendAlreadyClaimed => '接收方已领取代币';
@override
String get pendingOfflineSend => '待处理的离线发送';
@override
String get pendingOfflineSendsHeader => '未领取的离线发送';
@override
String get pendingSendDetailTitle => '待处理发送';
@override
String get validToken => '有效代币';
@@ -287,6 +316,9 @@ class L10nZh extends L10n {
@override
String get insufficientBalance => '余额不足';
@override
String get feeExceedsAmount => '手续费超过发送金额';
@override
String tokenCreationError(String error) {
return '创建代币错误:$error';
@@ -510,6 +542,12 @@ class L10nZh extends L10n {
@override
String get sentStatus => '已发送';
@override
String get receiving => '接收中';
@override
String get sending => '发送中';
@override
String get now => '刚刚';
@@ -1245,4 +1283,30 @@ class L10nZh extends L10n {
String swapErrorGeneric(String error) {
return '兑换错误: $error';
}
@override
String get swapChartUnavailable => '价格不可用 · 点击重试';
@override
String swapChartMinMax(String minPrice, String maxPrice) {
return '24h 最低: $minPrice — 最高: $maxPrice';
}
@override
String get privacyPolicy => '隐私政策';
@override
String get privacyTitle => '我们什么都不收集';
@override
String get privacyGoodbye => '再见';
@override
String get privacyKeepReading => '(想继续看就看吧…)';
@override
String get privacyBody => '我们不知道你是谁\n我们不知道你有多少\n我们不知道你在做什么';
@override
String get privacyConclusion => '保护数据的最好方式\n就是不拥有它';
}
+29 -3
View File
@@ -63,8 +63,8 @@
"homeTitle": "Início",
"receive": "Receber",
"send": "Enviar",
"sendAction": "Enviar",
"receiveAction": "Receber",
"sendAction": "Enviar",
"receiveAction": "Receber",
"deposit": "Depositar",
"withdraw": "Sacar",
"lightning": "Lightning",
@@ -86,6 +86,21 @@
"receiveCashu": "Receber Cashu",
"pasteTheCashuToken": "Cole o token Cashu:",
"pasteFromClipboard": "Colar da área de transferência",
"emptyClipboard": "Área de transferência vazia",
"cancelSend": "Cancelar envio",
"cancelSendConfirmTitle": "Cancelar este envio?",
"cancelSendConfirmBody": "Só funciona se o destinatário ainda não resgatou o token. Se já resgatou, o envio será marcado como concluído.",
"cancelSendSuccess": "Envio cancelado. Recuperou {amount} {unit}",
"@cancelSendSuccess": {
"placeholders": {
"amount": {"type": "String"},
"unit": {"type": "String"}
}
},
"cancelSendAlreadyClaimed": "O destinatário já resgatou o token",
"pendingOfflineSend": "Envio offline pendente",
"pendingOfflineSendsHeader": "Envios offline não resgatados",
"pendingSendDetailTitle": "Envio pendente",
"validToken": "Token válido",
"invalidToken": "Token inválido ou malformado",
"amount": "Valor:",
@@ -118,6 +133,7 @@
"confirm": "Confirmar",
"cancel": "Cancelar",
"insufficientBalance": "Saldo insuficiente",
"feeExceedsAmount": "A taxa excede o valor a enviar",
"tokenCreationError": "Erro ao criar token: {error}",
"@tokenCreationError": {
"placeholders": {
@@ -227,6 +243,8 @@
"pendingStatus": "Pendente",
"receivedStatus": "Recebido",
"sentStatus": "Enviado",
"receiving": "Recebendo",
"sending": "Enviando",
"now": "Agora",
"agoMinutes": "Há {minutes} min",
"@agoMinutes": {
@@ -573,5 +591,13 @@
"swapSuccess": "Swap concluído",
"swapErrorInsufficient": "Saldo insuficiente",
"swapErrorExpired": "A cotação expirou",
"swapErrorGeneric": "Erro no swap: {error}"
"swapErrorGeneric": "Erro no swap: {error}",
"swapChartUnavailable": "Preço indisponível · Toque para tentar novamente",
"swapChartMinMax": "24h Mín: {minPrice} — Máx: {maxPrice}",
"privacyPolicy": "Política de privacidade",
"privacyTitle": "NÃO RECOLHEMOS NADA",
"privacyGoodbye": "ADEUS",
"privacyKeepReading": "(continue lendo se quiser…)",
"privacyBody": "Não sabemos quem você é\nNão sabemos quanto você tem\nNão sabemos o que você faz",
"privacyConclusion": "A melhor forma de proteger seus dados\né não tê-los"
}
+29 -3
View File
@@ -63,8 +63,8 @@
"homeTitle": "Главная",
"receive": "Получить",
"send": "Отправить",
"sendAction": "Отправить",
"receiveAction": "Получить",
"sendAction": "Отправить",
"receiveAction": "Получить",
"deposit": "Пополнить",
"withdraw": "Вывести",
"lightning": "Lightning",
@@ -86,6 +86,21 @@
"receiveCashu": "Получить Cashu",
"pasteTheCashuToken": "Вставьте Cashu токен:",
"pasteFromClipboard": "Вставить из буфера обмена",
"emptyClipboard": "Буфер обмена пуст",
"cancelSend": "Отменить отправку",
"cancelSendConfirmTitle": "Отменить эту отправку?",
"cancelSendConfirmBody": "Работает только если получатель еще не получил токен. Если уже получил, отправка будет помечена как завершенная.",
"cancelSendSuccess": "Отправка отменена. Восстановлено {amount} {unit}",
"@cancelSendSuccess": {
"placeholders": {
"amount": {"type": "String"},
"unit": {"type": "String"}
}
},
"cancelSendAlreadyClaimed": "Получатель уже получил токен",
"pendingOfflineSend": "Ожидающая оффлайн-отправка",
"pendingOfflineSendsHeader": "Неполученные оффлайн-отправки",
"pendingSendDetailTitle": "Ожидающая отправка",
"validToken": "Токен действителен",
"invalidToken": "Недействительный или повреждённый токен",
"amount": "Сумма:",
@@ -118,6 +133,7 @@
"confirm": "Подтвердить",
"cancel": "Отмена",
"insufficientBalance": "Недостаточный баланс",
"feeExceedsAmount": "Комиссия превышает сумму отправки",
"tokenCreationError": "Ошибка создания токена: {error}",
"@tokenCreationError": {
"placeholders": {
@@ -227,6 +243,8 @@
"pendingStatus": "Ожидание",
"receivedStatus": "Получено",
"sentStatus": "Отправлено",
"receiving": "Получение",
"sending": "Отправка",
"now": "Сейчас",
"agoMinutes": "{minutes} мин назад",
"@agoMinutes": {
@@ -573,5 +591,13 @@
"swapSuccess": "Обмен завершён",
"swapErrorInsufficient": "Недостаточный баланс",
"swapErrorExpired": "Котировка истекла",
"swapErrorGeneric": "Ошибка обмена: {error}"
"swapErrorGeneric": "Ошибка обмена: {error}",
"swapChartUnavailable": "Цена недоступна · Нажмите для повтора",
"swapChartMinMax": "24h Мин: {minPrice} — Макс: {maxPrice}",
"privacyPolicy": "Политика конфиденциальности",
"privacyTitle": "МЫ НЕ СОБИРАЕМ НИЧЕГО",
"privacyGoodbye": "ПОКА",
"privacyKeepReading": "(читай дальше, если хочешь…)",
"privacyBody": "Мы не знаем, кто ты\nМы не знаем, сколько у тебя\nМы не знаем, что ты делаешь",
"privacyConclusion": "Лучший способ защитить твои данные —\nне иметь их"
}
+29 -3
View File
@@ -63,8 +63,8 @@
"homeTitle": "Nyumbani",
"receive": "Pokea",
"send": "Tuma",
"sendAction": "Tuma",
"receiveAction": "Pokea",
"sendAction": "Tuma",
"receiveAction": "Pokea",
"deposit": "Weka",
"withdraw": "Toa",
"lightning": "Lightning",
@@ -86,6 +86,21 @@
"receiveCashu": "Pokea Cashu",
"pasteTheCashuToken": "Bandika tokeni ya Cashu:",
"pasteFromClipboard": "Bandika kutoka ubao",
"emptyClipboard": "Ubao wa kunakili ni tupu",
"cancelSend": "Ghairi kutuma",
"cancelSendConfirmTitle": "Ghairi kutuma huku?",
"cancelSendConfirmBody": "Hufanya kazi tu ikiwa mpokeaji hajatumia tokeni. Ikiwa tayari alitumia, kutuma kutaonyeshwa kama kumekamilika.",
"cancelSendSuccess": "Kutuma kumeghairiwa. Umerudisha {amount} {unit}",
"@cancelSendSuccess": {
"placeholders": {
"amount": {"type": "String"},
"unit": {"type": "String"}
}
},
"cancelSendAlreadyClaimed": "Mpokeaji tayari alitumia tokeni",
"pendingOfflineSend": "Kutuma nje ya mtandao kunasubiri",
"pendingOfflineSendsHeader": "Kutuma nje ya mtandao bila kudaiwa",
"pendingSendDetailTitle": "Kutuma kunakusubiri",
"validToken": "Tokeni halali",
"invalidToken": "Tokeni batili au imeharibika",
"amount": "Kiasi:",
@@ -118,6 +133,7 @@
"confirm": "Thibitisha",
"cancel": "Ghairi",
"insufficientBalance": "Salio halitoshi",
"feeExceedsAmount": "Ada inazidi kiasi cha kutuma",
"tokenCreationError": "Hitilafu ya kuunda tokeni: {error}",
"@tokenCreationError": {
"placeholders": {
@@ -227,6 +243,8 @@
"pendingStatus": "Inasubiri",
"receivedStatus": "Imepokelewa",
"sentStatus": "Imetumwa",
"receiving": "Inapokea",
"sending": "Inatumwa",
"now": "Sasa",
"agoMinutes": "Dakika {minutes} zilizopita",
"@agoMinutes": {
@@ -573,5 +591,13 @@
"swapSuccess": "Ubadilishaji umekamilika",
"swapErrorInsufficient": "Salio haitoshi",
"swapErrorExpired": "Bei imeisha muda",
"swapErrorGeneric": "Kosa la ubadilishaji: {error}"
"swapErrorGeneric": "Kosa la ubadilishaji: {error}",
"swapChartUnavailable": "Bei haipatikani · Gusa kurudia",
"swapChartMinMax": "24h Chini: {minPrice} — Juu: {maxPrice}",
"privacyPolicy": "Sera ya faragha",
"privacyTitle": "HATUKUSANYI CHOCHOTE",
"privacyGoodbye": "KWAHERI",
"privacyKeepReading": "(endelea kusoma ukitaka…)",
"privacyBody": "Hatujui wewe ni nani\nHatujui una kiasi gani\nHatujui unafanya nini",
"privacyConclusion": "Njia bora ya kulinda data yako\nni kutokuwa nayo"
}
+29 -3
View File
@@ -63,8 +63,8 @@
"homeTitle": "首页",
"receive": "收款",
"send": "付款",
"sendAction": "付款",
"receiveAction": "收款",
"sendAction": "付款",
"receiveAction": "收款",
"deposit": "存入",
"withdraw": "提取",
"lightning": "闪电网络",
@@ -86,6 +86,21 @@
"receiveCashu": "接收 Cashu",
"pasteTheCashuToken": "粘贴 Cashu 代币:",
"pasteFromClipboard": "从剪贴板粘贴",
"emptyClipboard": "剪贴板为空",
"cancelSend": "取消发送",
"cancelSendConfirmTitle": "取消此次发送?",
"cancelSendConfirmBody": "仅当接收方尚未领取代币时有效。如已领取,发送将标记为已完成。",
"cancelSendSuccess": "发送已取消。已恢复 {amount} {unit}",
"@cancelSendSuccess": {
"placeholders": {
"amount": {"type": "String"},
"unit": {"type": "String"}
}
},
"cancelSendAlreadyClaimed": "接收方已领取代币",
"pendingOfflineSend": "待处理的离线发送",
"pendingOfflineSendsHeader": "未领取的离线发送",
"pendingSendDetailTitle": "待处理发送",
"validToken": "有效代币",
"invalidToken": "无效或格式错误的代币",
"amount": "金额:",
@@ -118,6 +133,7 @@
"confirm": "确认",
"cancel": "取消",
"insufficientBalance": "余额不足",
"feeExceedsAmount": "手续费超过发送金额",
"tokenCreationError": "创建代币错误:{error}",
"@tokenCreationError": {
"placeholders": {
@@ -227,6 +243,8 @@
"pendingStatus": "待处理",
"receivedStatus": "已收到",
"sentStatus": "已发送",
"receiving": "接收中",
"sending": "发送中",
"now": "刚刚",
"agoMinutes": "{minutes} 分钟前",
"@agoMinutes": {
@@ -573,5 +591,13 @@
"swapSuccess": "兑换完成",
"swapErrorInsufficient": "余额不足",
"swapErrorExpired": "报价已过期",
"swapErrorGeneric": "兑换错误: {error}"
"swapErrorGeneric": "兑换错误: {error}",
"swapChartUnavailable": "价格不可用 · 点击重试",
"swapChartMinMax": "24h 最低: {minPrice} — 最高: {maxPrice}",
"privacyPolicy": "隐私政策",
"privacyTitle": "我们什么都不收集",
"privacyGoodbye": "再见",
"privacyKeepReading": "(想继续看就看吧…)",
"privacyBody": "我们不知道你是谁\n我们不知道你有多少\n我们不知道你在做什么",
"privacyConclusion": "保护数据的最好方式\n就是不拥有它"
}
+315 -177
View File
@@ -15,6 +15,8 @@ import 'package:uuid/uuid.dart';
import '../data/transaction_meta_storage.dart';
import '../data/pending_token.dart';
import '../data/pending_token_storage.dart';
import '../data/pending_send.dart';
import '../data/pending_send_storage.dart';
import '../core/utils/keyset_debug.dart';
import '../core/utils/p2pk_utils.dart';
import '../widgets/effects/cashu_confetti.dart';
@@ -47,6 +49,9 @@ class WalletProvider extends ChangeNotifier {
/// Storage para tokens pendientes de reclamar (Receive Later)
final PendingTokenStorage _pendingTokenStorage = PendingTokenStorage();
/// Storage para envíos offline pendientes de ser reclamados por el receptor
final PendingSendStorage _pendingSendStorage = PendingSendStorage();
/// Controller global de confetti para celebrar recepciones
final CashuConfettiController confettiController = CashuConfettiController();
@@ -82,8 +87,8 @@ class WalletProvider extends ChangeNotifier {
static const _mintsKey = 'wallet_mints';
static const _activeMintKey = 'wallet_active_mint';
static const _activeUnitKey = 'wallet_active_unit';
static const _pendingMintInvoicesKey = 'pending_mint_invoices';
static const _pendingNostrRequestKey = 'pending_nostr_request';
static const _legacyPendingMintInvoicesKey = 'pending_mint_invoices';
/// Mint de Cuba Bitcoin - siempre aparece primero en la lista
static const cubaBitcoinMint = 'https://mint.cubabitcoin.org';
@@ -125,6 +130,159 @@ class WalletProvider extends ChangeNotifier {
/// Stream de cambios en pending tokens
Stream<void> get pendingTokenChanges => _pendingTokenStorage.changes;
// ============================================================
// PENDING SENDS GETTERS (envíos offline no reclamados)
// ============================================================
int get pendingSendCount => _pendingSendStorage.count;
int get activePendingSendCount => _pendingSendStorage.activeCount;
bool get hasPendingSends => _pendingSendStorage.hasPendingSends;
bool get hasActivePendingSends => _pendingSendStorage.hasActivePendingSends;
Stream<void> get pendingSendChanges => _pendingSendStorage.changes;
List<PendingSend> listPendingSends() => _pendingSendStorage.listAll();
/// Envíos activos (receptor aún no reclamó). Render: tile warning con
/// botón cancel.
List<PendingSend> listActivePendingSends() =>
_pendingSendStorage.listActive();
/// Envíos liquidados (receptor reclamó). Render: tile outgoing settled,
/// sin botón cancel.
List<PendingSend> listSettledPendingSends() =>
_pendingSendStorage.listSettled();
List<PendingSend> listPendingSendsByMintUnit(String mintUrl, String unit) =>
_pendingSendStorage.listByMintUnit(mintUrl, unit);
/// Persiste un envío offline pendiente para poder reclamarlo después.
/// Llamar desde el flujo de offline_send_screen después de crear el token.
Future<PendingSend> addPendingSend({
required String encoded,
required BigInt amount,
required String mintUrl,
required String unit,
required List<String> proofYs,
String? memo,
}) async {
final send = await _pendingSendStorage.add(
id: _uuid.v4(),
encoded: encoded,
amount: amount,
mintUrl: mintUrl,
unit: unit,
proofYs: proofYs,
memo: memo,
);
// El flujo offline modifica el DB directamente (markProofsPendingSpent),
// así que CDK no sabe que cambió. Forzamos refresh del stream de balance
// para que el home se actualice al instante.
try {
final wallet = await getWallet(mintUrl, unit);
await wallet.refreshBalance();
} catch (e) {
debugPrint('refreshBalance tras addPendingSend falló: $e');
}
notifyListeners();
return send;
}
/// Reclama las proofs de un envío offline pendiente. Verifica con el mint:
/// - Si algunas están UNSPENT → revierte localmente y borra el PendingSend.
/// - Si ninguna UNSPENT y ninguna PENDING (todas SPENT o ya reconciliadas)
/// → el receptor reclamó: borra el PendingSend.
/// - Si hay proofs PENDING en el mint → conserva el PendingSend para retry.
Future<ReclaimResult> reclaimPendingSend(PendingSend send) async {
final wallet = await getWallet(send.mintUrl, send.unit);
final result = await wallet.reclaimProofsByYs(ys: send.proofYs);
await _settlePendingSendOutcome(send, result);
notifyListeners();
return result;
}
/// Reconcilia todos los PendingSend contra el mint. Análogo a
/// `checkPendingTransactions` pero para el storage propio de envíos
/// offline (que CDK no conoce). Pensado para correr en pull-to-refresh,
/// history mount y startup.
///
/// **Importante:** usa `checkProofsByYs` (observe-only), no
/// `reclaimProofsByYs`. La reconciliación automática NO debe revertir
/// proofs Unspent — eso cancelaría envíos legítimos que el receptor
/// todavía no reclamó, abriendo una ventana de doble-gasto.
///
/// Sólo marca `settled` cuando el mint confirma que todos los proofs
/// están gastados. Cualquier otro caso (pending mid-swap o unspent
/// esperando al receptor) mantiene el record activo.
///
/// Tolera errores por-record (un mint offline no rompe el resto).
/// Devuelve cuántos records pasaron a settled.
Future<int> reconcilePendingSends() async {
// Sólo activos: los settled ya están resueltos.
final sends = _pendingSendStorage.listActive();
if (sends.isEmpty) return 0;
var settled = 0;
for (final send in sends) {
try {
final wallet = await getWallet(send.mintUrl, send.unit);
final result = await wallet.checkProofsByYs(ys: send.proofYs);
final total = BigInt.from(send.proofYs.length);
final allSpent = result.pendingCount == BigInt.zero &&
result.spentCount >= total;
if (allSpent) {
await _pendingSendStorage.markSettled(send.id);
settled++;
}
} catch (e) {
debugPrint('reconcilePendingSends: ${send.id} failed: $e');
}
}
if (settled > 0) notifyListeners();
return settled;
}
/// Decisión central sobre qué hacer con un PendingSend tras consultar
/// el mint. Devuelve `true` si el record dejó de estar activo.
///
/// - `count > 0` → recuperamos nosotros:
/// el envío se abortó, no hay ledger que mostrar. REMOVE.
/// - `pending == 0 && spent >= proofYs.length` → mint confirmó todos
/// spent: receptor reclamó. SETTLE para histórico.
/// - cualquier otro caso (pending > 0, o cobertura parcial de spent)
/// → conservar activo; próxima reconciliación reintenta.
Future<bool> _settlePendingSendOutcome(
PendingSend send,
ReclaimResult result,
) async {
if (result.count > BigInt.zero) {
// Recuperamos las proofs; el envío se canceló desde nuestro lado.
await _pendingSendStorage.remove(send.id);
return true;
}
// Defensa en profundidad: aunque `pending_count == 0` suele bastar para
// decir "todas consumidas", exigimos además que `spent_count` cubra
// todos los proofs del send. Un resultado parcial o malformado podría
// dejar `pending == 0` sin haber observado spent para todos los ys.
final expected = BigInt.from(send.proofYs.length);
final allAccountedSpent = expected > BigInt.zero &&
result.pendingCount == BigInt.zero &&
result.spentCount >= expected;
if (allAccountedSpent) {
// Ninguna unspent, ninguna pending, todas spent → receptor reclamó.
// Pasa a histórico como "outgoing liquidado".
await _pendingSendStorage.markSettled(send.id);
return true;
}
// pending > 0 (receptor mid-swap) o cobertura parcial → conservamos
// activo para que la próxima reconciliación lo resuelva.
return false;
}
Future<void> removePendingSend(String id) async {
await _pendingSendStorage.remove(id);
notifyListeners();
}
/// Lista de mints ordenados por balance.
/// Cuba Bitcoin siempre primero, luego ordenados por: sats → usd → eur → otros.
Future<List<String>> getSortedMintUrls() async {
@@ -333,6 +491,9 @@ class WalletProvider extends ChangeNotifier {
// Inicializar storage de tokens pendientes
await _pendingTokenStorage.init();
// Inicializar storage de envíos offline pendientes
await _pendingSendStorage.init();
// Obtener directorio de documentos (path absoluto requerido)
final dir = await getApplicationDocumentsDirectory();
final dbPath = '${dir.path}/elcaju_wallet.sqlite';
@@ -1092,43 +1253,27 @@ class WalletProvider extends ChangeNotifier {
}
/// Método de conveniencia: prepara y confirma en un solo paso.
/// Si confirmSend falla, libera proofs reservados con cancelSend.
/// Si confirmSend falla, los proofs quedan en PendingSpent.
/// No llamamos reclaimPendingProofs aquí porque podría revocar
/// tokens de otros envíos no reclamados. El usuario puede recuperar
/// manualmente desde Settings → Recover Tokens.
Future<String> sendTokens(BigInt amount, String? memo) async {
final prepared = await prepareSend(amount);
debugPrint('[SEND] prepareSend OK - fee=${prepared.fee}');
try {
final token = await confirmSend(prepared, memo);
debugPrint('[SEND] Send completed');
return token;
} catch (e) {
try {
await cancelSend(prepared);
} catch (cancelErr) {
debugPrint('[SEND] cancelSend failed: $cancelErr');
}
rethrow;
}
final token = await confirmSend(prepared, memo);
debugPrint('[SEND] Send completed');
return token;
}
/// Envía tokens P2PK (bloqueados a una clave pública).
/// CDK 0.15+ con includeFee: true maneja correctamente mints con ppk>0.
/// Si confirmSend falla, libera proofs reservados con cancelSend.
Future<String> sendTokensP2pk(BigInt amount, String pubkey, String? memo) async {
debugPrint('[P2PK] Sending $amount to ${pubkey.length > 16 ? pubkey.substring(0, 16) : pubkey}...');
final prepared = await prepareSendP2pk(amount, pubkey);
debugPrint('[P2PK] prepareSend OK - fee=${prepared.fee}');
try {
final token = await confirmSend(prepared, memo);
debugPrint('[P2PK] Send completed');
return token;
} catch (e) {
try {
await cancelSend(prepared);
} catch (cancelErr) {
debugPrint('[P2PK] cancelSend failed: $cancelErr');
}
rethrow;
}
final token = await confirmSend(prepared, memo);
debugPrint('[P2PK] Send completed');
return token;
}
/// Verifica si hay transacciones salientes pendientes en el wallet activo.
@@ -1185,8 +1330,6 @@ class WalletProvider extends ChangeNotifier {
throw Exception('No hay wallet activo');
}
final mintUrl = _activeMintUrl!;
final unit = _activeUnit;
String? invoiceBolt11;
// Cerrar controller anterior si existe (evitar leak)
@@ -1209,25 +1352,24 @@ class WalletProvider extends ChangeNotifier {
amount: amount,
description: description,
).listen(
(quote) async {
// Guardar invoice temprano en SharedPreferences
(quote) {
if (quote.state == MintQuoteState.unpaid) {
invoiceBolt11 = quote.request;
_savePendingMintInvoice(quote.id, quote.request, mintUrl, unit, amount);
}
// Cuando se completa, guardar metadata, confetti, limpiar pending
if (quote.state == MintQuoteState.issued && invoiceBolt11 != null) {
final saved = await _saveMintMetadata(wallet, invoiceBolt11!, quote.transactionId);
// Only remove pending invoice if metadata was saved;
// otherwise _matchPendingMintInvoices can recover it on next startup
if (saved) _removePendingMintInvoice(quote.id);
}
// Reenviar a la UI (si sigue escuchando)
// Reenviar a la UI ANTES de cualquier await: Rust cierra el stream
// inmediatamente tras emitir Issued, y onDone corre durante el await
// de _saveMintMetadata, cerrando el controller antes de que el evento
// llegue al screen.
if (!controller.isClosed) {
controller.add(quote);
}
// Metadata async fire-and-forget (no bloquea el forward)
if (quote.state == MintQuoteState.issued && invoiceBolt11 != null) {
final invoice = invoiceBolt11!;
unawaited(_saveMintMetadata(wallet, invoice, quote.transactionId));
}
},
onError: (error) {
if (!controller.isClosed) {
@@ -1245,91 +1387,63 @@ class WalletProvider extends ChangeNotifier {
return controller.stream;
}
/// Guarda un invoice de mint pendiente para recuperarlo después.
Future<void> _savePendingMintInvoice(
String quoteId, String invoice, String mintUrl, String unit, BigInt amount,
) async {
/// Exact recovery of paid-but-unissued mint quotes using `quote_id`.
///
/// Replaces the old heuristic that matched by (mintUrl, unit, amount) and
/// could pair a freshly created invoice to an unrelated orphan transaction
/// of the same amount. For every unissued quote the CDK knows about, we
/// ask the mint for the current state and — if Paid — issue the proofs,
/// linking the resulting tx_id to its bolt11 in TransactionMeta.
Future<void> _recoverPendingMintQuotes() async {
// One-time cleanup of the legacy SharedPreferences state (no-op after
// the first run). Only writes when the key is actually present.
try {
final prefs = await SharedPreferences.getInstance();
final jsonStr = prefs.getString(_pendingMintInvoicesKey) ?? '{}';
final map = Map<String, dynamic>.from(jsonDecode(jsonStr));
map[quoteId] = {
'invoice': invoice,
'mintUrl': mintUrl,
'unit': unit,
'amount': amount.toString(),
'createdAt': DateTime.now().toIso8601String(),
};
await prefs.setString(_pendingMintInvoicesKey, jsonEncode(map));
debugPrint('Pending mint invoice guardado: $quoteId');
if (prefs.getString(_legacyPendingMintInvoicesKey) != null) {
await prefs.remove(_legacyPendingMintInvoicesKey);
debugPrint('Removed legacy $_legacyPendingMintInvoicesKey from prefs');
}
} catch (e) {
debugPrint('Error guardando pending mint invoice: $e');
debugPrint('Legacy pending-invoices cleanup failed: $e');
}
}
/// Elimina un invoice de mint pendiente (ya fue procesado).
Future<void> _removePendingMintInvoice(String quoteId) async {
try {
final prefs = await SharedPreferences.getInstance();
final jsonStr = prefs.getString(_pendingMintInvoicesKey) ?? '{}';
final map = Map<String, dynamic>.from(jsonDecode(jsonStr));
map.remove(quoteId);
await prefs.setString(_pendingMintInvoicesKey, jsonEncode(map));
debugPrint('Pending mint invoice eliminado: $quoteId');
} catch (e) {
debugPrint('Error eliminando pending mint invoice: $e');
}
}
for (final entry in _mintUnits.entries) {
for (final unit in entry.value) {
try {
final wallet = await getWallet(entry.key, unit);
final pending = await wallet.getUnissuedMintQuotes();
for (final quote in pending) {
try {
final updated = await wallet.checkMintQuoteStatus(quoteId: quote.id);
if (updated.state != MintQuoteState.paid) continue;
/// TTL para invoices de mint pendientes (24 horas).
static const _pendingMintInvoiceTtl = Duration(hours: 24);
/// Obtiene todos los invoices de mint pendientes, limpiando expirados.
Future<Map<String, dynamic>> _getPendingMintInvoices() async {
try {
final prefs = await SharedPreferences.getInstance();
final jsonStr = prefs.getString(_pendingMintInvoicesKey) ?? '{}';
final map = Map<String, dynamic>.from(jsonDecode(jsonStr));
// Filtrar expirados
final now = DateTime.now();
final expired = <String>[];
for (final entry in map.entries) {
final data = Map<String, dynamic>.from(entry.value);
final createdAt = DateTime.tryParse(data['createdAt'] ?? '');
if (createdAt == null || now.difference(createdAt) > _pendingMintInvoiceTtl) {
expired.add(entry.key);
final minted = await wallet.mintByQuoteId(quoteId: quote.id);
final txId = minted.transactionId;
if (txId != null && txId.isNotEmpty) {
await _txMetaStorage.save(
txId,
TransactionMeta(
type: TransactionType.lightning,
invoice: updated.request,
),
);
debugPrint('Recovered mint quote ${quote.id} → tx $txId');
} else {
debugPrint('Recovered mint quote ${quote.id} but no tx_id');
}
confettiController.fire();
notifyListeners();
} catch (e) {
debugPrint('Recover quote ${quote.id} failed: $e');
}
}
} catch (e) {
debugPrint('Recover quotes for ${entry.key}/$unit failed: $e');
}
}
// Limpiar expirados si hay
if (expired.isNotEmpty) {
for (final key in expired) {
map.remove(key);
}
await prefs.setString(_pendingMintInvoicesKey, jsonEncode(map));
debugPrint('Pending mint invoices expirados eliminados: ${expired.length}');
}
return map;
} catch (e) {
return {};
}
}
/// Busca un invoice pendiente que coincida con un mintUrl y unit.
/// Retorna el invoice string o null.
Future<String?> findPendingMintInvoice(String mintUrl, String unit) async {
final pending = await _getPendingMintInvoices();
for (final entry in pending.values) {
final data = Map<String, dynamic>.from(entry);
if (data['mintUrl'] == mintUrl && data['unit'] == unit) {
return data['invoice'] as String?;
}
}
return null;
}
// ============================================================
// PENDING NOSTR PAYMENT REQUESTS
// ============================================================
@@ -1498,19 +1612,82 @@ class WalletProvider extends ChangeNotifier {
}
/// Ejecuta el pago del invoice.
/// Guarda metadata type=lightning para identificar en historial.
/// FRB consume PreparedMelt por valor, así que cancelMelt no es posible.
/// Si falla, usamos recoverIncompleteSagas para reanudar solo esta operación
/// sin afectar otros proofs pendientes (como tokens enviados no reclamados).
Future<BigInt> melt(MeltQuote quote) async {
final wallet = await getActiveWallet();
final totalPaid = await wallet.melt(quote: quote);
final prepared = await wallet.prepareMelt(quote: quote);
try {
final totalPaid = await wallet.confirmMelt(melt: prepared);
// Guardar metadata con el invoice
if (_pendingMeltInvoice != null) {
await _saveMeltMetadata(wallet, _pendingMeltInvoice!);
_pendingMeltInvoice = null;
// Guardar metadata con el invoice
if (_pendingMeltInvoice != null) {
await _saveMeltMetadata(wallet, _pendingMeltInvoice!);
_pendingMeltInvoice = null;
}
notifyListeners();
return totalPaid;
} catch (e) {
// PreparedMelt ya fue consumido por FRB — cancelMelt es imposible.
// recoverIncompleteSagas reanuda solo la saga interrumpida,
// sin afectar otros proofs en PendingSpent (sends no reclamados).
try {
await wallet.recoverIncompleteSagas();
} catch (recoverErr) {
debugPrint('[MELT] recoverIncompleteSagas failed: $recoverErr');
}
rethrow;
}
}
notifyListeners();
return totalPaid;
/// Recupera proofs huérfanas en PendingSpent consultando el mint.
/// Solo revierte proofs que el mint confirma como no gastadas.
/// Si [mintUrl] es null, escanea todos los mints.
/// Retorna Map<unit, ReclaimResult> con resultados agrupados por unidad.
Future<Map<String, ReclaimResult>> reclaimPendingProofs({String? mintUrl}) async {
final perUnit = <String, ReclaimResult>{};
final mints = mintUrl != null
? {mintUrl: _mintUnits[mintUrl] ?? ['sat']}
: _mintUnits;
for (final entry in mints.entries) {
for (final unit in entry.value) {
try {
final wallet = await getWallet(entry.key, unit);
final result = await wallet.reclaimPendingProofs();
if (result.count > BigInt.zero) {
final prev = perUnit[unit];
perUnit[unit] = ReclaimResult(
count: (prev?.count ?? BigInt.zero) + result.count,
amount: (prev?.amount ?? BigInt.zero) + result.amount,
pendingCount:
(prev?.pendingCount ?? BigInt.zero) + result.pendingCount,
spentCount:
(prev?.spentCount ?? BigInt.zero) + result.spentCount,
);
}
} catch (e) {
debugPrint('Reclaim pending proofs failed for ${entry.key}:$unit: $e');
}
}
}
if (perUnit.isNotEmpty) {
notifyListeners();
}
return perUnit;
}
/// Cancela un envío pendiente reclamando sus proofs.
/// Usa tx.ys para identificar las proofs exactas de esa transacción.
/// Solo revierte las que el mint confirma como no gastadas.
Future<ReclaimResult> reclaimTransaction(Transaction tx) async {
final wallet = await getWallet(tx.mintUrl, tx.unit);
final result = await wallet.reclaimProofsByYs(ys: tx.ys);
if (result.count > BigInt.zero) {
notifyListeners();
}
return result;
}
/// Guarda metadata para una transacción de melt (Lightning withdrawal).
@@ -1659,6 +1836,13 @@ class WalletProvider extends ChangeNotifier {
/// Llamar en background al iniciar la app.
/// También vincula transacciones incoming sin metadata con invoices pendientes.
Future<void> checkPendingTransactions() async {
// Recover paid-but-unissued quotes by quote_id FIRST, before the
// per-mint loop runs checkAllMintQuotes. CDK's mint_unissued_quotes
// issues proofs silently (drops amount_issued > 0 into the quote,
// excluding it from the unissued list), so if it ran first our
// recovery would find nothing and bolt11 metadata would be lost.
await _recoverPendingMintQuotes();
// Iterar todos los mints y todas sus unidades, no solo los wallets
// ya instanciados. initialize() solo precarga units.first por mint,
// así que quotes/sagas/melts en otras unidades se perderían.
@@ -1668,7 +1852,9 @@ class WalletProvider extends ChangeNotifier {
final wallet = await getWallet(entry.key, unit);
try {
// Reclamar quotes pagados pero no emitidos (Lightning → proofs)
// Safety net: after our recovery pass, this should be a no-op
// for healthy cases. Still useful if _recoverPendingMintQuotes
// errored on a specific quote and CDK can retry issuance.
await wallet.checkAllMintQuotes();
} catch (e) {
debugPrint('Check mint quotes failed: $e');
@@ -1694,69 +1880,17 @@ class WalletProvider extends ChangeNotifier {
// Silencioso - puede fallar offline
debugPrint('Check pending failed: $e');
}
} catch (e) {
debugPrint('Error getting wallet ${entry.key}:$unit: $e');
}
}
}
// Vincular transacciones incoming sin metadata con pending invoices
await _matchPendingMintInvoices();
// Resume pending Nostr payment request if app was killed mid-wait
await resumePendingNostrRequest();
}
/// Busca transacciones incoming sin metadata y las vincula con
/// invoices de mint pendientes guardados en SharedPreferences.
Future<void> _matchPendingMintInvoices() async {
try {
final pending = await _getPendingMintInvoices();
if (pending.isEmpty) return;
// Obtener todas las transacciones incoming
final allTxs = await getAllTransactions(
direction: TransactionDirection.incoming,
);
for (final tx in allTxs) {
// Solo procesar transacciones sin metadata
if (_txMetaStorage.has(tx.id)) continue;
// Buscar un pending invoice que coincida con mintUrl, unit y amount
String? matchedQuoteId;
String? matchedInvoice;
for (final entry in pending.entries) {
final data = Map<String, dynamic>.from(entry.value);
if (data['mintUrl'] == tx.mintUrl && data['unit'] == tx.unit && data['amount'] == tx.amount.toString()) {
matchedQuoteId = entry.key;
matchedInvoice = data['invoice'] as String?;
break;
}
}
if (matchedQuoteId != null && matchedInvoice != null) {
await _txMetaStorage.save(
tx.id,
TransactionMeta(
type: TransactionType.lightning,
invoice: matchedInvoice,
),
);
// Limpiar el pending invoice ya vinculado
await _removePendingMintInvoice(matchedQuoteId);
pending.remove(matchedQuoteId);
debugPrint('Matched pending mint invoice → tx ${tx.id}');
confettiController.fire();
notifyListeners();
}
}
} catch (e) {
debugPrint('Error matching pending mint invoices: $e');
}
}
/// Verifica si un token específico fue gastado.
Future<bool> isTokenSpent(String encodedToken) async {
final wallet = activeWallet;
@@ -1911,6 +2045,10 @@ class WalletProvider extends ChangeNotifier {
// Limpiar metadata de transacciones
await _txMetaStorage.clear();
// Limpiar envíos offline pendientes (storage separado, sobrevive al
// borrado del wallet.sqlite si no se limpia acá).
await _pendingSendStorage.clear();
// Borrar archivo
final dir = await getApplicationDocumentsDirectory();
final dbPath = '${dir.path}/elcaju_wallet.sqlite';
+89 -27
View File
@@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import 'package:lucide_icons/lucide_icons.dart';
import 'package:elcaju/l10n/app_localizations.dart';
@@ -59,38 +60,81 @@ class _ScanScreenState extends State<ScanScreen> {
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,
onDetect: _onCodeDetected,
showFlashControl: true,
showCameraSwitch: false,
),
),
// Indicador de procesamiento
if (_isProcessing)
Container(
color: Colors.black54,
child: const Center(
child: CircularProgressIndicator(
color: AppColors.primaryAction,
// Instrucciones en la parte inferior
Positioned(
bottom: 170,
left: 24,
right: 24,
child: Text(
_getInstructionForMode(l10n),
style: TextStyle(
fontFamily: 'Inter',
fontSize: 16,
color: Colors.white.withValues(alpha: 0.8),
),
textAlign: TextAlign.center,
),
),
// Botón pegar del portapapeles (encima del flash)
Positioned(
bottom: 100,
left: 24,
right: 24,
child: Center(
child: Material(
color: Colors.white.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(24),
child: InkWell(
onTap: _isProcessing ? null : _pasteFromClipboard,
borderRadius: BorderRadius.circular(24),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 20,
vertical: 12,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
LucideIcons.clipboard,
color: Colors.white,
size: 20,
),
const SizedBox(width: 8),
Text(
l10n.pasteFromClipboard,
style: const TextStyle(
fontFamily: 'Inter',
fontSize: 14,
fontWeight: FontWeight.w600,
color: Colors.white,
),
),
],
),
),
),
),
),
),
],
// Indicador de procesamiento
if (_isProcessing)
Container(
color: Colors.black54,
child: const Center(
child: CircularProgressIndicator(
color: AppColors.primaryAction,
),
),
),
],
),
),
);
@@ -118,6 +162,24 @@ class _ScanScreenState extends State<ScanScreen> {
}
}
Future<void> _pasteFromClipboard() async {
final l10n = L10n.of(context)!;
final clipboardData = await Clipboard.getData(Clipboard.kTextPlain);
final text = clipboardData?.text?.trim();
if (!mounted) return;
if (text == null || text.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(l10n.emptyClipboard),
backgroundColor: AppColors.warning,
duration: const Duration(seconds: 2),
),
);
return;
}
_onCodeDetected(text);
}
void _onCodeDetected(String rawData) async {
if (_isProcessing) return;
+1 -2
View File
@@ -841,8 +841,7 @@ class _RequestScreenState extends State<RequestScreen> {
_status = RequestStatus.received;
_receivedAmount = amount;
});
final walletProvider = context.read<WalletProvider>();
walletProvider.confettiController.fire();
// Confetti se dispara globalmente desde WalletProvider._saveMintMetadata
// TODO: re-enable once CDK fixes NIP-17 (cashubtc/cdk#1807)
// Only remove pending Nostr request if this screen created one.
// Disabled while Nostr payment requests are disabled to avoid
+149 -80
View File
@@ -14,6 +14,8 @@ import '../../src/rust/api/wallet.dart';
import '../../widgets/common/gradient_background.dart';
import '../../widgets/common/glass_card.dart';
import '../../widgets/common/primary_button.dart';
import '../../widgets/common/secondary_button.dart';
import '../../widgets/common/bottom_sheet_container.dart';
import '../../providers/wallet_provider.dart';
import '../../providers/price_provider.dart';
@@ -44,6 +46,7 @@ class _SwapScreenState extends State<SwapScreen>
List<double> _chartData = [];
bool _isLoadingChart = true;
bool _chartError = false;
// --- Swap state ---
bool _isSwapping = false;
@@ -81,7 +84,6 @@ class _SwapScreenState extends State<SwapScreen>
Future<void> _loadBalances() async {
final walletProvider = context.read<WalletProvider>();
final mintUrl = WalletProvider.cubaBitcoinMint;
if (mintUrl == null) return;
try {
final balances = await walletProvider.getBalancesForMint(mintUrl);
@@ -96,33 +98,40 @@ class _SwapScreenState extends State<SwapScreen>
}
Future<void> _loadChartData() async {
setState(() {
_isLoadingChart = true;
_chartError = false;
});
try {
final prices = await PriceService.getHistoricalPrices(range: 'ONE_DAY');
if (mounted && prices.isNotEmpty) {
if (!mounted) return;
final chartData = prices
.map((p) => p.priceUsd)
.where((price) => price.isFinite && price > 0)
.toList();
if (chartData.length >= 2) {
setState(() {
_chartData = prices.map((p) => p.priceUsd).toList();
_chartData = chartData;
_isLoadingChart = false;
});
} else {
setState(() {
_chartData = [];
_isLoadingChart = false;
_chartError = true;
});
}
} catch (_) {
if (mounted) {
setState(() {
_chartData = _generateMockData();
_chartData = [];
_isLoadingChart = false;
_chartError = true;
});
}
}
}
List<double> _generateMockData() {
final priceProvider = context.read<PriceProvider>();
final basePrice = priceProvider.btcPriceUsd ?? 65000;
final rng = Random();
return List.generate(24, (i) {
return basePrice + (rng.nextDouble() - 0.48) * basePrice * 0.02;
});
}
void _onFromChanged() {
if (_isUpdating) return;
_lastEditedFrom = true;
@@ -263,7 +272,6 @@ class _SwapScreenState extends State<SwapScreen>
Future<void> _startSwap() async {
final walletProvider = context.read<WalletProvider>();
final mintUrl = WalletProvider.cubaBitcoinMint;
if (mintUrl == null) return;
final destAmount = _getDestAmount();
if (destAmount <= BigInt.zero) return;
@@ -374,29 +382,11 @@ class _SwapScreenState extends State<SwapScreen>
backgroundColor: Colors.transparent,
isDismissible: false,
enableDrag: false,
builder: (ctx) => 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),
width: 1,
),
),
builder: (ctx) => BottomSheetContainer(
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),
),
),
const BottomSheetHandle(),
// Icono swap
Container(
@@ -477,29 +467,13 @@ class _SwapScreenState extends State<SwapScreen>
Row(
children: [
Expanded(
child: GestureDetector(
onTap: () {
child: SecondaryButton(
text: l10n.cancel,
onPressed: () {
Navigator.pop(ctx);
_mintSubscription?.cancel();
},
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.cancel,
style: const TextStyle(
fontFamily: 'Inter',
fontSize: 16,
fontWeight: FontWeight.w600,
color: Colors.white,
),
),
),
),
height: 52,
),
),
const SizedBox(width: AppDimensions.paddingMedium),
@@ -520,8 +494,6 @@ class _SwapScreenState extends State<SwapScreen>
),
],
),
const SizedBox(height: AppDimensions.paddingSmall),
],
),
),
@@ -544,9 +516,31 @@ class _SwapScreenState extends State<SwapScreen>
_swapError = null;
});
// Verificar que el fee no supere el monto (operación inviable)
if (meltQuote.amount <= meltQuote.feeReserve) {
setState(() {
_isSwapping = false;
_swapError = l10n.feeExceedsAmount;
});
return;
}
late final PreparedMelt prepared;
try {
prepared = await srcWallet.prepareMelt(quote: meltQuote);
} catch (e) {
// prepareMelt falló: no hay proofs reservadas, solo mostrar error
if (!mounted) return;
setState(() {
_isSwapping = false;
_swapError = l10n.swapErrorGeneric(e.toString());
});
return;
}
try {
// Ejecutar melt (paga el invoice Lightning)
await srcWallet.melt(quote: meltQuote);
await srcWallet.confirmMelt(melt: prepared);
// Guardar metadata del melt (lado enviado)
await walletProvider.saveSwapMeltMetadata(
@@ -586,6 +580,16 @@ class _SwapScreenState extends State<SwapScreen>
} catch (e) {
_mintSubscription?.cancel();
_mintSubscription = null;
// PreparedMelt ya fue consumido por FRB — cancelMelt es imposible.
// recoverIncompleteSagas reanuda solo la saga interrumpida,
// sin afectar otros proofs en PendingSpent (sends no reclamados).
try {
await srcWallet.recoverIncompleteSagas();
} catch (recoverErr) {
debugPrint('[SWAP] recoverIncompleteSagas failed: $recoverErr');
}
if (!mounted) return;
final errorStr = e.toString().toLowerCase();
setState(() {
@@ -708,24 +712,43 @@ class _SwapScreenState extends State<SwapScreen>
// --- Widgets ---
Widget _buildPriceChart(PriceProvider priceProvider) {
final l10n = L10n.of(context)!;
final btcPrice = priceProvider.btcPriceUsd;
final priceStr = btcPrice != null
? '\$${NumberFormat('#,###').format(btcPrice.round())}'
: '...';
final fmt = NumberFormat('#,###');
final priceStr = btcPrice != null ? '\$${fmt.format(btcPrice.round())}' : '...';
String rateStr = '...';
if (btcPrice != null && btcPrice > 0) {
final satsPerDollar = (100000000 / btcPrice).round();
rateStr = '1 USD ≈ ${NumberFormat('#,###').format(satsPerDollar)} sats';
rateStr = '1 USD ≈ ${fmt.format(satsPerDollar)} sats';
}
final isUpTrend =
_chartData.length >= 2 && _chartData.last >= _chartData.first;
final hasData = _chartData.length >= 2;
final isUpTrend = hasData && _chartData.last >= _chartData.first;
final trendColor = isUpTrend ? AppColors.success : AppColors.error;
// % cambio 24h
String? changeStr;
if (hasData) {
final change = ((_chartData.last - _chartData.first) / _chartData.first) * 100;
final sign = change >= 0 ? '+' : '';
changeStr = '$sign${change.toStringAsFixed(1)}% 24h';
}
// Min/Max 24h
String? minMaxStr;
if (hasData) {
final minVal = _chartData.reduce(min);
final maxVal = _chartData.reduce(max);
minMaxStr = l10n.swapChartMinMax(
'\$${fmt.format(minVal.round())}',
'\$${fmt.format(maxVal.round())}',
);
}
return Column(
children: [
// Price
// Precio centrado
Text(
priceStr,
style: const TextStyle(
@@ -736,18 +759,35 @@ class _SwapScreenState extends State<SwapScreen>
),
),
const SizedBox(height: 2),
Text(
'USD',
style: TextStyle(
fontFamily: 'Inter',
fontSize: 14,
fontWeight: FontWeight.w500,
color: AppColors.textSecondary.withValues(alpha: 0.6),
),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
'BTC/USD',
style: TextStyle(
fontFamily: 'Inter',
fontSize: 14,
fontWeight: FontWeight.w500,
color: AppColors.textSecondary.withValues(alpha: 0.6),
),
),
if (changeStr != null) ...[
const SizedBox(width: 8),
Text(
changeStr,
style: TextStyle(
fontFamily: 'Inter',
fontSize: 13,
fontWeight: FontWeight.w600,
color: trendColor,
),
),
],
],
),
const SizedBox(height: 8),
// Sparkline (sin contenedor, integrado)
// Sparkline o estado de error
SizedBox(
height: 40,
width: double.infinity,
@@ -762,16 +802,45 @@ class _SwapScreenState extends State<SwapScreen>
),
),
)
: CustomPaint(
painter: _SparklinePainter(
data: _chartData,
lineColor: trendColor,
fillColor: trendColor.withValues(alpha: 0.08),
),
),
: _chartError
? GestureDetector(
onTap: _loadChartData,
child: Center(
child: Text(
l10n.swapChartUnavailable,
style: TextStyle(
fontFamily: 'Inter',
fontSize: 12,
color: AppColors.textSecondary.withValues(alpha: 0.5),
),
),
),
)
: CustomPaint(
painter: _SparklinePainter(
data: _chartData,
lineColor: trendColor,
fillColor: trendColor.withValues(alpha: 0.08),
),
),
),
const SizedBox(height: 8),
// Min/Max 24h
if (minMaxStr != null)
Padding(
padding: const EdgeInsets.only(bottom: 4),
child: Text(
minMaxStr,
style: TextStyle(
fontFamily: 'Inter',
fontSize: 12,
fontWeight: FontWeight.w500,
color: AppColors.textSecondary.withValues(alpha: 0.5),
),
),
),
// Exchange rate
Text(
rateStr,
+6 -1
View File
@@ -166,7 +166,12 @@ class _SplashScreenState extends State<SplashScreen>
/// Se ejecuta sin bloquear la navegación.
Future<void> _checkPendingTransactions(WalletProvider wallet) async {
try {
await wallet.checkPendingTransactions();
// CDK reconcilia online; reconcilePendingSends reconcilia los envíos
// offline (storage propio).
await Future.wait([
wallet.checkPendingTransactions(),
wallet.reconcilePendingSends(),
]);
debugPrint('Verificación de proofs completada');
} catch (e) {
debugPrint('Error verificando proofs: $e');
+74 -80
View File
@@ -9,6 +9,7 @@ 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';
import '../../widgets/common/bottom_sheet_container.dart';
import '../../providers/wallet_provider.dart';
import '../../providers/settings_provider.dart';
import '../../providers/p2pk_provider.dart';
@@ -356,6 +357,9 @@ class _HomeScreenState extends State<HomeScreen> {
Expanded(
child: AnimatedActionButton(
label: l10n.sendAction,
icon: LucideIcons.arrowUpRight,
showIcon: true,
iconTrailing: true,
type: ButtonType.criticalAction,
onTap: _showSendOptions,
),
@@ -368,6 +372,8 @@ class _HomeScreenState extends State<HomeScreen> {
Expanded(
child: AnimatedActionButton(
label: l10n.receiveAction,
icon: LucideIcons.arrowDownRight,
showIcon: true,
type: ButtonType.primaryAction,
onTap: _showReceiveOptions,
),
@@ -573,29 +579,11 @@ class _MethodSelectorModal extends StatelessWidget {
@override
Widget build(BuildContext context) {
return 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),
width: 1,
),
),
return BottomSheetContainer(
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),
),
),
const BottomSheetHandle(),
// Título
Text(
@@ -618,9 +606,6 @@ class _MethodSelectorModal extends StatelessWidget {
child: _MethodOptionTile(option: option),
),
),
// Espacio para la barra de navegación del sistema
SizedBox(height: MediaQuery.of(context).padding.bottom + AppDimensions.paddingSmall),
],
),
);
@@ -635,67 +620,76 @@ class _MethodOptionTile extends StatelessWidget {
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: option.onTap,
child: Container(
width: double.infinity,
padding: const EdgeInsets.all(AppDimensions.paddingMedium),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.05),
return Semantics(
button: true,
label: option.label,
hint: option.description,
child: Material(
color: Colors.white.withValues(alpha: 0.05),
borderRadius: BorderRadius.circular(16),
child: InkWell(
onTap: option.onTap,
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: Colors.white.withValues(alpha: 0.1),
width: 1,
),
),
child: Row(
children: [
// Icono
Container(
width: 48,
height: 48,
decoration: BoxDecoration(
gradient: const LinearGradient(
colors: AppColors.buttonGradient,
child: Container(
width: double.infinity,
padding: const EdgeInsets.all(AppDimensions.paddingMedium),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: Colors.white.withValues(alpha: 0.1),
width: 1,
),
),
child: Row(
children: [
// Icono
Container(
width: 48,
height: 48,
decoration: BoxDecoration(
gradient: const LinearGradient(
colors: AppColors.buttonGradient,
),
borderRadius: BorderRadius.circular(12),
),
child: Icon(option.icon, color: Colors.white, size: 24),
),
borderRadius: BorderRadius.circular(12),
),
child: Icon(option.icon, color: Colors.white, size: 24),
),
const SizedBox(width: AppDimensions.paddingMedium),
// Texto
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
option.label,
style: const TextStyle(
fontFamily: 'Inter',
fontSize: 18,
fontWeight: FontWeight.w600,
color: Colors.white,
),
const SizedBox(width: AppDimensions.paddingMedium),
// Texto
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
option.label,
style: const TextStyle(
fontFamily: 'Inter',
fontSize: 18,
fontWeight: FontWeight.w600,
color: Colors.white,
),
),
const SizedBox(height: 2),
Text(
option.description,
style: TextStyle(
fontFamily: 'Inter',
fontSize: 14,
color: AppColors.textSecondary.withValues(alpha: 0.7),
),
),
],
),
const SizedBox(height: 2),
Text(
option.description,
style: TextStyle(
fontFamily: 'Inter',
fontSize: 14,
color: AppColors.textSecondary.withValues(alpha: 0.7),
),
),
],
),
),
// Flecha
Icon(
LucideIcons.chevronRight,
color: Colors.white.withValues(alpha: 0.5),
size: 24,
),
],
),
// Flecha
Icon(
LucideIcons.chevronRight,
color: Colors.white.withValues(alpha: 0.5),
size: 24,
),
],
),
),
),
);
@@ -1,7 +1,9 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:lucide_icons/lucide_icons.dart';
import 'package:provider/provider.dart';
import 'package:elcaju/l10n/app_localizations.dart';
import '../../providers/wallet_provider.dart';
import '../../core/constants/colors.dart';
import '../../core/constants/dimensions.dart';
import '../../core/models/proof.dart';
@@ -11,6 +13,7 @@ import '../../widgets/common/gradient_background.dart';
import '../../widgets/common/glass_card.dart';
import '../../widgets/common/primary_button.dart';
import '../../widgets/proof/proof_selector.dart';
import '../../core/utils/keyset_debug.dart';
import 'share_token_screen.dart';
/// Pantalla para enviar tokens de forma offline seleccionando proofs manualmente.
@@ -32,6 +35,13 @@ class _OfflineSendScreenState extends State<OfflineSendScreen> {
final TextEditingController _memoController = TextEditingController();
final ProofService _proofService = ProofService();
// Capturado en initState para poder persistir el PendingSend incluso si
// el widget se desmonta durante el await de markProofsPendingSpent.
// Usar `context.read` después del await es inseguro (widget puede no
// existir), y gatear con `mounted` perdería el record — los proofs
// quedan marcados PENDING_SPENT pero sin handle para reclamar.
late final WalletProvider _walletProvider;
List<LocalProof> _availableProofs = [];
Set<String> _selectedIds = {};
bool _isLoading = true;
@@ -41,6 +51,7 @@ class _OfflineSendScreenState extends State<OfflineSendScreen> {
@override
void initState() {
super.initState();
_walletProvider = context.read<WalletProvider>();
_loadProofs();
}
@@ -325,6 +336,21 @@ class _OfflineSendScreenState extends State<OfflineSendScreen> {
Future<void> _createOfflineToken() async {
final selectedProofs = _selectedProofs;
final selectedTotal = _proofService.calculateTotal(selectedProofs);
// Validar monto mínimo viable para el receptor
// Leer ppk del keyset local, fallback a 100 (estándar)
var ppk = await KeysetDebug.getInputFeePpk(widget.mintUrl, widget.unit);
if (ppk < 0) ppk = 100; // -1 = error leyendo DB, asumir fee estándar
final minReceiveFee = ppk > 0 ? BigInt.from((ppk + 999) ~/ 1000) : BigInt.zero;
if (selectedTotal <= minReceiveFee) {
if (mounted) {
setState(() {
_errorMessage = L10n.of(context)!.feeExceedsAmount;
});
}
return;
}
var proofsMarkedPending = false;
setState(() {
@@ -349,6 +375,20 @@ class _OfflineSendScreenState extends State<OfflineSendScreen> {
);
final token = cdkToken.encoded;
// Rastrear el envío offline como pendiente para poder cancelarlo
// desde el historial. Los offline sends no crean CDK Transaction.
// Usamos el provider capturado en initState (no context.read) para
// que el record se persista aunque el widget se haya desmontado
// mientras esperábamos markProofsPendingSpent.
await _walletProvider.addPendingSend(
encoded: token,
amount: selectedTotal,
mintUrl: widget.mintUrl,
unit: widget.unit,
proofYs: selectedProofs.map((p) => p.yHex).toList(),
memo: memo,
);
if (mounted) {
_isCreating = false;
Navigator.pushReplacement(
+60 -44
View File
@@ -1,3 +1,4 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:http/http.dart' as http;
@@ -7,9 +8,12 @@ import 'package:elcaju/l10n/app_localizations.dart';
import '../../core/constants/colors.dart';
import '../../core/constants/dimensions.dart';
import '../../core/utils/formatters.dart';
import '../../core/utils/keyset_debug.dart';
import '../../widgets/common/gradient_background.dart';
import '../../widgets/common/glass_card.dart';
import '../../widgets/common/primary_button.dart';
import '../../widgets/common/secondary_button.dart';
import '../../widgets/common/bottom_sheet_container.dart';
import '../../widgets/common/numpad_widget.dart';
import '../../providers/wallet_provider.dart';
import '../../core/utils/nostr_utils.dart';
@@ -572,11 +576,32 @@ class _SendScreenState extends State<SendScreen> {
});
return;
}
// Offline: verificar monto mínimo con ppk del keyset local (fallback 100)
var ppk = await KeysetDebug.getInputFeePpk(mintUrl, _activeUnit);
if (ppk < 0) ppk = 100;
final minReceiveFee = ppk > 0 ? BigInt.from((ppk + 999) ~/ 1000) : BigInt.zero;
if (!mounted) return;
if (_amount <= minReceiveFee) {
setState(() {
_errorMessage = L10n.of(context)!.feeExceedsAmount;
});
return;
}
// Offline: ir directo a selección de monedas
_goToOfflineModeWithMessage();
return;
}
// Online: verificar monto mínimo con ppk real del mint
final minReceiveFee = await _getMinReceiveFee(mintUrl);
if (!mounted) return;
if (_amount <= minReceiveFee) {
setState(() {
_errorMessage = L10n.of(context)!.feeExceedsAmount;
});
return;
}
// Online: mostrar modal de confirmación normal
showModalBottomSheet(
context: context,
@@ -595,6 +620,32 @@ class _SendScreenState extends State<SendScreen> {
);
}
/// Calcula el fee mínimo que el receptor pagará al reclamar el token.
/// Consulta /v1/keysets del mint para obtener el ppk real.
/// Fallback: ppk=100 (estándar) → fee mínimo = 1 sat.
Future<BigInt> _getMinReceiveFee(String mintUrl) async {
try {
final response = await http.get(
Uri.parse('$mintUrl/v1/keysets'),
).timeout(const Duration(seconds: 3));
if (response.statusCode == 200) {
final data = json.decode(response.body) as Map<String, dynamic>;
final keysets = data['keysets'] as List<dynamic>? ?? [];
for (final ks in keysets) {
if (ks['active'] == true && ks['unit'] == _activeUnit) {
final ppk = (ks['input_fee_ppk'] as num?)?.toInt() ?? 0;
if (ppk > 0) {
return BigInt.from((ppk + 999) ~/ 1000); // ceil(ppk / 1000)
}
return BigInt.zero;
}
}
}
} catch (_) {}
// Fallback: ppk=100 estándar
return BigInt.one;
}
/// Verifica conectividad haciendo petición HTTP real al mint.
Future<bool> _checkConnectivity(String mintUrl) async {
try {
@@ -748,29 +799,12 @@ class _ConfirmationModal extends StatelessWidget {
@override
Widget build(BuildContext context) {
return 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),
width: 1,
),
),
final l10n = L10n.of(context)!;
return BottomSheetContainer(
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),
),
),
const BottomSheetHandle(),
// Icono
Container(
@@ -790,7 +824,7 @@ class _ConfirmationModal extends StatelessWidget {
// Titulo
Text(
L10n.of(context)!.confirmSend,
l10n.confirmSend,
style: const TextStyle(
fontFamily: 'Inter',
fontSize: 20,
@@ -831,40 +865,22 @@ class _ConfirmationModal extends StatelessWidget {
Row(
children: [
Expanded(
child: GestureDetector(
onTap: onCancel,
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,
),
),
),
),
child: SecondaryButton(
text: l10n.cancel,
onPressed: onCancel,
height: 52,
),
),
const SizedBox(width: AppDimensions.paddingMedium),
Expanded(
child: PrimaryButton(
text: L10n.of(context)!.confirm,
text: l10n.confirm,
onPressed: onConfirm,
height: 52,
),
),
],
),
const SizedBox(height: AppDimensions.paddingSmall),
],
),
);
+23 -45
View File
@@ -9,6 +9,8 @@ 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/secondary_button.dart';
import '../../widgets/common/bottom_sheet_container.dart';
import '../../widgets/common/numpad_widget.dart';
import '../../providers/wallet_provider.dart';
import '../../providers/price_provider.dart';
@@ -404,6 +406,8 @@ class _AmountScreenState extends State<AmountScreen> {
Future<void> _processPayment() async {
if (!_canPay) return;
final walletProvider = context.read<WalletProvider>();
setState(() {
_isProcessing = true;
_errorMessage = null;
@@ -431,14 +435,22 @@ class _AmountScreenState extends State<AmountScreen> {
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
// 4a. Verificar que el fee no supere el monto (operación inviable)
if (quote.amount <= quote.feeReserve) {
setState(() {
_isProcessing = false;
_errorMessage = L10n.of(context)!.feeExceedsAmount;
});
return;
}
// 4b. Verificar balance suficiente
if (total > _availableBalance) {
setState(() {
_isProcessing = false;
@@ -506,32 +518,16 @@ class _AmountScreenState extends State<AmountScreen> {
Future<bool> _showConfirmation(BigInt amount, BigInt fee, BigInt total) async {
final unitLabel = UnitFormatter.getUnitLabel(_activeUnit);
final l10n = L10n.of(context)!;
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),
),
),
builder: (context) => BottomSheetContainer(
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),
),
),
const BottomSheetHandle(),
// Icono
Container(
@@ -551,7 +547,7 @@ class _AmountScreenState extends State<AmountScreen> {
// Título
Text(
L10n.of(context)!.confirmPayment,
l10n.confirmPayment,
style: const TextStyle(
fontFamily: 'Inter',
fontSize: 20,
@@ -596,40 +592,22 @@ class _AmountScreenState extends State<AmountScreen> {
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,
),
),
),
),
child: SecondaryButton(
text: l10n.cancel,
onPressed: () => Navigator.pop(context, false),
height: 52,
),
),
const SizedBox(width: AppDimensions.paddingMedium),
Expanded(
child: PrimaryButton(
text: L10n.of(context)!.pay,
text: l10n.pay,
onPressed: () => Navigator.pop(context, true),
height: 52,
),
),
],
),
const SizedBox(height: AppDimensions.paddingSmall),
],
),
),
+11 -44
View File
@@ -12,6 +12,8 @@ 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/secondary_button.dart';
import '../../widgets/common/bottom_sheet_container.dart';
import '../../providers/wallet_provider.dart';
import '../10_scanner/scan_screen.dart';
import 'amount_screen.dart';
@@ -858,29 +860,12 @@ class _ConfirmationModal extends StatelessWidget {
@override
Widget build(BuildContext context) {
return 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),
width: 1,
),
),
final l10n = L10n.of(context)!;
return BottomSheetContainer(
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),
),
),
const BottomSheetHandle(),
// Icono
Container(
@@ -900,7 +885,7 @@ class _ConfirmationModal extends StatelessWidget {
// Título
Text(
L10n.of(context)!.confirmPayment,
l10n.confirmPayment,
style: const TextStyle(
fontFamily: 'Inter',
fontSize: 20,
@@ -945,40 +930,22 @@ class _ConfirmationModal extends StatelessWidget {
Row(
children: [
Expanded(
child: GestureDetector(
onTap: onCancel,
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,
),
),
),
),
child: SecondaryButton(
text: l10n.cancel,
onPressed: onCancel,
height: 52,
),
),
const SizedBox(width: AppDimensions.paddingMedium),
Expanded(
child: PrimaryButton(
text: L10n.of(context)!.pay,
text: l10n.pay,
onPressed: onConfirm,
height: 52,
),
),
],
),
const SizedBox(height: AppDimensions.paddingSmall),
],
),
);
+144
View File
@@ -0,0 +1,144 @@
import 'package:flutter/material.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:elcaju/l10n/app_localizations.dart';
import '../../core/constants/colors.dart';
/// Muestra el diálogo "Acerca de" de ElCaju
void showElCajuAboutDialog(BuildContext context, String appVersion) {
showDialog(
context: context,
builder: (context) => AlertDialog(
backgroundColor: AppColors.deepVoidPurple,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Logo
Container(
width: 80,
height: 80,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(20),
),
child: ClipRRect(
borderRadius: BorderRadius.circular(20),
child: Image.asset(
'assets/img/elcajucubano.png',
fit: BoxFit.cover,
),
),
),
const SizedBox(height: 16),
const Text(
'El Caju',
style: TextStyle(
fontFamily: 'Inter',
fontSize: 24,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
const SizedBox(height: 4),
Text(
'v$appVersion',
style: TextStyle(
fontFamily: 'Inter',
fontSize: 14,
color: AppColors.textSecondary,
),
),
const SizedBox(height: 16),
Text(
L10n.of(context)!.aboutTagline,
style: TextStyle(
fontFamily: 'Inter',
fontSize: 16,
color: AppColors.textSecondary,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
_buildAboutDescription(context),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(
L10n.of(context)!.close,
style: const TextStyle(
fontFamily: 'Inter',
color: AppColors.textSecondary,
),
),
),
],
),
);
}
Widget _buildAboutDescription(BuildContext context) {
final description = L10n.of(context)!.aboutDescription;
const keyword = 'LaChispa';
final index = description.indexOf(keyword);
if (index == -1) {
return Text(
description,
style: TextStyle(
fontFamily: 'Inter',
fontSize: 14,
color: AppColors.textSecondary.withValues(alpha: 0.7),
),
textAlign: TextAlign.center,
);
}
final before = description.substring(0, index);
final after = description.substring(index + keyword.length);
return RichText(
textAlign: TextAlign.center,
text: TextSpan(
style: TextStyle(
fontFamily: 'Inter',
fontSize: 14,
color: AppColors.textSecondary.withValues(alpha: 0.7),
),
children: [
TextSpan(text: before),
WidgetSpan(
alignment: PlaceholderAlignment.baseline,
baseline: TextBaseline.alphabetic,
child: GestureDetector(
onTap: () => _openLaChispa(context),
child: Text(
keyword,
style: TextStyle(
fontFamily: 'Inter',
fontSize: 14,
color: AppColors.secondaryAction,
decoration: TextDecoration.underline,
decorationColor: AppColors.secondaryAction,
),
),
),
),
TextSpan(text: after),
],
),
);
}
Future<void> _openLaChispa(BuildContext context) async {
final url = Uri.parse('https://app.lachispa.me');
try {
await launchUrl(url, mode: LaunchMode.externalApplication);
} catch (_) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(L10n.of(context)!.couldNotOpenLink),
backgroundColor: AppColors.error,
),
);
}
}
}
@@ -0,0 +1,240 @@
import 'package:flutter/material.dart';
import 'package:lucide_icons/lucide_icons.dart';
import 'package:elcaju/l10n/app_localizations.dart';
import '../../core/constants/colors.dart';
import '../../core/constants/dimensions.dart';
import '../../widgets/common/bottom_sheet_container.dart';
import '../../widgets/common/secondary_button.dart';
/// Modal para confirmar borrado de wallet
class DeleteWalletModal extends StatefulWidget {
final VoidCallback onConfirm;
final VoidCallback onCancel;
const DeleteWalletModal({
super.key,
required this.onConfirm,
required this.onCancel,
});
@override
State<DeleteWalletModal> createState() => _DeleteWalletModalState();
}
class _DeleteWalletModalState extends State<DeleteWalletModal> {
final TextEditingController _controller = TextEditingController();
bool _canDelete = false;
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final l10n = L10n.of(context)!;
final confirmWord = l10n.deleteConfirmWord;
return BottomSheetContainer(
respectKeyboard: true,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const BottomSheetHandle(),
// Icono advertencia
Container(
width: 64,
height: 64,
decoration: BoxDecoration(
color: AppColors.error.withValues(alpha: 0.2),
shape: BoxShape.circle,
),
child: Icon(
LucideIcons.alertTriangle,
color: AppColors.error,
size: 32,
),
),
const SizedBox(height: AppDimensions.paddingMedium),
// Título
Text(
l10n.deleteWalletQuestion,
style: const TextStyle(
fontFamily: 'Inter',
fontSize: 20,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
const SizedBox(height: AppDimensions.paddingSmall),
// Advertencia
Container(
margin: const EdgeInsets.symmetric(horizontal: 16),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: AppColors.error.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
),
child: Column(
children: [
Row(
children: [
Icon(
LucideIcons.alertCircle,
color: AppColors.error,
size: 16,
),
const SizedBox(width: 8),
Expanded(
child: Text(
l10n.actionIrreversible,
style: const TextStyle(
fontFamily: 'Inter',
fontSize: 14,
fontWeight: FontWeight.w600,
color: AppColors.error,
),
),
),
],
),
const SizedBox(height: 8),
Text(
l10n.deleteWalletWarning,
style: TextStyle(
fontFamily: 'Inter',
fontSize: 12,
color: AppColors.error.withValues(alpha: 0.8),
),
),
],
),
),
const SizedBox(height: AppDimensions.paddingMedium),
// Input de confirmación
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.typeDeleteToConfirm,
style: TextStyle(
fontFamily: 'Inter',
fontSize: 14,
color: AppColors.textSecondary,
),
),
const SizedBox(height: 8),
TextField(
controller: _controller,
style: const TextStyle(
fontFamily: 'Inter',
fontSize: 16,
color: Colors.white,
),
decoration: InputDecoration(
hintText: confirmWord,
hintStyle: TextStyle(
fontFamily: 'Inter',
fontSize: 16,
color: Colors.white.withValues(alpha: 0.3),
),
filled: true,
fillColor: Colors.white.withValues(alpha: 0.05),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
),
),
onChanged: (value) {
setState(() {
_canDelete = value == confirmWord;
});
},
),
],
),
),
const SizedBox(height: AppDimensions.paddingLarge),
// Botones
Row(
children: [
Expanded(
child: SecondaryButton(
text: l10n.cancel,
onPressed: widget.onCancel,
height: 52,
),
),
const SizedBox(width: AppDimensions.paddingMedium),
Expanded(
child: _DestructiveButton(
text: l10n.deleteWallet,
enabled: _canDelete,
onPressed: widget.onConfirm,
),
),
],
),
],
),
);
}
}
/// Botón destructivo con semantics accesibles.
/// Separado de los botones estándar porque la acción es irreversible.
class _DestructiveButton extends StatelessWidget {
final String text;
final bool enabled;
final VoidCallback onPressed;
const _DestructiveButton({
required this.text,
required this.enabled,
required this.onPressed,
});
@override
Widget build(BuildContext context) {
return Semantics(
button: true,
enabled: enabled,
label: text,
child: SizedBox(
height: 52,
child: Material(
color: enabled
? AppColors.error
: AppColors.error.withValues(alpha: 0.3),
borderRadius: BorderRadius.circular(16),
child: InkWell(
onTap: enabled ? onPressed : null,
borderRadius: BorderRadius.circular(16),
child: Center(
child: Text(
text,
style: TextStyle(
fontFamily: 'Inter',
fontSize: 16,
fontWeight: FontWeight.w600,
color: enabled
? Colors.white
: Colors.white.withValues(alpha: 0.5),
),
),
),
),
),
),
);
}
}
+26 -25
View File
@@ -275,52 +275,53 @@ class _MintsScreenState extends State<MintsScreen> {
);
}
/// Badges de balance compactos (estilo cashu.me)
/// Badges de balance compactos (estilo cashu.me).
/// Muestra todas las unidades soportadas por el mint: las de saldo > 0
/// con estilo activo y las de saldo 0 atenuadas, para reflejar las
/// capacidades del mint aunque aún no se hayan usado.
Widget _buildBalanceBadges(List<String> units, Map<String, BigInt> balances) {
final nonZeroBalances = balances.entries
.where((e) => e.value > BigInt.zero)
.toList();
final entries = balances.isNotEmpty
? balances.entries.toList()
: units.map((u) => MapEntry(u, BigInt.zero)).toList();
if (nonZeroBalances.isEmpty) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
),
child: Text(
'0 ${units.isNotEmpty ? units.first : "sat"}',
style: TextStyle(
fontFamily: 'Inter',
fontSize: 12,
color: Colors.white.withValues(alpha: 0.6),
),
),
);
if (entries.isEmpty) {
return const SizedBox.shrink();
}
entries.sort((a, b) {
final aZero = a.value == BigInt.zero;
final bZero = b.value == BigInt.zero;
if (aZero == bZero) return 0;
return aZero ? 1 : -1;
});
return Wrap(
spacing: 6,
runSpacing: 4,
children: nonZeroBalances.map((entry) {
children: entries.map((entry) {
final unit = entry.key;
final balance = entry.value;
final isZero = balance == BigInt.zero;
final formatted = UnitFormatter.formatBalance(balance, unit);
final label = UnitFormatter.getUnitLabel(unit);
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: AppColors.primaryAction.withValues(alpha: 0.15),
color: isZero
? Colors.white.withValues(alpha: 0.08)
: AppColors.primaryAction.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(8),
),
child: Text(
'$formatted $label',
style: const TextStyle(
style: TextStyle(
fontFamily: 'Inter',
fontSize: 12,
fontWeight: FontWeight.w500,
color: AppColors.primaryAction,
fontWeight: isZero ? FontWeight.w400 : FontWeight.w500,
color: isZero
? Colors.white.withValues(alpha: 0.5)
: AppColors.primaryAction,
),
),
);
+194
View File
@@ -0,0 +1,194 @@
import 'package:flutter/material.dart';
import 'package:lucide_icons/lucide_icons.dart';
import '../../core/constants/colors.dart';
/// Diálogo para ingresar PIN de 4 dígitos
class PinDialog extends StatefulWidget {
final String title;
final String subtitle;
const PinDialog({
super.key,
required this.title,
required this.subtitle,
});
@override
State<PinDialog> createState() => _PinDialogState();
}
class _PinDialogState extends State<PinDialog> {
final List<String> _pin = [];
@override
Widget build(BuildContext context) {
return AlertDialog(
backgroundColor: AppColors.deepVoidPurple,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)),
title: Text(
widget.title,
style: const TextStyle(
fontFamily: 'Inter',
fontSize: 20,
fontWeight: FontWeight.bold,
color: Colors.white,
),
textAlign: TextAlign.center,
),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
widget.subtitle,
style: TextStyle(
fontFamily: 'Inter',
fontSize: 14,
color: AppColors.textSecondary,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 24),
// Indicadores de PIN
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: List.generate(4, (index) {
final filled = index < _pin.length;
return Container(
margin: const EdgeInsets.symmetric(horizontal: 8),
width: 16,
height: 16,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: filled
? AppColors.primaryAction
: Colors.white.withValues(alpha: 0.2),
border: Border.all(
color: filled
? AppColors.primaryAction
: Colors.white.withValues(alpha: 0.3),
width: 2,
),
),
);
}),
),
const SizedBox(height: 24),
// Teclado numérico
_buildNumPad(),
],
),
);
}
Widget _buildNumPad() {
return Column(
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: ['1', '2', '3'].map((n) => _buildNumKey(n)).toList(),
),
const SizedBox(height: 12),
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: ['4', '5', '6'].map((n) => _buildNumKey(n)).toList(),
),
const SizedBox(height: 12),
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: ['7', '8', '9'].map((n) => _buildNumKey(n)).toList(),
),
const SizedBox(height: 12),
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
// Cancelar
GestureDetector(
onTap: () => Navigator.pop(context),
child: Container(
width: 60,
height: 60,
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.05),
borderRadius: BorderRadius.circular(30),
),
child: Icon(
LucideIcons.x,
color: AppColors.textSecondary,
size: 24,
),
),
),
_buildNumKey('0'),
// Borrar
GestureDetector(
onTap: _deleteDigit,
child: Container(
width: 60,
height: 60,
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.05),
borderRadius: BorderRadius.circular(30),
),
child: Icon(
LucideIcons.delete,
color: AppColors.textSecondary,
size: 24,
),
),
),
],
),
],
);
}
Widget _buildNumKey(String number) {
return GestureDetector(
onTap: () => _addDigit(number),
child: Container(
width: 60,
height: 60,
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(30),
),
child: Center(
child: Text(
number,
style: const TextStyle(
fontFamily: 'Inter',
fontSize: 24,
fontWeight: FontWeight.w600,
color: Colors.white,
),
),
),
),
);
}
void _addDigit(String digit) {
if (_pin.length < 4) {
setState(() {
_pin.add(digit);
});
if (_pin.length == 4) {
// PIN completo, cerrar con resultado
Future.delayed(const Duration(milliseconds: 200), () {
if (mounted) {
Navigator.pop(context, _pin.join());
}
});
}
}
}
void _deleteDigit() {
if (_pin.isNotEmpty) {
setState(() {
_pin.removeLast();
});
}
}
}
+131
View File
@@ -0,0 +1,131 @@
import 'package:flutter/material.dart';
import 'package:lucide_icons/lucide_icons.dart';
import 'package:elcaju/l10n/app_localizations.dart';
import '../../core/constants/colors.dart';
import '../../core/constants/dimensions.dart';
import '../../widgets/common/gradient_background.dart';
class PrivacyScreen extends StatelessWidget {
const PrivacyScreen({super.key});
@override
Widget build(BuildContext context) {
final l10n = L10n.of(context)!;
final conclusionLines = l10n.privacyConclusion
.split('\n')
.map((s) => s.trim())
.where((s) => s.isNotEmpty)
.toList();
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.privacyPolicy,
style: const TextStyle(
fontFamily: 'Inter',
fontWeight: FontWeight.w600,
color: Colors.white,
),
),
),
body: SafeArea(
child: LayoutBuilder(
builder: (context, constraints) => SingleChildScrollView(
padding: const EdgeInsets.all(AppDimensions.paddingLarge),
child: ConstrainedBox(
constraints: BoxConstraints(minHeight: constraints.maxHeight),
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
l10n.privacyTitle,
style: const TextStyle(
fontFamily: 'Inter',
fontSize: 24,
fontWeight: FontWeight.bold,
color: Colors.white,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 12),
Text(
l10n.privacyGoodbye,
style: const TextStyle(
fontFamily: 'Inter',
fontSize: 24,
fontWeight: FontWeight.w800,
color: AppColors.primaryAction,
),
),
const SizedBox(height: 40),
Text(
l10n.privacyKeepReading,
style: TextStyle(
fontFamily: 'Inter',
fontSize: 13,
fontStyle: FontStyle.italic,
color: AppColors.textSecondary.withValues(alpha: 0.5),
),
),
const SizedBox(height: 28),
...l10n.privacyBody.split('\n')
.map((s) => s.trim())
.where((s) => s.isNotEmpty)
.map(
(line) => Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Text(
line,
style: TextStyle(
fontFamily: 'Inter',
fontSize: 16,
color: AppColors.textSecondary.withValues(alpha: 0.85),
),
textAlign: TextAlign.center,
),
),
),
const SizedBox(height: 28),
...conclusionLines.asMap().entries.map((entry) {
final isLast = entry.key == conclusionLines.length - 1;
return Padding(
padding: EdgeInsets.only(bottom: isLast ? 0 : 8),
child: Text(
entry.value,
style: isLast
? const TextStyle(
fontFamily: 'Inter',
fontSize: 18,
fontWeight: FontWeight.w700,
color: Colors.white,
)
: TextStyle(
fontFamily: 'Inter',
fontSize: 15,
fontWeight: FontWeight.w500,
color: AppColors.textSecondary.withValues(alpha: 0.7),
),
textAlign: TextAlign.center,
),
);
}),
],
),
),
),
),
),
),
),
);
}
}
@@ -0,0 +1,704 @@
import 'package:flutter/material.dart';
import 'package:lucide_icons/lucide_icons.dart';
import 'package:provider/provider.dart';
import 'package:elcaju/l10n/app_localizations.dart';
import '../../core/constants/colors.dart';
import '../../core/constants/dimensions.dart';
import '../../core/utils/formatters.dart';
import '../../providers/wallet_provider.dart';
import '../../widgets/common/bottom_sheet_container.dart';
/// Modal para recuperar tokens usando NUT-13
class RecoverTokensModal extends StatefulWidget {
const RecoverTokensModal({super.key});
@override
State<RecoverTokensModal> createState() => _RecoverTokensModalState();
}
class _RecoverTokensModalState extends State<RecoverTokensModal> {
final TextEditingController _mnemonicController = TextEditingController();
bool _useCurrentMnemonic = true;
bool _scanAllMints = true;
String? _selectedMintUrl;
List<String> _availableMints = [];
bool _isLoading = false;
bool _isLoadingMints = true;
String? _result;
bool _isSuccess = false;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) => _loadMints());
}
void _loadMints() {
final walletProvider = context.read<WalletProvider>();
setState(() {
_availableMints = walletProvider.mintUrls;
_isLoadingMints = false;
if (_availableMints.isNotEmpty) {
_selectedMintUrl = _availableMints.first;
}
});
}
@override
void dispose() {
_mnemonicController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return BottomSheetContainer(
respectKeyboard: true,
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const BottomSheetHandle(),
// Icono
Container(
width: 64,
height: 64,
decoration: BoxDecoration(
color: AppColors.primaryAction.withValues(alpha: 0.2),
shape: BoxShape.circle,
),
child: Icon(
LucideIcons.searchCode,
color: AppColors.primaryAction,
size: 32,
),
),
const SizedBox(height: AppDimensions.paddingMedium),
// Título
Text(
L10n.of(context)!.recoverTokensTitle,
style: const TextStyle(
fontFamily: 'Inter',
fontSize: 20,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
const SizedBox(height: AppDimensions.paddingSmall),
// Descripción
Text(
L10n.of(context)!.recoverTokensDescription,
style: TextStyle(
fontFamily: 'Inter',
fontSize: 14,
color: AppColors.textSecondary,
),
textAlign: TextAlign.center,
),
const SizedBox(height: AppDimensions.paddingLarge),
// Opciones de mnemonic
_buildMnemonicOptions(),
const SizedBox(height: AppDimensions.paddingMedium),
// Selector de mint (solo si usa mnemonic actual)
if (_useCurrentMnemonic) _buildMintSelector(),
// Input para mnemonic personalizado
if (!_useCurrentMnemonic) _buildMnemonicInput(),
// Resultado
if (_result != null) _buildResult(),
const SizedBox(height: AppDimensions.paddingMedium),
// Botón de acción
_buildActionButton(),
const SizedBox(height: AppDimensions.paddingSmall),
],
),
),
);
}
Widget _buildMnemonicOptions() {
final l10n = L10n.of(context)!;
return Column(
children: [
// Opción: Usar mnemonic actual
GestureDetector(
onTap: () => setState(() => _useCurrentMnemonic = true),
child: Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: _useCurrentMnemonic
? AppColors.primaryAction.withValues(alpha: 0.2)
: Colors.white.withValues(alpha: 0.05),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: _useCurrentMnemonic
? AppColors.primaryAction.withValues(alpha: 0.5)
: Colors.transparent,
),
),
child: Row(
children: [
Icon(
_useCurrentMnemonic
? LucideIcons.checkCircle
: LucideIcons.circle,
color: _useCurrentMnemonic
? AppColors.primaryAction
: AppColors.textSecondary,
size: 20,
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.useCurrentSeedPhrase,
style: const TextStyle(
fontFamily: 'Inter',
fontSize: 16,
fontWeight: FontWeight.w500,
color: Colors.white,
),
),
Text(
l10n.scanWithSavedWords,
style: TextStyle(
fontFamily: 'Inter',
fontSize: 12,
color: AppColors.textSecondary,
),
),
],
),
),
],
),
),
),
const SizedBox(height: 8),
// Opción: Usar otro mnemonic
GestureDetector(
onTap: () => setState(() => _useCurrentMnemonic = false),
child: Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: !_useCurrentMnemonic
? AppColors.primaryAction.withValues(alpha: 0.2)
: Colors.white.withValues(alpha: 0.05),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: !_useCurrentMnemonic
? AppColors.primaryAction.withValues(alpha: 0.5)
: Colors.transparent,
),
),
child: Row(
children: [
Icon(
!_useCurrentMnemonic
? LucideIcons.checkCircle
: LucideIcons.circle,
color: !_useCurrentMnemonic
? AppColors.primaryAction
: AppColors.textSecondary,
size: 20,
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.useOtherSeedPhrase,
style: const TextStyle(
fontFamily: 'Inter',
fontSize: 16,
fontWeight: FontWeight.w500,
color: Colors.white,
),
),
Text(
l10n.recoverFromOtherWords,
style: TextStyle(
fontFamily: 'Inter',
fontSize: 12,
color: AppColors.textSecondary,
),
),
],
),
),
],
),
),
),
],
);
}
Widget _buildMintSelector() {
final l10n = L10n.of(context)!;
if (_isLoadingMints) {
return const Padding(
padding: EdgeInsets.all(16),
child: Center(
child: SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(
color: AppColors.primaryAction,
strokeWidth: 2,
),
),
),
);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.mintsToScan,
style: TextStyle(
fontFamily: 'Inter',
fontSize: 14,
fontWeight: FontWeight.w500,
color: AppColors.textSecondary,
),
),
const SizedBox(height: 8),
// Opción: Escanear todos
GestureDetector(
onTap: () => setState(() => _scanAllMints = true),
child: Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: _scanAllMints
? AppColors.primaryAction.withValues(alpha: 0.2)
: Colors.white.withValues(alpha: 0.05),
borderRadius: BorderRadius.circular(10),
border: Border.all(
color: _scanAllMints
? AppColors.primaryAction.withValues(alpha: 0.5)
: Colors.transparent,
),
),
child: Row(
children: [
Icon(
_scanAllMints ? LucideIcons.checkCircle : LucideIcons.circle,
color: _scanAllMints
? AppColors.primaryAction
: AppColors.textSecondary,
size: 18,
),
const SizedBox(width: 10),
Text(
l10n.allMints(_availableMints.length),
style: TextStyle(
fontFamily: 'Inter',
fontSize: 14,
color: Colors.white,
fontWeight: _scanAllMints ? FontWeight.w500 : FontWeight.normal,
),
),
],
),
),
),
const SizedBox(height: 8),
// Opción: Mint específico
GestureDetector(
onTap: () => setState(() => _scanAllMints = false),
child: Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: !_scanAllMints
? AppColors.primaryAction.withValues(alpha: 0.2)
: Colors.white.withValues(alpha: 0.05),
borderRadius: BorderRadius.circular(10),
border: Border.all(
color: !_scanAllMints
? AppColors.primaryAction.withValues(alpha: 0.5)
: Colors.transparent,
),
),
child: Row(
children: [
Icon(
!_scanAllMints ? LucideIcons.checkCircle : LucideIcons.circle,
color: !_scanAllMints
? AppColors.primaryAction
: AppColors.textSecondary,
size: 18,
),
const SizedBox(width: 10),
Text(
l10n.specificMint,
style: const TextStyle(
fontFamily: 'Inter',
fontSize: 14,
color: Colors.white,
),
),
],
),
),
),
// Dropdown de mints (solo si selecciona específico)
if (!_scanAllMints && _availableMints.isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: 8),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.05),
borderRadius: BorderRadius.circular(10),
border: Border.all(
color: Colors.white.withValues(alpha: 0.1),
),
),
child: DropdownButton<String>(
value: _selectedMintUrl,
isExpanded: true,
dropdownColor: AppColors.deepVoidPurple,
underline: const SizedBox(),
icon: Icon(
LucideIcons.chevronDown,
color: AppColors.textSecondary,
size: 20,
),
items: _availableMints.map((url) {
final host = Uri.parse(url).host;
return DropdownMenuItem(
value: url,
child: Text(
host,
style: const TextStyle(
fontFamily: 'Inter',
fontSize: 14,
color: Colors.white,
),
),
);
}).toList(),
onChanged: (value) {
if (value != null) {
setState(() => _selectedMintUrl = value);
}
},
),
),
),
],
);
}
Widget _buildMnemonicInput() {
return Padding(
padding: const EdgeInsets.only(top: AppDimensions.paddingMedium),
child: TextField(
controller: _mnemonicController,
autocorrect: false,
enableSuggestions: false,
textCapitalization: TextCapitalization.none,
maxLines: 3,
style: const TextStyle(
fontFamily: 'Inter',
fontSize: 14,
color: Colors.white,
),
decoration: InputDecoration(
hintText: L10n.of(context)!.enterMnemonicWords,
hintStyle: TextStyle(
fontFamily: 'Inter',
fontSize: 14,
color: Colors.white.withValues(alpha: 0.3),
),
filled: true,
fillColor: Colors.white.withValues(alpha: 0.05),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
),
contentPadding: const EdgeInsets.all(16),
),
),
);
}
Widget _buildResult() {
return Container(
margin: const EdgeInsets.only(top: AppDimensions.paddingMedium),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: _isSuccess
? AppColors.success.withValues(alpha: 0.1)
: AppColors.error.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: _isSuccess
? AppColors.success.withValues(alpha: 0.3)
: AppColors.error.withValues(alpha: 0.3),
),
),
child: Row(
children: [
Icon(
_isSuccess ? LucideIcons.checkCircle : LucideIcons.alertCircle,
color: _isSuccess ? AppColors.success : AppColors.error,
size: 20,
),
const SizedBox(width: 12),
Expanded(
child: Text(
_result!,
style: TextStyle(
fontFamily: 'Inter',
fontSize: 14,
color: _isSuccess ? AppColors.success : AppColors.error,
),
),
),
],
),
);
}
Widget _buildActionButton() {
return SizedBox(
width: double.infinity,
child: GestureDetector(
onTap: _isLoading ? null : _startRecover,
child: Container(
padding: const EdgeInsets.symmetric(vertical: 16),
decoration: BoxDecoration(
gradient: _isLoading
? null
: const LinearGradient(
colors: [AppColors.primaryAction, Color(0xFFFF9100)],
),
color: _isLoading ? Colors.grey : null,
borderRadius: BorderRadius.circular(16),
),
child: Center(
child: _isLoading
? const SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(
color: Colors.white,
strokeWidth: 2,
),
)
: Text(
L10n.of(context)!.scanMints,
style: const TextStyle(
fontFamily: 'Inter',
fontSize: 16,
fontWeight: FontWeight.w600,
color: Colors.white,
),
),
),
),
),
);
}
Future<void> _startRecover() async {
if (!mounted) return;
final l10n = L10n.of(context)!;
setState(() {
_isLoading = true;
_result = null;
});
try {
final walletProvider = context.read<WalletProvider>();
if (_useCurrentMnemonic) {
// Usar mnemonic actual
if (_scanAllMints) {
// Escanear todos los mints (retorna Map<String, Map<String, BigInt>>)
final results = await walletProvider.restoreAllMints();
int mintsRecovered = 0;
int mintsWithError = 0;
final recoveredDetails = <String>[];
for (final mintEntry in results.entries) {
final unitBalances = mintEntry.value;
bool hasError = false;
bool hasRecovered = false;
for (final unitEntry in unitBalances.entries) {
final unit = unitEntry.key;
final balance = unitEntry.value;
if (balance < BigInt.zero) {
hasError = true;
} else if (balance > BigInt.zero) {
final formatted = UnitFormatter.formatBalance(balance, unit);
final label = UnitFormatter.getUnitLabel(unit);
recoveredDetails.add('$formatted $label');
hasRecovered = true;
}
}
if (hasError) mintsWithError++;
if (hasRecovered) mintsRecovered++;
}
// Recuperar proofs pendientes (sends no reclamados, melts fallidos)
final reclaimMap = await walletProvider.reclaimPendingProofs();
if (!mounted) return;
// Agregar cada unidad reclamada por separado
if (reclaimMap.isNotEmpty) {
for (final entry in reclaimMap.entries) {
final unit = entry.key;
final reclaim = entry.value;
final formatted = UnitFormatter.formatBalance(reclaim.amount, unit);
final label = UnitFormatter.getUnitLabel(unit);
recoveredDetails.add('$formatted $label (${reclaim.count} proofs)');
}
mintsRecovered++;
}
setState(() {
_isSuccess = true;
if (recoveredDetails.isNotEmpty) {
_result = l10n.recoveredTokens(recoveredDetails.join(", "), mintsRecovered);
} else {
_result = l10n.scanCompleteNoTokens;
}
if (mintsWithError > 0) {
_result = '$_result ${l10n.mintsWithError(mintsWithError)}';
}
});
} else {
// Escanear mint específico (retorna Map<String, BigInt>)
if (_selectedMintUrl == null) {
if (!mounted) return;
setState(() {
_isSuccess = false;
_result = l10n.selectMintToScan;
});
return;
}
final unitBalances = await walletProvider.restoreFromMint(_selectedMintUrl!);
final mintHost = UnitFormatter.getMintDisplayName(_selectedMintUrl!);
final recoveredDetails = <String>[];
for (final entry in unitBalances.entries) {
final unit = entry.key;
final balance = entry.value;
if (balance > BigInt.zero) {
final formatted = UnitFormatter.formatBalance(balance, unit);
final label = UnitFormatter.getUnitLabel(unit);
recoveredDetails.add('$formatted $label');
}
}
// Recuperar proofs pendientes de este mint
final reclaimMap = await walletProvider.reclaimPendingProofs(mintUrl: _selectedMintUrl);
if (!mounted) return;
// Agregar cada unidad reclamada por separado
for (final entry in reclaimMap.entries) {
final unit = entry.key;
final reclaim = entry.value;
final formatted = UnitFormatter.formatBalance(reclaim.amount, unit);
final label = UnitFormatter.getUnitLabel(unit);
recoveredDetails.add('$formatted $label (${reclaim.count} proofs)');
}
setState(() {
_isSuccess = true;
if (recoveredDetails.isNotEmpty) {
_result = l10n.recoveredFromMint(recoveredDetails.join(", "), mintHost);
} else {
_result = l10n.noTokensFoundInMint(mintHost);
}
});
}
} else {
// Usar otro mnemonic
final mnemonic = _mnemonicController.text.trim().toLowerCase();
final words = mnemonic.split(RegExp(r'\s+'));
if (words.length != 12 && words.length != 24) {
if (!mounted) return;
setState(() {
_isSuccess = false;
_result = l10n.mnemonicMustHaveWords;
});
return;
}
// Obtener lista de mints actuales para escanear
final mintUrls = walletProvider.mintUrls;
if (mintUrls.isEmpty) {
if (!mounted) return;
setState(() {
_isSuccess = false;
_result = l10n.noConnectedMintsToScan;
});
return;
}
final recovered = await walletProvider.restoreWithMnemonic(
mnemonic,
mintUrls,
);
if (!mounted) return;
setState(() {
_isSuccess = true;
if (recovered > BigInt.zero) {
// Usamos la unidad activa como aproximación para el formato
final activeUnit = walletProvider.activeUnit;
final formatted = UnitFormatter.formatBalance(recovered, activeUnit);
final label = UnitFormatter.getUnitLabel(activeUnit);
_result = l10n.recoveredAndTransferred(formatted, label);
} else {
_result = l10n.noTokensForMnemonic;
}
});
}
} catch (e) {
if (!mounted) return;
setState(() {
_isSuccess = false;
_result = '${l10n.error}: $e';
});
} finally {
if (mounted) {
setState(() {
_isLoading = false;
});
}
}
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+123 -6
View File
@@ -10,9 +10,29 @@ import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated.dart';
import 'payment_request.dart';
import 'token.dart';
// These functions are ignored because they are not marked as `pub`: `mint_url`, `unit`, `update_balance_streams`
// These functions are ignored because they are not marked as `pub`: `empty`, `from_states`, `into_result`, `mint_url`, `unit`, `update_balance_streams`
// These types are ignored because they are neither used by any `pub` functions nor (for structs and enums) marked `#[frb(unignore)]`: `StateBuckets`
// These function are ignored because they are on traits that is not defined in current crate (put an empty `#[frb]` on it to unignore): `assert_receiver_is_total_eq`, `assert_receiver_is_total_eq`, `assert_receiver_is_total_eq`, `clone`, `clone`, `clone`, `clone`, `cmp`, `eq`, `eq`, `eq`, `fmt`, `fmt`, `from`, `from`, `from`, `from`, `from`, `from`, `into`, `partial_cmp`, `try_into`, `try_into`
// Rust type: RustOpaqueMoi<flutter_rust_bridge::for_generated::RustAutoOpaqueInner<PreparedMelt>>
abstract class PreparedMelt implements RustOpaqueInterface {
BigInt get amount;
BigInt get feeReserve;
BigInt get inputFee;
BigInt get swapFee;
set amount(BigInt amount);
set feeReserve(BigInt feeReserve);
set inputFee(BigInt inputFee);
set swapFee(BigInt swapFee);
}
// Rust type: RustOpaqueMoi<flutter_rust_bridge::for_generated::RustAutoOpaqueInner<PreparedSend>>
abstract class PreparedSend implements RustOpaqueInterface {
BigInt get amount;
@@ -44,12 +64,39 @@ abstract class Wallet implements RustOpaqueInterface {
Future<BigInt> balance();
Future<void> cancelMelt({required PreparedMelt melt});
Future<void> cancelSend({required PreparedSend send});
Future<void> checkAllMintQuotes();
/// Ask the mint for the current state of a single quote by id and
/// return the refreshed MintQuote. Local store is updated as a side
/// effect inside CDK.
Future<MintQuote> checkMintQuoteStatus({required String quoteId});
Future<void> checkPendingTransactions();
/// Observe-only counterpart of `reclaim_proofs_by_ys`. Queries the mint
/// for the state of the target Ys but **does NOT** revert Unspent proofs
/// to the local Unspent state.
///
/// Use this for periodic reconciliation where we want to auto-settle
/// sends the receiver already claimed, without accidentally cancelling
/// sends the receiver has not claimed yet (which `reclaim_proofs_by_ys`
/// would do by calling `unreserve_proofs`).
///
/// Return semantics:
/// - `count` / `amount` always 0 (nothing is recovered).
/// - `pending_count` / `spent_count` reflect the mint's report.
/// - `spent_count` also includes target Ys that are no longer present
/// in local PendingSpent (already reconciled or externally cleaned).
/// The caller can infer "all resolved as spent" from
/// `spent_count == target_ys.len() && pending_count == 0`.
Future<ReclaimResult> checkProofsByYs({required List<String> ys});
Future<BigInt> confirmMelt({required PreparedMelt melt});
/// Create a NUT-18 payment request with Nostr transport.
///
/// Builds a PaymentRequest with the wallet's mint URL and unit,
@@ -63,16 +110,24 @@ abstract class Wallet implements RustOpaqueInterface {
Future<MintInfo?> getMint();
/// Return pending (unissued) mint quotes known to the local store,
/// filtered to this wallet's mint/unit.
Future<List<MintQuote>> getUnissuedMintQuotes();
Future<bool> isTokenSpent({required Token token});
Future<List<Transaction>> listTransactions({TransactionDirection? direction});
Future<BigInt> melt({required MeltQuote quote});
Future<MeltQuote> meltQuote({required String request});
Stream<MintQuote> mint({required BigInt amount, String? description});
/// Mint proofs for a paid quote and return an Issued MintQuote with
/// `transaction_id` and `token` populated — same shape the `mint()`
/// stream emits on Issued. Use this to recover metadata for quotes
/// that were paid while the app was killed.
Future<MintQuote> mintByQuoteId({required String quoteId});
factory Wallet({
required String mintUrl,
required String unit,
@@ -97,16 +152,35 @@ abstract class Wallet implements RustOpaqueInterface {
BigInt? customAmount,
});
Future<PreparedMelt> prepareMelt({required MeltQuote quote});
Future<PreparedSend> prepareSend({required BigInt amount, SendOptions? opts});
Future<BigInt> receive({required Token token, ReceiveOptions? opts});
/// Check pending-spent proofs with the mint and revert unspent ones.
/// Returns the number of proofs recovered.
Future<BigInt> reclaimPendingProofs();
/// Returns per-state buckets (unspent reverted, still pending, spent).
Future<ReclaimResult> reclaimPendingProofs();
/// Reclaim proofs belonging to a specific send identified by its Y values.
/// Scoped variant of `reclaim_pending_proofs`: only proofs whose Y matches
/// the input list are checked with the mint and reverted if Unspent.
///
/// Empty returns:
/// - Input `ys` empty or all unparseable → `ReclaimResult::empty()`.
/// - None of the target Ys are in local pending anymore (a previous
/// reconciliation already processed them) → `spent_count` is set to
/// the number of targets, signalling to callers that the send is
/// definitively finished and any local record can be removed.
Future<ReclaimResult> reclaimProofsByYs({required List<String> ys});
Future<void> recoverIncompleteSagas();
/// Recalcula el balance desde la DB y lo empuja al stream.
/// Llamar después de manipulaciones directas al DB (ej: offline send
/// marking proofs as PendingSpent fuera de la API de CDK).
Future<void> refreshBalance();
Future<void> restore();
Future<SendResult> send({
@@ -182,7 +256,7 @@ class MintQuote {
final Token? token;
final String? error;
/// Deterministic transaction ID (set when state == Issued)
/// Deterministic transaction ID (set when proofs are available, typically on Issued)
final String? transactionId;
const MintQuote({
@@ -245,6 +319,49 @@ class ReceiveOptions {
preimages == other.preimages;
}
class ReclaimResult {
/// Proofs reverted from PendingSpent to Unspent. Also the number of
/// recovered proofs reflected in `amount`.
final BigInt count;
/// Total value of the recovered proofs.
final BigInt amount;
/// Proofs the mint reports as still Pending (receiver mid-swap). The
/// caller should keep the record for retry — a later reconciliation
/// will resolve them.
final BigInt pendingCount;
/// Proofs the mint reports as Spent (receiver already claimed). When
/// `count == 0 && pending_count == 0`, the send is definitively
/// finished and the caller can delete its local record.
final BigInt spentCount;
const ReclaimResult({
required this.count,
required this.amount,
required this.pendingCount,
required this.spentCount,
});
@override
int get hashCode =>
count.hashCode ^
amount.hashCode ^
pendingCount.hashCode ^
spentCount.hashCode;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is ReclaimResult &&
runtimeType == other.runtimeType &&
count == other.count &&
amount == other.amount &&
pendingCount == other.pendingCount &&
spentCount == other.spentCount;
}
class SendOptions {
final String? pubkey;
final bool? includeFee;
File diff suppressed because it is too large Load Diff
+135
View File
@@ -31,6 +31,10 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
get rust_arc_decrement_strong_count_NostrListenerHandlePtr => wire
._rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerNostrListenerHandlePtr;
CrossPlatformFinalizerArg
get rust_arc_decrement_strong_count_PreparedMeltPtr => wire
._rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerPreparedMeltPtr;
CrossPlatformFinalizerArg
get rust_arc_decrement_strong_count_PreparedSendPtr => wire
._rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerPreparedSendPtr;
@@ -62,6 +66,12 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
dynamic raw,
);
@protected
PreparedMelt
dco_decode_Auto_Owned_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerPreparedMelt(
dynamic raw,
);
@protected
PreparedSend
dco_decode_Auto_Owned_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerPreparedSend(
@@ -92,6 +102,12 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
dynamic raw,
);
@protected
PreparedMelt
dco_decode_Auto_RefMut_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerPreparedMelt(
dynamic raw,
);
@protected
PreparedSend
dco_decode_Auto_RefMut_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerPreparedSend(
@@ -122,6 +138,12 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
dynamic raw,
);
@protected
PreparedMelt
dco_decode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerPreparedMelt(
dynamic raw,
);
@protected
PreparedSend
dco_decode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerPreparedSend(
@@ -161,6 +183,12 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
dynamic raw,
);
@protected
PreparedMelt
dco_decode_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerPreparedMelt(
dynamic raw,
);
@protected
PreparedSend
dco_decode_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerPreparedSend(
@@ -271,6 +299,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected
List<MintMethodSettings> dco_decode_list_mint_method_settings(dynamic raw);
@protected
List<MintQuote> dco_decode_list_mint_quote(dynamic raw);
@protected
List<int> dco_decode_list_prim_u_8_loose(dynamic raw);
@@ -372,6 +403,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected
ReceiveOptions dco_decode_receive_options(dynamic raw);
@protected
ReclaimResult dco_decode_reclaim_result(dynamic raw);
@protected
(String, String) dco_decode_record_string_string(dynamic raw);
@@ -426,6 +460,12 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
SseDeserializer deserializer,
);
@protected
PreparedMelt
sse_decode_Auto_Owned_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerPreparedMelt(
SseDeserializer deserializer,
);
@protected
PreparedSend
sse_decode_Auto_Owned_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerPreparedSend(
@@ -456,6 +496,12 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
SseDeserializer deserializer,
);
@protected
PreparedMelt
sse_decode_Auto_RefMut_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerPreparedMelt(
SseDeserializer deserializer,
);
@protected
PreparedSend
sse_decode_Auto_RefMut_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerPreparedSend(
@@ -486,6 +532,12 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
SseDeserializer deserializer,
);
@protected
PreparedMelt
sse_decode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerPreparedMelt(
SseDeserializer deserializer,
);
@protected
PreparedSend
sse_decode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerPreparedSend(
@@ -527,6 +579,12 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
SseDeserializer deserializer,
);
@protected
PreparedMelt
sse_decode_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerPreparedMelt(
SseDeserializer deserializer,
);
@protected
PreparedSend
sse_decode_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerPreparedSend(
@@ -651,6 +709,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
SseDeserializer deserializer,
);
@protected
List<MintQuote> sse_decode_list_mint_quote(SseDeserializer deserializer);
@protected
List<int> sse_decode_list_prim_u_8_loose(SseDeserializer deserializer);
@@ -776,6 +837,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected
ReceiveOptions sse_decode_receive_options(SseDeserializer deserializer);
@protected
ReclaimResult sse_decode_reclaim_result(SseDeserializer deserializer);
@protected
(String, String) sse_decode_record_string_string(
SseDeserializer deserializer,
@@ -839,6 +903,13 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
SseSerializer serializer,
);
@protected
void
sse_encode_Auto_Owned_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerPreparedMelt(
PreparedMelt self,
SseSerializer serializer,
);
@protected
void
sse_encode_Auto_Owned_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerPreparedSend(
@@ -874,6 +945,13 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
SseSerializer serializer,
);
@protected
void
sse_encode_Auto_RefMut_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerPreparedMelt(
PreparedMelt self,
SseSerializer serializer,
);
@protected
void
sse_encode_Auto_RefMut_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerPreparedSend(
@@ -909,6 +987,13 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
SseSerializer serializer,
);
@protected
void
sse_encode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerPreparedMelt(
PreparedMelt self,
SseSerializer serializer,
);
@protected
void
sse_encode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerPreparedSend(
@@ -957,6 +1042,13 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
SseSerializer serializer,
);
@protected
void
sse_encode_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerPreparedMelt(
PreparedMelt self,
SseSerializer serializer,
);
@protected
void
sse_encode_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerPreparedSend(
@@ -1114,6 +1206,12 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
SseSerializer serializer,
);
@protected
void sse_encode_list_mint_quote(
List<MintQuote> self,
SseSerializer serializer,
);
@protected
void sse_encode_list_prim_u_8_loose(List<int> self, SseSerializer serializer);
@@ -1270,6 +1368,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
SseSerializer serializer,
);
@protected
void sse_encode_reclaim_result(ReclaimResult self, SseSerializer serializer);
@protected
void sse_encode_record_string_string(
(String, String) self,
@@ -1404,6 +1505,40 @@ class RustLibWire implements BaseWire {
_rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerNostrListenerHandlePtr
.asFunction<void Function(ffi.Pointer<ffi.Void>)>();
void
rust_arc_increment_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerPreparedMelt(
ffi.Pointer<ffi.Void> ptr,
) {
return _rust_arc_increment_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerPreparedMelt(
ptr,
);
}
late final _rust_arc_increment_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerPreparedMeltPtr =
_lookup<ffi.NativeFunction<ffi.Void Function(ffi.Pointer<ffi.Void>)>>(
'frbgen_elcaju_rust_arc_increment_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerPreparedMelt',
);
late final _rust_arc_increment_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerPreparedMelt =
_rust_arc_increment_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerPreparedMeltPtr
.asFunction<void Function(ffi.Pointer<ffi.Void>)>();
void
rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerPreparedMelt(
ffi.Pointer<ffi.Void> ptr,
) {
return _rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerPreparedMelt(
ptr,
);
}
late final _rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerPreparedMeltPtr =
_lookup<ffi.NativeFunction<ffi.Void Function(ffi.Pointer<ffi.Void>)>>(
'frbgen_elcaju_rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerPreparedMelt',
);
late final _rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerPreparedMelt =
_rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerPreparedMeltPtr
.asFunction<void Function(ffi.Pointer<ffi.Void>)>();
void
rust_arc_increment_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerPreparedSend(
ffi.Pointer<ffi.Void> ptr,
+127
View File
@@ -33,6 +33,10 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
get rust_arc_decrement_strong_count_NostrListenerHandlePtr => wire
.rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerNostrListenerHandle;
CrossPlatformFinalizerArg
get rust_arc_decrement_strong_count_PreparedMeltPtr => wire
.rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerPreparedMelt;
CrossPlatformFinalizerArg
get rust_arc_decrement_strong_count_PreparedSendPtr => wire
.rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerPreparedSend;
@@ -64,6 +68,12 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
dynamic raw,
);
@protected
PreparedMelt
dco_decode_Auto_Owned_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerPreparedMelt(
dynamic raw,
);
@protected
PreparedSend
dco_decode_Auto_Owned_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerPreparedSend(
@@ -94,6 +104,12 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
dynamic raw,
);
@protected
PreparedMelt
dco_decode_Auto_RefMut_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerPreparedMelt(
dynamic raw,
);
@protected
PreparedSend
dco_decode_Auto_RefMut_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerPreparedSend(
@@ -124,6 +140,12 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
dynamic raw,
);
@protected
PreparedMelt
dco_decode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerPreparedMelt(
dynamic raw,
);
@protected
PreparedSend
dco_decode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerPreparedSend(
@@ -163,6 +185,12 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
dynamic raw,
);
@protected
PreparedMelt
dco_decode_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerPreparedMelt(
dynamic raw,
);
@protected
PreparedSend
dco_decode_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerPreparedSend(
@@ -273,6 +301,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected
List<MintMethodSettings> dco_decode_list_mint_method_settings(dynamic raw);
@protected
List<MintQuote> dco_decode_list_mint_quote(dynamic raw);
@protected
List<int> dco_decode_list_prim_u_8_loose(dynamic raw);
@@ -374,6 +405,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected
ReceiveOptions dco_decode_receive_options(dynamic raw);
@protected
ReclaimResult dco_decode_reclaim_result(dynamic raw);
@protected
(String, String) dco_decode_record_string_string(dynamic raw);
@@ -428,6 +462,12 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
SseDeserializer deserializer,
);
@protected
PreparedMelt
sse_decode_Auto_Owned_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerPreparedMelt(
SseDeserializer deserializer,
);
@protected
PreparedSend
sse_decode_Auto_Owned_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerPreparedSend(
@@ -458,6 +498,12 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
SseDeserializer deserializer,
);
@protected
PreparedMelt
sse_decode_Auto_RefMut_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerPreparedMelt(
SseDeserializer deserializer,
);
@protected
PreparedSend
sse_decode_Auto_RefMut_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerPreparedSend(
@@ -488,6 +534,12 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
SseDeserializer deserializer,
);
@protected
PreparedMelt
sse_decode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerPreparedMelt(
SseDeserializer deserializer,
);
@protected
PreparedSend
sse_decode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerPreparedSend(
@@ -529,6 +581,12 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
SseDeserializer deserializer,
);
@protected
PreparedMelt
sse_decode_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerPreparedMelt(
SseDeserializer deserializer,
);
@protected
PreparedSend
sse_decode_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerPreparedSend(
@@ -653,6 +711,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
SseDeserializer deserializer,
);
@protected
List<MintQuote> sse_decode_list_mint_quote(SseDeserializer deserializer);
@protected
List<int> sse_decode_list_prim_u_8_loose(SseDeserializer deserializer);
@@ -778,6 +839,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected
ReceiveOptions sse_decode_receive_options(SseDeserializer deserializer);
@protected
ReclaimResult sse_decode_reclaim_result(SseDeserializer deserializer);
@protected
(String, String) sse_decode_record_string_string(
SseDeserializer deserializer,
@@ -841,6 +905,13 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
SseSerializer serializer,
);
@protected
void
sse_encode_Auto_Owned_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerPreparedMelt(
PreparedMelt self,
SseSerializer serializer,
);
@protected
void
sse_encode_Auto_Owned_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerPreparedSend(
@@ -876,6 +947,13 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
SseSerializer serializer,
);
@protected
void
sse_encode_Auto_RefMut_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerPreparedMelt(
PreparedMelt self,
SseSerializer serializer,
);
@protected
void
sse_encode_Auto_RefMut_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerPreparedSend(
@@ -911,6 +989,13 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
SseSerializer serializer,
);
@protected
void
sse_encode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerPreparedMelt(
PreparedMelt self,
SseSerializer serializer,
);
@protected
void
sse_encode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerPreparedSend(
@@ -959,6 +1044,13 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
SseSerializer serializer,
);
@protected
void
sse_encode_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerPreparedMelt(
PreparedMelt self,
SseSerializer serializer,
);
@protected
void
sse_encode_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerPreparedSend(
@@ -1116,6 +1208,12 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
SseSerializer serializer,
);
@protected
void sse_encode_list_mint_quote(
List<MintQuote> self,
SseSerializer serializer,
);
@protected
void sse_encode_list_prim_u_8_loose(List<int> self, SseSerializer serializer);
@@ -1272,6 +1370,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
SseSerializer serializer,
);
@protected
void sse_encode_reclaim_result(ReclaimResult self, SseSerializer serializer);
@protected
void sse_encode_record_string_string(
(String, String) self,
@@ -1361,6 +1462,22 @@ class RustLibWire implements BaseWire {
ptr,
);
void
rust_arc_increment_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerPreparedMelt(
int ptr,
) => wasmModule
.rust_arc_increment_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerPreparedMelt(
ptr,
);
void
rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerPreparedMelt(
int ptr,
) => wasmModule
.rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerPreparedMelt(
ptr,
);
void
rust_arc_increment_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerPreparedSend(
int ptr,
@@ -1452,6 +1569,16 @@ extension type RustLibWasmModule._(JSObject _) implements JSObject {
int ptr,
);
external void
rust_arc_increment_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerPreparedMelt(
int ptr,
);
external void
rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerPreparedMelt(
int ptr,
);
external void
rust_arc_increment_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerPreparedSend(
int ptr,
+15 -2
View File
@@ -58,6 +58,9 @@ class AnimatedActionButton extends StatefulWidget {
/// Si mostrar el icono (default: false)
final bool showIcon;
/// Si el icono va después del texto (default: false = antes del texto)
final bool iconTrailing;
/// Ancho del botón (default: expandir al padre)
final double? width;
@@ -73,6 +76,7 @@ class AnimatedActionButton extends StatefulWidget {
this.backgroundColor,
this.icon,
this.showIcon = false,
this.iconTrailing = false,
this.width,
this.height,
});
@@ -300,8 +304,8 @@ class _AnimatedActionButtonState extends State<AnimatedActionButton>
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: [
// Icono opcional
if (widget.showIcon && widget.icon != null) ...[
// Icono leading (antes del texto)
if (widget.showIcon && widget.icon != null && !widget.iconTrailing) ...[
Icon(
widget.icon,
color: Colors.white,
@@ -319,6 +323,15 @@ class _AnimatedActionButtonState extends State<AnimatedActionButton>
color: Colors.white,
),
),
// Icono trailing (después del texto)
if (widget.showIcon && widget.icon != null && widget.iconTrailing) ...[
const SizedBox(width: AppDimensions.paddingSmall),
Icon(
widget.icon,
color: Colors.white,
size: widget.type == ButtonType.navigation ? 28 : 20,
),
],
],
),
),
@@ -0,0 +1,80 @@
import 'package:flutter/material.dart';
import '../../core/constants/colors.dart';
import '../../core/constants/dimensions.dart';
/// Frame común para modales tipo bottom sheet.
///
/// Aplica SafeArea(top: false) para que los botones internos no queden
/// tapados por la barra de navegación del sistema (gestos o 3 botones).
///
/// Usá [respectKeyboard]: true cuando el modal contenga TextField, para
/// combinar el inset de la barra con el del teclado (viewInsets.bottom).
class BottomSheetContainer extends StatelessWidget {
final Widget child;
final EdgeInsetsGeometry padding;
final bool respectKeyboard;
const BottomSheetContainer({
super.key,
required this.child,
this.padding = const EdgeInsets.all(AppDimensions.paddingMedium),
this.respectKeyboard = false,
});
@override
Widget build(BuildContext context) {
final mq = MediaQuery.of(context);
// viewPadding.bottom es el inset crudo de la nav bar del sistema
// (no se consume aunque un SafeArea padre ya lo haya absorbido).
final navBarInset = mq.viewPadding.bottom;
// Combinar el padding base con el extra del navBar abajo.
final resolvedPadding = padding.resolve(Directionality.of(context));
final effectivePadding = EdgeInsets.only(
left: resolvedPadding.left,
top: resolvedPadding.top,
right: resolvedPadding.right,
bottom: resolvedPadding.bottom + navBarInset,
);
final frame = Container(
decoration: BoxDecoration(
color: AppColors.deepVoidPurple,
borderRadius: const BorderRadius.vertical(
top: Radius.circular(AppDimensions.radiusXLarge),
),
border: Border.all(
color: Colors.white.withValues(alpha: 0.1),
width: 1,
),
),
child: Padding(padding: effectivePadding, child: child),
);
if (respectKeyboard) {
return Padding(
padding: EdgeInsets.only(bottom: mq.viewInsets.bottom),
child: frame,
);
}
return frame;
}
}
/// Handle visual en la parte superior del modal (la barrita).
class BottomSheetHandle extends StatelessWidget {
const BottomSheetHandle({super.key});
@override
Widget build(BuildContext context) {
return Container(
margin: const EdgeInsets.only(bottom: 16),
width: 40,
height: 4,
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.3),
borderRadius: BorderRadius.circular(2),
),
);
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
name: elcaju
description: "ElCaju - Tu wallet de ecash privado"
publish_to: 'none'
version: 0.2.0+3
version: 0.3.0+4
environment:
sdk: ^3.11.0
+350 -21
View File
@@ -411,6 +411,62 @@ impl Wallet {
Ok(())
}
/// Return pending (unissued) mint quotes known to the local store,
/// filtered to this wallet's mint/unit.
pub async fn get_unissued_mint_quotes(&self) -> Result<Vec<MintQuote>, Error> {
let quotes = self.inner.get_unissued_mint_quotes().await?;
Ok(quotes.into_iter().map(MintQuote::from).collect())
}
/// Ask the mint for the current state of a single quote by id and
/// return the refreshed MintQuote. Local store is updated as a side
/// effect inside CDK.
pub async fn check_mint_quote_status(&self, quote_id: String) -> Result<MintQuote, Error> {
let quote = self.inner.check_mint_quote_status(&quote_id).await?;
Ok(MintQuote::from(quote))
}
/// Mint proofs for a paid quote and return an Issued MintQuote with
/// `transaction_id` and `token` populated — same shape the `mint()`
/// stream emits on Issued. Use this to recover metadata for quotes
/// that were paid while the app was killed.
pub async fn mint_by_quote_id(&self, quote_id: String) -> Result<MintQuote, Error> {
let mint_url = self.mint_url()?;
let unit = self.unit();
let cdk_quote = self.inner.check_mint_quote_status(&quote_id).await?;
let expiry = cdk_quote.expiry;
let request = cdk_quote.request.clone();
let mint_proofs = self
.inner
.mint(&quote_id, SplitTarget::None, None)
.await?;
let tx_id = match TransactionId::try_from(mint_proofs.clone()) {
Ok(id) => Some(id.to_string()),
Err(e) => {
info!("Failed to compute mint tx ID: {e}");
None
}
};
let mint_amount = mint_proofs.total_amount().unwrap_or_default();
let token = Token::try_from(CdkToken::new(mint_url, mint_proofs, None, unit)).ok();
self.update_balance_streams().await;
Ok(MintQuote {
id: quote_id,
request,
amount: Some(mint_amount.into()),
expiry: Some(expiry),
state: CdkMintQuoteState::Issued.into(),
token,
error: None,
transaction_id: tx_id,
})
}
// === Lightning Withdrawal (NUT-05) ===
pub async fn melt_quote(&self, request: String) -> Result<MeltQuote, Error> {
@@ -421,15 +477,52 @@ impl Wallet {
.into())
}
pub async fn melt(&self, quote: MeltQuote) -> Result<u64, Error> {
let melted = self
pub async fn prepare_melt(&self, quote: MeltQuote) -> Result<PreparedMelt, Error> {
let prepared = self
.inner
.prepare_melt(&quote.id, HashMap::new())
.await?
.confirm()
.await?;
Ok(PreparedMelt {
amount: prepared.amount().into(),
fee_reserve: prepared.quote().fee_reserve.into(),
swap_fee: prepared.swap_fee().into(),
input_fee: prepared.input_fee().into(),
operation_id: prepared.operation_id(),
cdk_quote: prepared.quote().clone(),
proofs: prepared.proofs().clone(),
proofs_to_swap: prepared.proofs_to_swap().clone(),
cdk_input_fee: prepared.input_fee(),
cdk_input_fee_without_swap: prepared.input_fee_without_swap(),
})
}
pub async fn confirm_melt(&self, melt: PreparedMelt) -> Result<u64, Error> {
let finalized = self
.inner
.confirm_prepared_melt(
melt.operation_id,
melt.cdk_quote,
melt.proofs,
melt.proofs_to_swap,
melt.cdk_input_fee,
melt.cdk_input_fee_without_swap,
HashMap::new(),
)
.await?;
self.update_balance_streams().await;
Ok(melted.total_amount().into())
Ok(finalized.total_amount().into())
}
pub async fn cancel_melt(&self, melt: PreparedMelt) -> Result<(), Error> {
self.inner
.cancel_prepared_melt(
melt.operation_id,
melt.proofs,
melt.proofs_to_swap,
)
.await?;
self.update_balance_streams().await;
Ok(())
}
// === Transactions ===
@@ -494,34 +587,166 @@ impl Wallet {
// === Reclaim orphaned proofs ===
/// Check pending-spent proofs with the mint and revert unspent ones.
/// Returns the number of proofs recovered.
pub async fn reclaim_pending_proofs(&self) -> Result<u64, Error> {
/// Returns per-state buckets (unspent reverted, still pending, spent).
pub async fn reclaim_pending_proofs(&self) -> Result<ReclaimResult, Error> {
let pending = self.inner.get_pending_spent_proofs().await?;
if pending.is_empty() {
return Ok(0);
return Ok(ReclaimResult::empty());
}
// Build a map of Y → amount for later lookup
let mut amount_by_y: HashMap<PublicKey, u64> = HashMap::new();
for proof in &pending {
if let Ok(y) = proof.y() {
amount_by_y.insert(y, u64::from(proof.amount));
}
}
// check_proofs_spent: queries mint AND removes spent proofs from local DB
let states = self.inner.check_proofs_spent(pending).await?;
let buckets = StateBuckets::from_states(states, &amount_by_y);
// Collect Y values of proofs the mint says are NOT spent
// Only reclaim proofs the mint explicitly reports as Unspent.
// Pending proofs (still being processed) must not be unreserved.
let unspent_ys: Vec<PublicKey> = states
.into_iter()
.filter(|s| s.state == ProofState::Unspent)
.map(|s| s.y)
.collect();
let count = unspent_ys.len() as u64;
if count > 0 {
if !buckets.unspent_ys.is_empty() {
// Revert from PendingSpent to Unspent
self.inner.unreserve_proofs(unspent_ys).await?;
self.inner.unreserve_proofs(buckets.unspent_ys.clone()).await?;
}
// Always refresh: check_proofs_spent may have removed spent proofs
self.update_balance_streams().await;
Ok(count)
Ok(buckets.into_result())
}
/// Reclaim proofs belonging to a specific send identified by its Y values.
/// Scoped variant of `reclaim_pending_proofs`: only proofs whose Y matches
/// the input list are checked with the mint and reverted if Unspent.
///
/// Empty returns:
/// - Input `ys` empty or all unparseable → `ReclaimResult::empty()`.
/// - None of the target Ys are in local pending anymore (a previous
/// reconciliation already processed them) → `spent_count` is set to
/// the number of targets, signalling to callers that the send is
/// definitively finished and any local record can be removed.
pub async fn reclaim_proofs_by_ys(&self, ys: Vec<String>) -> Result<ReclaimResult, Error> {
let target_ys: std::collections::HashSet<PublicKey> = ys
.iter()
.filter_map(|y| PublicKey::from_hex(y).ok())
.collect();
if target_ys.is_empty() {
return Ok(ReclaimResult::empty());
}
let total = target_ys.len() as u64;
let pending = self.inner.get_pending_spent_proofs().await?;
// Filter to only proofs with a Y in our target set.
let relevant: Vec<_> = pending
.into_iter()
.filter(|proof| proof.y().map(|y| target_ys.contains(&y)).unwrap_or(false))
.collect();
// Target Ys that aren't in local PendingSpent anymore are treated as
// spent — a previous `check_proofs_spent` call removed them from the
// local DB because the mint confirmed SPENT. This matches
// `check_proofs_by_ys` and keeps `ReclaimResult`'s bucket invariant
// (`unspent + pending + spent == total`) across both paths.
let resolved_out_of_pending = total.saturating_sub(relevant.len() as u64);
if relevant.is_empty() {
return Ok(ReclaimResult {
count: 0,
amount: 0,
pending_count: 0,
spent_count: total,
});
}
let mut amount_by_y: HashMap<PublicKey, u64> = HashMap::new();
for proof in &relevant {
if let Ok(y) = proof.y() {
amount_by_y.insert(y, u64::from(proof.amount));
}
}
let states = self.inner.check_proofs_spent(relevant).await?;
let buckets = StateBuckets::from_states(states, &amount_by_y);
if !buckets.unspent_ys.is_empty() {
self.inner.unreserve_proofs(buckets.unspent_ys.clone()).await?;
}
self.update_balance_streams().await;
let mut result = buckets.into_result();
result.spent_count += resolved_out_of_pending;
Ok(result)
}
/// Observe-only counterpart of `reclaim_proofs_by_ys`. Queries the mint
/// for the state of the target Ys but **does NOT** revert Unspent proofs
/// to the local Unspent state.
///
/// Use this for periodic reconciliation where we want to auto-settle
/// sends the receiver already claimed, without accidentally cancelling
/// sends the receiver has not claimed yet (which `reclaim_proofs_by_ys`
/// would do by calling `unreserve_proofs`).
///
/// Return semantics:
/// - `count` / `amount` always 0 (nothing is recovered).
/// - `pending_count` / `spent_count` reflect the mint's report.
/// - `spent_count` also includes target Ys that are no longer present
/// in local PendingSpent (already reconciled or externally cleaned).
/// The caller can infer "all resolved as spent" from
/// `spent_count == target_ys.len() && pending_count == 0`.
pub async fn check_proofs_by_ys(&self, ys: Vec<String>) -> Result<ReclaimResult, Error> {
let target_ys: std::collections::HashSet<PublicKey> = ys
.iter()
.filter_map(|y| PublicKey::from_hex(y).ok())
.collect();
if target_ys.is_empty() {
return Ok(ReclaimResult::empty());
}
let total = target_ys.len() as u64;
let pending = self.inner.get_pending_spent_proofs().await?;
let relevant: Vec<_> = pending
.into_iter()
.filter(|proof| proof.y().map(|y| target_ys.contains(&y)).unwrap_or(false))
.collect();
// Ys we expected to be in local PendingSpent but aren't anymore.
// Safe assumption: they were reconciled out earlier because the
// mint confirmed them spent (check_proofs_spent removed them).
let resolved_out_of_pending = total.saturating_sub(relevant.len() as u64);
if relevant.is_empty() {
return Ok(ReclaimResult {
count: 0,
amount: 0,
pending_count: 0,
spent_count: total,
});
}
let mut amount_by_y: HashMap<PublicKey, u64> = HashMap::new();
for proof in &relevant {
if let Ok(y) = proof.y() {
amount_by_y.insert(y, u64::from(proof.amount));
}
}
// check_proofs_spent queries the mint AND removes locally-spent
// proofs from the DB. That cleanup is fine — it doesn't cancel
// an active send, it just advances state once the mint confirms
// SPENT. The part we deliberately skip is `unreserve_proofs`.
let states = self.inner.check_proofs_spent(relevant).await?;
let buckets = StateBuckets::from_states(states, &amount_by_y);
self.update_balance_streams().await;
Ok(ReclaimResult {
count: 0,
amount: 0,
pending_count: buckets.pending_count,
spent_count: buckets.spent_count + resolved_out_of_pending,
})
}
// === Utility ===
@@ -552,6 +777,13 @@ impl Wallet {
CurrencyUnit::from_str(&self.unit).unwrap_or(CurrencyUnit::Custom(self.unit.clone()))
}
/// Recalcula el balance desde la DB y lo empuja al stream.
/// Llamar después de manipulaciones directas al DB (ej: offline send
/// marking proofs as PendingSpent fuera de la API de CDK).
pub async fn refresh_balance(&self) {
self.update_balance_streams().await;
}
pub(crate) async fn update_balance_streams(&self) {
let balance = self
.inner
@@ -677,6 +909,103 @@ impl<'a> From<CdkPreparedSend<'a>> for PreparedSend {
}
}
pub struct ReclaimResult {
/// Proofs reverted from PendingSpent to Unspent. Also the number of
/// recovered proofs reflected in `amount`.
pub count: u64,
/// Total value of the recovered proofs.
pub amount: u64,
/// Proofs the mint reports as still Pending (receiver mid-swap). The
/// caller should keep the record for retry — a later reconciliation
/// will resolve them.
pub pending_count: u64,
/// Proofs the mint reports as Spent (receiver already claimed). When
/// `count == 0 && pending_count == 0`, the send is definitively
/// finished and the caller can delete its local record.
pub spent_count: u64,
}
impl ReclaimResult {
#[frb(ignore)]
fn empty() -> Self {
Self {
count: 0,
amount: 0,
pending_count: 0,
spent_count: 0,
}
}
}
/// Internal helper: partition the response of `check_proofs_spent` into
/// the three buckets we care about (Unspent / Pending / Spent) and carry
/// the Ys/amount needed to build a `ReclaimResult`.
#[frb(ignore)]
struct StateBuckets {
unspent_ys: Vec<PublicKey>,
unspent_amount: u64,
pending_count: u64,
spent_count: u64,
}
impl StateBuckets {
fn from_states(
states: Vec<cdk::nuts::ProofState>,
amount_by_y: &HashMap<PublicKey, u64>,
) -> Self {
let mut unspent_ys = Vec::new();
let mut unspent_amount: u64 = 0;
let mut pending_count: u64 = 0;
let mut spent_count: u64 = 0;
for s in states {
match s.state {
ProofState::Unspent => {
if let Some(&a) = amount_by_y.get(&s.y) {
unspent_amount = unspent_amount.saturating_add(a);
}
unspent_ys.push(s.y);
}
ProofState::Pending => pending_count += 1,
ProofState::Spent => spent_count += 1,
// Reserved / other states: not expected for pending-spent
// queries; ignore so we don't miscount.
_ => {}
}
}
Self {
unspent_ys,
unspent_amount,
pending_count,
spent_count,
}
}
fn into_result(self) -> ReclaimResult {
ReclaimResult {
count: self.unspent_ys.len() as u64,
amount: self.unspent_amount,
pending_count: self.pending_count,
spent_count: self.spent_count,
}
}
}
pub struct PreparedMelt {
pub amount: u64,
pub fee_reserve: u64,
pub swap_fee: u64,
pub input_fee: u64,
operation_id: Uuid,
cdk_quote: CdkMeltQuote,
proofs: cdk::nuts::Proofs,
proofs_to_swap: cdk::nuts::Proofs,
cdk_input_fee: Amount,
cdk_input_fee_without_swap: Amount,
}
#[derive(Default)]
pub struct ReceiveOptions {
pub signing_keys: Option<Vec<String>>,
+1170 -113
View File
File diff suppressed because it is too large Load Diff