Compare commits
44
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7ec4e4eebc | ||
|
|
92cdf4ec2d | ||
|
|
b9d57b69b0 | ||
|
|
4f83d9c3a1 | ||
|
|
6246d09876 | ||
|
|
73a0261af6 | ||
|
|
dd9450c0cc | ||
|
|
09565aa3ed | ||
|
|
e3fdd46df8 | ||
|
|
f4d15fd2ab | ||
|
|
7bc9639e89 | ||
|
|
e11778ff0a | ||
|
|
d5d4b3a6ea | ||
|
|
a0f9ca738b | ||
|
|
ca63c56ecf | ||
|
|
5cdce99154 | ||
|
|
f734b35670 | ||
|
|
5eef9ce130 | ||
|
|
f2951d2bef | ||
|
|
8015b4817b | ||
|
|
cc1531f3d5 | ||
|
|
aaef4b1cdc | ||
|
|
dacec9ea22 | ||
|
|
4b8edf9669 | ||
|
|
f458cfad34 | ||
|
|
428f2c1f45 | ||
|
|
9a14af33a2 | ||
|
|
770868a773 | ||
|
|
dc78f8958c | ||
|
|
03ba0e532f | ||
|
|
a6a4365a0d | ||
|
|
ac23574ae2 | ||
|
|
87f8e52036 | ||
|
|
eb9319fca0 | ||
|
|
a9e4272762 | ||
|
|
f66668f0ec | ||
|
|
100a4943eb | ||
|
|
5b1317c3e4 | ||
|
|
d85b6562dc | ||
|
|
56585319cc | ||
|
|
79ca30c5ed | ||
|
|
0bd988a7a6 | ||
|
|
218d798d2a | ||
|
|
e8d8789413 |
@@ -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})';
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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데이터를 갖지 않는 것입니다"
|
||||
}
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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データを持たないことです';
|
||||
}
|
||||
|
||||
@@ -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데이터를 갖지 않는 것입니다';
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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не иметь их';
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
|
||||
+1097
-105
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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("e_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("e_id).await?;
|
||||
let expiry = cdk_quote.expiry;
|
||||
let request = cdk_quote.request.clone();
|
||||
|
||||
let mint_proofs = self
|
||||
.inner
|
||||
.mint("e_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("e.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
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user