Merge pull request #114 from Forte11Cuba/feat/reclaim-pending-send-from-history

feat: add cancel/reclaim button for pending sends (online + offline)
This commit is contained in:
Forte11
2026-04-18 01:05:04 -06:00
committed by GitHub
32 changed files with 1943 additions and 44 deletions
+101
View File
@@ -0,0 +1,101 @@
/// Modelo para rastrear envíos offline pendientes.
///
/// 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;
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,
}) : proofYs = List.unmodifiable(proofYs);
PendingSend copyWith({
String? id,
String? encoded,
BigInt? amount,
String? mintUrl,
String? unit,
List<String>? proofYs,
DateTime? createdAt,
String? memo,
}) {
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,
);
}
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,
};
}
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();
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?,
);
}
@override
String toString() =>
'PendingSend(id: $id, amount: $amount $unit, mintUrl: $mintUrl, ys: ${proofYs.length})';
}
+141
View File
@@ -0,0 +1,141 @@
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 pendientes.
///
/// Los envíos offline no crean una Transaction en CDK, así que necesitamos
/// rastrearlos nosotros para poder:
/// - Mostrarlos en el historial como "pendientes de reclamo por el receptor".
/// - 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';
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;
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: 1,
onCreate: (db, version) async {
await db.execute('''
CREATE TABLE $_tableName (
id TEXT PRIMARY KEY,
encoded TEXT NOT NULL,
amount TEXT NOT NULL,
mint_url TEXT NOT NULL,
unit TEXT NOT NULL,
proof_ys TEXT NOT NULL,
created_at INTEGER NOT NULL,
memo TEXT
)
''');
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 _loadFromDb();
_isInitialized = true;
debugPrint('PendingSendStorage inicializado con ${_cache.length} envíos');
}
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,
);
_cache[send.id] = send;
await _db?.insert(_tableName, send.toMap());
_changesController.add(null);
return send;
}
Future<void> remove(String id) async {
_cache.remove(id);
await _db?.delete(_tableName, where: 'id = ?', whereArgs: [id]);
_changesController.add(null);
}
List<PendingSend> listAll() {
final list = _cache.values.toList();
list.sort((a, b) => b.createdAt.compareTo(a.createdAt));
return list;
}
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);
}
}
+14
View File
@@ -87,6 +87,20 @@
"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:",
+14
View File
@@ -77,6 +77,20 @@
"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:",
+14
View File
@@ -87,6 +87,20 @@
"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:",
+14
View File
@@ -87,6 +87,20 @@
"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 :",
+14
View File
@@ -87,6 +87,20 @@
"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:",
+14
View File
@@ -87,6 +87,20 @@
"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": "金額:",
+14
View File
@@ -87,6 +87,20 @@
"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": "금액:",
+48
View File
@@ -511,6 +511,54 @@ abstract class L10n {
/// **'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:
+29
View File
@@ -218,6 +218,35 @@ class L10nDe extends L10n {
@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';
+28
View File
@@ -215,6 +215,34 @@ class L10nEn extends L10n {
@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';
+27
View File
@@ -215,6 +215,33 @@ class L10nEs extends L10n {
@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';
+28
View File
@@ -219,6 +219,34 @@ class L10nFr extends L10n {
@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';
+28
View File
@@ -216,6 +216,34 @@ class L10nIt extends L10n {
@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';
+27
View File
@@ -211,6 +211,33 @@ class L10nJa extends L10n {
@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 => '有効なトークン';
+27
View File
@@ -213,6 +213,33 @@ class L10nKo extends L10n {
@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 => '유효한 토큰';
+27
View File
@@ -216,6 +216,33 @@ class L10nPt extends L10n {
@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';
+27
View File
@@ -215,6 +215,33 @@ class L10nRu extends L10n {
@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 => 'Токен действителен';
+27
View File
@@ -215,6 +215,33 @@ class L10nSw extends L10n {
@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';
+26
View File
@@ -210,6 +210,32 @@ class L10nZh extends L10n {
@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 => '有效代币';
+14
View File
@@ -87,6 +87,20 @@
"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:",
+14
View File
@@ -87,6 +87,20 @@
"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": "Сумма:",
+14
View File
@@ -87,6 +87,20 @@
"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:",
+14
View File
@@ -87,6 +87,20 @@
"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": "金额:",
+96
View File
@@ -15,6 +15,8 @@ import 'package:uuid/uuid.dart';
import '../data/transaction_meta_storage.dart';
import '../data/pending_token.dart';
import '../data/pending_token_storage.dart';
import '../data/pending_send.dart';
import '../data/pending_send_storage.dart';
import '../core/utils/keyset_debug.dart';
import '../core/utils/p2pk_utils.dart';
import '../widgets/effects/cashu_confetti.dart';
@@ -47,6 +49,9 @@ class WalletProvider extends ChangeNotifier {
/// Storage para tokens pendientes de reclamar (Receive Later)
final PendingTokenStorage _pendingTokenStorage = PendingTokenStorage();
/// Storage para envíos offline pendientes de ser reclamados por el receptor
final PendingSendStorage _pendingSendStorage = PendingSendStorage();
/// Controller global de confetti para celebrar recepciones
final CashuConfettiController confettiController = CashuConfettiController();
@@ -125,6 +130,78 @@ 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;
bool get hasPendingSends => _pendingSendStorage.hasPendingSends;
Stream<void> get pendingSendChanges => _pendingSendStorage.changes;
List<PendingSend> listPendingSends() => _pendingSendStorage.listAll();
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 están UNSPENT → revierte a Unspent local y borra el PendingSend.
/// - Si están SPENT (receptor ya reclamó) → borra el PendingSend igual
/// (el envío se completó).
/// - Si algunas PENDING en el mint → deja el PendingSend para reintento.
Future<ReclaimResult> reclaimPendingSend(PendingSend send) async {
final wallet = await getWallet(send.mintUrl, send.unit);
final result = await wallet.reclaimProofsByYs(ys: send.proofYs);
// Solo removemos el registro si el mint confirmó que había proofs
// UNSPENT y las revertimos. Un count=0 es ambiguo: puede significar
// "receptor ya reclamó (SPENT)" o "receptor en medio del swap
// (PENDING)". En el caso PENDING, necesitamos conservar el record
// para poder reintentar después — borrar perdería el retry handle.
// Esto puede dejar records "fantasma" tras un SPENT real; se puede
// agregar dismissal manual como mejora futura.
if (result.count > BigInt.zero) {
await _pendingSendStorage.remove(send.id);
}
notifyListeners();
return result;
}
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 +410,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';
@@ -1544,6 +1624,18 @@ class WalletProvider extends ChangeNotifier {
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).
Future<void> _saveMeltMetadata(Wallet wallet, String invoice) async {
try {
@@ -1943,6 +2035,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,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';
@@ -33,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;
@@ -42,6 +51,7 @@ class _OfflineSendScreenState extends State<OfflineSendScreen> {
@override
void initState() {
super.initState();
_walletProvider = context.read<WalletProvider>();
_loadProofs();
}
@@ -365,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(
+749 -6
View File
@@ -13,10 +13,14 @@ import '../../core/constants/dimensions.dart';
import '../../core/utils/formatters.dart';
import '../../data/transaction_meta_storage.dart';
import '../../data/pending_token.dart';
import '../../data/pending_send.dart';
import '../../providers/wallet_provider.dart';
import '../../providers/p2pk_provider.dart';
import '../../core/utils/p2pk_utils.dart';
import '../../widgets/common/gradient_background.dart';
import '../../widgets/common/bottom_sheet_container.dart';
import '../../widgets/common/primary_button.dart';
import '../../widgets/common/secondary_button.dart';
/// Filtros disponibles para el historial
enum HistoryFilter {
@@ -157,6 +161,14 @@ class _HistoryScreenState extends State<HistoryScreen> {
return _buildPendingTokensList(walletProvider);
}
// Envíos offline pendientes (no tienen Transaction en CDK).
// Solo mostrar en filtros "all" y "pending".
final pendingSends =
(_currentFilter == HistoryFilter.all ||
_currentFilter == HistoryFilter.pending)
? walletProvider.listPendingSends()
: <PendingSend>[];
return FutureBuilder<List<Transaction>>(
future: walletProvider.getAllTransactions(),
builder: (context, snapshot) {
@@ -169,22 +181,30 @@ class _HistoryScreenState extends State<HistoryScreen> {
}
final allTransactions = snapshot.data ?? [];
final filteredTransactions =
_applyFilter(allTransactions, walletProvider);
// Aplicar filtro
final filteredTransactions = _applyFilter(allTransactions, walletProvider);
if (filteredTransactions.isEmpty) {
if (filteredTransactions.isEmpty && pendingSends.isEmpty) {
return _buildEmptyState();
}
final totalCount = pendingSends.length + filteredTransactions.length;
return RefreshIndicator(
onRefresh: _refreshTransactions,
color: AppColors.primaryAction,
child: ListView.builder(
padding: const EdgeInsets.all(AppDimensions.paddingMedium),
itemCount: filteredTransactions.length,
itemCount: totalCount,
itemBuilder: (context, index) {
final tx = filteredTransactions[index];
if (index < pendingSends.length) {
final send = pendingSends[index];
return _PendingSendTile(
send: send,
walletProvider: walletProvider,
);
}
final tx = filteredTransactions[index - pendingSends.length];
return _HistoryTransactionTile(
transaction: tx,
walletProvider: walletProvider,
@@ -703,6 +723,7 @@ class _TransactionDetailScreenState extends State<_TransactionDetailScreen> {
String? _tokenOrInvoice;
late final bool _shouldShowQR;
bool _isLoadingPendingInvoice = false;
bool _isReclaiming = false;
@override
void initState() {
@@ -941,6 +962,12 @@ class _TransactionDetailScreenState extends State<_TransactionDetailScreen> {
// 6. Botón COPY (grande, prominente)
if (_tokenOrInvoice != null) _buildCopyButton(),
// 7. Botón CANCEL / RECLAIM — solo para pending outgoing ecash
if (isPending && !_isIncoming && !_isLightning) ...[
const SizedBox(height: 12),
_buildReclaimButton(),
],
const SizedBox(height: 40),
],
),
@@ -1117,6 +1144,162 @@ class _TransactionDetailScreenState extends State<_TransactionDetailScreen> {
),
);
}
Widget _buildReclaimButton() {
final l10n = L10n.of(context)!;
return SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
onPressed: _isReclaiming ? null : _confirmReclaim,
icon: _isReclaiming
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(Colors.white70),
),
)
: const Icon(LucideIcons.undo2, size: 18, color: Colors.white70),
label: Text(
l10n.cancelSend,
style: const TextStyle(
fontFamily: 'Inter',
fontSize: 15,
fontWeight: FontWeight.w500,
color: Colors.white70,
letterSpacing: 0.5,
),
),
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
side: BorderSide(color: Colors.white.withValues(alpha: 0.2)),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(30),
),
),
),
);
}
Future<void> _confirmReclaim() async {
final l10n = L10n.of(context)!;
final confirmed = await showModalBottomSheet<bool>(
context: context,
backgroundColor: Colors.transparent,
isScrollControlled: true,
builder: (ctx) => BottomSheetContainer(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const BottomSheetHandle(),
Container(
width: 64,
height: 64,
decoration: BoxDecoration(
color: AppColors.warning.withValues(alpha: 0.2),
shape: BoxShape.circle,
),
child: Icon(
LucideIcons.undo2,
color: AppColors.warning,
size: 32,
),
),
const SizedBox(height: AppDimensions.paddingMedium),
Text(
l10n.cancelSendConfirmTitle,
style: const TextStyle(
fontFamily: 'Inter',
fontSize: 20,
fontWeight: FontWeight.bold,
color: Colors.white,
),
textAlign: TextAlign.center,
),
const SizedBox(height: AppDimensions.paddingSmall),
Text(
l10n.cancelSendConfirmBody,
style: TextStyle(
fontFamily: 'Inter',
fontSize: 14,
color: AppColors.textSecondary.withValues(alpha: 0.8),
height: 1.4,
),
textAlign: TextAlign.center,
),
const SizedBox(height: AppDimensions.paddingLarge),
Row(
children: [
Expanded(
child: SecondaryButton(
text: l10n.cancel,
onPressed: () => Navigator.pop(ctx, false),
height: 52,
),
),
const SizedBox(width: AppDimensions.paddingMedium),
Expanded(
child: PrimaryButton(
text: l10n.cancelSend,
onPressed: () => Navigator.pop(ctx, true),
height: 52,
),
),
],
),
],
),
),
);
if (confirmed == true && mounted) {
await _doReclaim();
}
}
Future<void> _doReclaim() async {
final l10n = L10n.of(context)!;
setState(() => _isReclaiming = true);
try {
final result = await widget.walletProvider
.reclaimTransaction(widget.transaction);
if (!mounted) return;
if (result.count > BigInt.zero) {
final formatted = UnitFormatter.formatBalance(
result.amount,
widget.transaction.unit,
);
final unitLabel = UnitFormatter.getUnitLabel(widget.transaction.unit);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(l10n.cancelSendSuccess(formatted, unitLabel)),
backgroundColor: AppColors.success,
duration: const Duration(seconds: 3),
),
);
Navigator.pop(context);
} else {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(l10n.cancelSendAlreadyClaimed),
backgroundColor: AppColors.warning,
duration: const Duration(seconds: 3),
),
);
}
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(e.toString()),
backgroundColor: AppColors.error,
duration: const Duration(seconds: 3),
),
);
} finally {
if (mounted) setState(() => _isReclaiming = false);
}
}
}
/// Chip de filtro con badge
@@ -1490,3 +1673,563 @@ class _MinimalDetailRow extends StatelessWidget {
}
}
/// Tile para mostrar un envío offline pendiente en el historial.
/// Se renderiza arriba de las Transactions de CDK.
class _PendingSendTile extends StatelessWidget {
final PendingSend send;
final WalletProvider walletProvider;
const _PendingSendTile({
required this.send,
required this.walletProvider,
});
@override
Widget build(BuildContext context) {
final l10n = L10n.of(context)!;
final formattedAmount = UnitFormatter.formatBalance(send.amount, send.unit);
final unitLabel = UnitFormatter.getUnitLabel(send.unit);
final mintDisplay = UnitFormatter.getMintDisplayName(send.mintUrl);
return GestureDetector(
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => _PendingSendDetailScreen(send: send),
),
);
},
child: Container(
margin: const EdgeInsets.only(bottom: AppDimensions.paddingSmall),
padding: const EdgeInsets.all(AppDimensions.paddingMedium),
decoration: BoxDecoration(
color: AppColors.warning.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: AppColors.warning.withValues(alpha: 0.3),
width: 1,
),
),
child: Row(
children: [
Container(
width: 44,
height: 44,
decoration: BoxDecoration(
color: AppColors.warning.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(12),
),
child: Icon(
LucideIcons.clock,
color: AppColors.warning,
size: 22,
),
),
const SizedBox(width: AppDimensions.paddingMedium),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
l10n.pendingOfflineSend,
style: const TextStyle(
fontFamily: 'Inter',
fontSize: 15,
fontWeight: FontWeight.w600,
color: Colors.white,
),
),
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 6,
vertical: 2,
),
decoration: BoxDecoration(
color: AppColors.warning.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(4),
),
child: Text(
l10n.pendingStatus,
style: const TextStyle(
fontFamily: 'Inter',
fontSize: 10,
fontWeight: FontWeight.w500,
color: AppColors.warning,
),
),
),
],
),
const SizedBox(height: 4),
Text(
mintDisplay,
style: TextStyle(
fontFamily: 'Inter',
fontSize: 12,
color: AppColors.textSecondary.withValues(alpha: 0.8),
),
),
],
),
),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
'-$formattedAmount',
style: const TextStyle(
fontFamily: 'Inter',
fontSize: 16,
fontWeight: FontWeight.w700,
color: Colors.white,
),
),
Text(
unitLabel,
style: TextStyle(
fontFamily: 'Inter',
fontSize: 11,
color: AppColors.textSecondary.withValues(alpha: 0.7),
),
),
],
),
],
),
),
);
}
}
/// Pantalla de detalle para un envío offline pendiente.
/// Muestra QR del token, monto, y botón para cancelar/reclamar.
class _PendingSendDetailScreen extends StatefulWidget {
final PendingSend send;
const _PendingSendDetailScreen({required this.send});
@override
State<_PendingSendDetailScreen> createState() =>
_PendingSendDetailScreenState();
}
class _PendingSendDetailScreenState extends State<_PendingSendDetailScreen> {
List<String> _urFragments = [];
int _currentFragment = 0;
Timer? _animationTimer;
bool _isReclaiming = false;
// Control de velocidad del QR animado (alineado con _TransactionDetailScreen)
static const int _intervalFast = 100;
static const int _intervalMedium = 200;
static const int _intervalSlow = 400;
int _currentInterval = _intervalMedium;
String _speedLabel = 'M';
@override
void initState() {
super.initState();
_encodeTokenToUR();
}
@override
void dispose() {
_animationTimer?.cancel();
super.dispose();
}
void _encodeTokenToUR() {
try {
final token = cdk.Token.parse(encoded: widget.send.encoded);
_urFragments = cdk.encodeQrToken(token: token);
if (_urFragments.length > 1) {
_startAnimation();
}
} catch (_) {
_urFragments = [widget.send.encoded];
}
}
void _startAnimation() {
_animationTimer?.cancel();
_animationTimer = Timer.periodic(
Duration(milliseconds: _currentInterval),
(_) {
if (mounted) {
setState(() {
_currentFragment =
(_currentFragment + 1) % _urFragments.length;
});
}
},
);
}
void _cycleSpeed() {
setState(() {
if (_currentInterval == _intervalMedium) {
_currentInterval = _intervalFast;
_speedLabel = 'F';
} else if (_currentInterval == _intervalFast) {
_currentInterval = _intervalSlow;
_speedLabel = 'S';
} else {
_currentInterval = _intervalMedium;
_speedLabel = 'M';
}
});
if (_urFragments.length > 1) _startAnimation();
}
Widget _buildQRControls() {
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(20),
),
child: Text(
'${_currentFragment + 1}/${_urFragments.length}',
style: const TextStyle(
fontFamily: 'Inter',
fontSize: 13,
color: Colors.white70,
),
),
),
const SizedBox(width: 16),
GestureDetector(
onTap: _cycleSpeed,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(20),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
LucideIcons.gauge,
color: Colors.white70,
size: 14,
),
const SizedBox(width: 6),
Text(
'${L10n.of(context)!.speed} $_speedLabel',
style: const TextStyle(
fontFamily: 'Inter',
fontSize: 13,
color: Colors.white70,
),
),
],
),
),
),
],
);
}
Future<void> _confirmReclaim() async {
final l10n = L10n.of(context)!;
final confirmed = await showModalBottomSheet<bool>(
context: context,
backgroundColor: Colors.transparent,
isScrollControlled: true,
builder: (ctx) => BottomSheetContainer(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const BottomSheetHandle(),
Container(
width: 64,
height: 64,
decoration: BoxDecoration(
color: AppColors.warning.withValues(alpha: 0.2),
shape: BoxShape.circle,
),
child: Icon(LucideIcons.undo2,
color: AppColors.warning, size: 32),
),
const SizedBox(height: AppDimensions.paddingMedium),
Text(
l10n.cancelSendConfirmTitle,
style: const TextStyle(
fontFamily: 'Inter',
fontSize: 20,
fontWeight: FontWeight.bold,
color: Colors.white,
),
textAlign: TextAlign.center,
),
const SizedBox(height: AppDimensions.paddingSmall),
Text(
l10n.cancelSendConfirmBody,
style: TextStyle(
fontFamily: 'Inter',
fontSize: 14,
color: AppColors.textSecondary.withValues(alpha: 0.8),
height: 1.4,
),
textAlign: TextAlign.center,
),
const SizedBox(height: AppDimensions.paddingLarge),
Row(
children: [
Expanded(
child: SecondaryButton(
text: l10n.cancel,
onPressed: () => Navigator.pop(ctx, false),
height: 52,
),
),
const SizedBox(width: AppDimensions.paddingMedium),
Expanded(
child: PrimaryButton(
text: l10n.cancelSend,
onPressed: () => Navigator.pop(ctx, true),
height: 52,
),
),
],
),
],
),
),
);
if (confirmed == true && mounted) await _doReclaim();
}
Future<void> _doReclaim() async {
final l10n = L10n.of(context)!;
final walletProvider = context.read<WalletProvider>();
setState(() => _isReclaiming = true);
try {
final result = await walletProvider.reclaimPendingSend(widget.send);
if (!mounted) return;
if (result.count > BigInt.zero) {
final formatted =
UnitFormatter.formatBalance(result.amount, widget.send.unit);
final unitLabel = UnitFormatter.getUnitLabel(widget.send.unit);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(l10n.cancelSendSuccess(formatted, unitLabel)),
backgroundColor: AppColors.success,
duration: const Duration(seconds: 3),
),
);
} else {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(l10n.cancelSendAlreadyClaimed),
backgroundColor: AppColors.warning,
duration: const Duration(seconds: 3),
),
);
}
Navigator.pop(context);
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(e.toString()),
backgroundColor: AppColors.error,
duration: const Duration(seconds: 3),
),
);
} finally {
if (mounted) setState(() => _isReclaiming = false);
}
}
@override
Widget build(BuildContext context) {
final l10n = L10n.of(context)!;
final formattedAmount =
UnitFormatter.formatBalance(widget.send.amount, widget.send.unit);
final unitLabel = UnitFormatter.getUnitLabel(widget.send.unit);
final mintDisplay =
UnitFormatter.getMintDisplayName(widget.send.mintUrl);
final qrData = _urFragments.isNotEmpty
? _urFragments[_currentFragment]
: widget.send.encoded;
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),
),
),
body: SafeArea(
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Column(
children: [
// 1. Título estado (mismo formato que la pantalla online)
Text(
l10n.sentEcash.split('').join(' ').toUpperCase(),
style: TextStyle(
fontFamily: 'Inter',
fontSize: 14,
fontWeight: FontWeight.w500,
letterSpacing: 2,
color: Colors.white.withValues(alpha: 0.8),
),
),
const SizedBox(height: 24),
// 2. QR
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(20),
),
child: QrImageView(
data: qrData,
version: QrVersions.auto,
size: 240,
backgroundColor: Colors.white,
errorCorrectionLevel: QrErrorCorrectLevel.L,
padding: EdgeInsets.zero,
),
),
// 3. Controles del QR (si es animado)
if (_urFragments.length > 1) ...[
const SizedBox(height: 12),
_buildQRControls(),
],
const SizedBox(height: 32),
// 4. Monto grande
Text(
formattedAmount,
style: const TextStyle(
fontFamily: 'Inter',
fontSize: 48,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
const SizedBox(height: 32),
// 5. Lista de detalles (mismo formato que la pantalla online)
Column(
children: [
_MinimalDetailRow(
icon: LucideIcons.coins,
label: l10n.unit,
value: unitLabel,
),
_MinimalDetailRow(
icon: LucideIcons.landmark,
label: l10n.mint,
value: mintDisplay,
),
_MinimalDetailRow(
icon: LucideIcons.clock,
label: l10n.status,
value: l10n.pending,
valueColor: AppColors.warning,
),
if (widget.send.memo != null &&
widget.send.memo!.isNotEmpty)
_MinimalDetailRow(
icon: LucideIcons.messageSquare,
label: l10n.memo,
value: widget.send.memo!,
),
],
),
const SizedBox(height: 32),
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: () {
Clipboard.setData(
ClipboardData(text: widget.send.encoded));
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(l10n.tokenCopied),
backgroundColor: AppColors.success,
duration: const Duration(seconds: 2),
),
);
},
style: ElevatedButton.styleFrom(
backgroundColor: Colors.white,
foregroundColor: Colors.black,
padding: const EdgeInsets.symmetric(vertical: 18),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(30),
),
elevation: 0,
),
child: Text(
l10n.copyButton,
style: const TextStyle(
fontFamily: 'Inter',
fontSize: 16,
fontWeight: FontWeight.w600,
letterSpacing: 1,
),
),
),
),
const SizedBox(height: 12),
SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
onPressed: _isReclaiming ? null : _confirmReclaim,
icon: _isReclaiming
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor:
AlwaysStoppedAnimation<Color>(Colors.white70),
),
)
: const Icon(LucideIcons.undo2,
size: 18, color: Colors.white70),
label: Text(
l10n.cancelSend,
style: const TextStyle(
fontFamily: 'Inter',
fontSize: 15,
fontWeight: FontWeight.w500,
color: Colors.white70,
letterSpacing: 0.5,
),
),
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
side: BorderSide(
color: Colors.white.withValues(alpha: 0.2)),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(30),
),
),
),
),
const SizedBox(height: 40),
],
),
),
),
),
);
}
}
+11
View File
@@ -128,8 +128,19 @@ abstract class Wallet implements RustOpaqueInterface {
/// Returns count and total amount of proofs recovered.
Future<ReclaimResult> reclaimPendingProofs();
/// Reclaim proofs belonging to a specific transaction identified by its Y values.
/// Works like reclaim_pending_proofs but scoped: only proofs whose Y matches
/// the input list are checked with the mint and reverted if Unspent.
/// Use tx.ys from a pending outgoing transaction to cancel that specific send.
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({
+113 -19
View File
@@ -69,7 +69,7 @@ class RustLib extends BaseEntrypoint<RustLibApi, RustLibApiImpl, RustLibWire> {
String get codegenVersion => '2.11.1';
@override
int get rustContentHash => 1903109516;
int get rustContentHash => -740347571;
static const kDefaultExternalLibraryLoaderConfig =
ExternalLibraryLoaderConfig(
@@ -320,10 +320,17 @@ abstract class RustLibApi extends BaseApi {
required Wallet that,
});
Future<ReclaimResult> crateApiWalletWalletReclaimProofsByYs({
required Wallet that,
required List<String> ys,
});
Future<void> crateApiWalletWalletRecoverIncompleteSagas({
required Wallet that,
});
Future<void> crateApiWalletWalletRefreshBalance({required Wallet that});
Future<void> crateApiWalletWalletRestore({required Wallet that});
Future<SendResult> crateApiWalletWalletSend({
@@ -2339,6 +2346,44 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
argNames: ["that"],
);
@override
Future<ReclaimResult> crateApiWalletWalletReclaimProofsByYs({
required Wallet that,
required List<String> ys,
}) {
return handler.executeNormal(
NormalTask(
callFfi: (port_) {
final serializer = SseSerializer(generalizedFrbRustBinding);
sse_encode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerWallet(
that,
serializer,
);
sse_encode_list_String(ys, serializer);
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 56,
port: port_,
);
},
codec: SseCodec(
decodeSuccessData: sse_decode_reclaim_result,
decodeErrorData: sse_decode_error,
),
constMeta: kCrateApiWalletWalletReclaimProofsByYsConstMeta,
argValues: [that, ys],
apiImpl: this,
),
);
}
TaskConstMeta get kCrateApiWalletWalletReclaimProofsByYsConstMeta =>
const TaskConstMeta(
debugName: "Wallet_reclaim_proofs_by_ys",
argNames: ["that", "ys"],
);
@override
Future<void> crateApiWalletWalletRecoverIncompleteSagas({
required Wallet that,
@@ -2354,7 +2399,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 56,
funcId: 57,
port: port_,
);
},
@@ -2375,6 +2420,40 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
argNames: ["that"],
);
@override
Future<void> crateApiWalletWalletRefreshBalance({required Wallet that}) {
return handler.executeNormal(
NormalTask(
callFfi: (port_) {
final serializer = SseSerializer(generalizedFrbRustBinding);
sse_encode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerWallet(
that,
serializer,
);
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 58,
port: port_,
);
},
codec: SseCodec(
decodeSuccessData: sse_decode_unit,
decodeErrorData: null,
),
constMeta: kCrateApiWalletWalletRefreshBalanceConstMeta,
argValues: [that],
apiImpl: this,
),
);
}
TaskConstMeta get kCrateApiWalletWalletRefreshBalanceConstMeta =>
const TaskConstMeta(
debugName: "Wallet_refresh_balance",
argNames: ["that"],
);
@override
Future<void> crateApiWalletWalletRestore({required Wallet that}) {
return handler.executeNormal(
@@ -2388,7 +2467,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 57,
funcId: 59,
port: port_,
);
},
@@ -2430,7 +2509,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 58,
funcId: 60,
port: port_,
);
},
@@ -2466,7 +2545,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 59,
funcId: 61,
port: port_,
);
},
@@ -2512,7 +2591,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 60,
funcId: 62,
port: port_,
);
},
@@ -2550,7 +2629,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
sse_encode_String(proofsJson, serializer);
sse_encode_opt_String(memo, serializer);
sse_encode_opt_String(unit, serializer);
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 61)!;
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 63)!;
},
codec: SseCodec(
decodeSuccessData: sse_decode_token,
@@ -2580,7 +2659,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
final serializer = SseSerializer(generalizedFrbRustBinding);
sse_encode_box_autoadd_token(token, serializer);
sse_encode_opt_box_autoadd_usize(maxFragmentLength, serializer);
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 62)!;
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 64)!;
},
codec: SseCodec(
decodeSuccessData: sse_decode_list_String,
@@ -2610,7 +2689,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 63,
funcId: 65,
port: port_,
);
},
@@ -2634,7 +2713,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
SyncTask(
callFfi: () {
final serializer = SseSerializer(generalizedFrbRustBinding);
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 64)!;
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 66)!;
},
codec: SseCodec(
decodeSuccessData: sse_decode_String,
@@ -2660,7 +2739,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 65,
funcId: 67,
port: port_,
);
},
@@ -2685,7 +2764,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
callFfi: () {
final serializer = SseSerializer(generalizedFrbRustBinding);
sse_encode_String(secret, serializer);
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 66)!;
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 68)!;
},
codec: SseCodec(
decodeSuccessData: sse_decode_String,
@@ -2708,7 +2787,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
callFfi: () {
final serializer = SseSerializer(generalizedFrbRustBinding);
sse_encode_String(mnemonic, serializer);
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 67)!;
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 69)!;
},
codec: SseCodec(
decodeSuccessData: sse_decode_list_prim_u_8_strict,
@@ -2735,7 +2814,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
callFfi: () {
final serializer = SseSerializer(generalizedFrbRustBinding);
sse_encode_String(encoded, serializer);
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 68)!;
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 70)!;
},
codec: SseCodec(
decodeSuccessData: sse_decode_payment_request_info,
@@ -2764,7 +2843,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 69,
funcId: 71,
port: port_,
);
},
@@ -2791,7 +2870,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 70,
funcId: 72,
port: port_,
);
},
@@ -2818,7 +2897,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 71,
funcId: 73,
port: port_,
);
},
@@ -2843,7 +2922,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
callFfi: () {
final serializer = SseSerializer(generalizedFrbRustBinding);
sse_encode_list_prim_u_8_loose(raw, serializer);
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 72)!;
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 74)!;
},
codec: SseCodec(
decodeSuccessData: sse_decode_token,
@@ -2866,7 +2945,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
callFfi: () {
final serializer = SseSerializer(generalizedFrbRustBinding);
sse_encode_String(encoded, serializer);
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 73)!;
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 75)!;
},
codec: SseCodec(
decodeSuccessData: sse_decode_token,
@@ -6545,9 +6624,24 @@ class WalletImpl extends RustOpaque implements Wallet {
Future<ReclaimResult> reclaimPendingProofs() =>
RustLib.instance.api.crateApiWalletWalletReclaimPendingProofs(that: this);
/// Reclaim proofs belonging to a specific transaction identified by its Y values.
/// Works like reclaim_pending_proofs but scoped: only proofs whose Y matches
/// the input list are checked with the mint and reverted if Unspent.
/// Use tx.ys from a pending outgoing transaction to cancel that specific send.
Future<ReclaimResult> reclaimProofsByYs({required List<String> ys}) => RustLib
.instance
.api
.crateApiWalletWalletReclaimProofsByYs(that: this, ys: ys);
Future<void> recoverIncompleteSagas() => RustLib.instance.api
.crateApiWalletWalletRecoverIncompleteSagas(that: this);
/// 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() =>
RustLib.instance.api.crateApiWalletWalletRefreshBalance(that: this);
Future<void> restore() =>
RustLib.instance.api.crateApiWalletWalletRestore(that: this);
+60
View File
@@ -573,6 +573,59 @@ impl Wallet {
Ok(ReclaimResult { count, amount })
}
/// Reclaim proofs belonging to a specific transaction identified by its Y values.
/// Works like reclaim_pending_proofs but scoped: only proofs whose Y matches
/// the input list are checked with the mint and reverted if Unspent.
/// Use tx.ys from a pending outgoing transaction to cancel that specific send.
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 { count: 0, amount: 0 });
}
let pending = self.inner.get_pending_spent_proofs().await?;
if pending.is_empty() {
return Ok(ReclaimResult { count: 0, amount: 0 });
}
// 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();
if relevant.is_empty() {
return Ok(ReclaimResult { count: 0, amount: 0 });
}
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 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;
let amount: u64 = unspent_ys.iter().filter_map(|y| amount_by_y.get(y)).sum();
if count > 0 {
self.inner.unreserve_proofs(unspent_ys).await?;
}
self.update_balance_streams().await;
Ok(ReclaimResult { count, amount })
}
// === Utility ===
pub async fn is_token_spent(&self, token: Token) -> Result<bool, Error> {
@@ -601,6 +654,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
+145 -19
View File
@@ -40,7 +40,7 @@ flutter_rust_bridge::frb_generated_boilerplate!(
default_rust_auto_opaque = RustAutoOpaqueMoi,
);
pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_VERSION: &str = "2.11.1";
pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_CONTENT_HASH: i32 = 1903109516;
pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_CONTENT_HASH: i32 = -740347571;
// Section: executor
@@ -2877,6 +2877,66 @@ fn wire__crate__api__wallet__Wallet_reclaim_pending_proofs_impl(
},
)
}
fn wire__crate__api__wallet__Wallet_reclaim_proofs_by_ys_impl(
port_: flutter_rust_bridge::for_generated::MessagePort,
ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr,
rust_vec_len_: i32,
data_len_: i32,
) {
FLUTTER_RUST_BRIDGE_HANDLER.wrap_async::<flutter_rust_bridge::for_generated::SseCodec, _, _, _>(
flutter_rust_bridge::for_generated::TaskInfo {
debug_name: "Wallet_reclaim_proofs_by_ys",
port: Some(port_),
mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal,
},
move || {
let message = unsafe {
flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire(
ptr_,
rust_vec_len_,
data_len_,
)
};
let mut deserializer =
flutter_rust_bridge::for_generated::SseDeserializer::new(message);
let api_that = <RustOpaqueMoi<
flutter_rust_bridge::for_generated::RustAutoOpaqueInner<Wallet>,
>>::sse_decode(&mut deserializer);
let api_ys = <Vec<String>>::sse_decode(&mut deserializer);
deserializer.end();
move |context| async move {
transform_result_sse::<_, crate::api::error::Error>(
(move || async move {
let mut api_that_guard = None;
let decode_indices_ =
flutter_rust_bridge::for_generated::lockable_compute_decode_order(
vec![flutter_rust_bridge::for_generated::LockableOrderInfo::new(
&api_that, 0, false,
)],
);
for i in decode_indices_ {
match i {
0 => {
api_that_guard =
Some(api_that.lockable_decode_async_ref().await)
}
_ => unreachable!(),
}
}
let api_that_guard = api_that_guard.unwrap();
let output_ok = crate::api::wallet::Wallet::reclaim_proofs_by_ys(
&*api_that_guard,
api_ys,
)
.await?;
Ok(output_ok)
})()
.await,
)
}
},
)
}
fn wire__crate__api__wallet__Wallet_recover_incomplete_sagas_impl(
port_: flutter_rust_bridge::for_generated::MessagePort,
ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr,
@@ -2934,6 +2994,63 @@ fn wire__crate__api__wallet__Wallet_recover_incomplete_sagas_impl(
},
)
}
fn wire__crate__api__wallet__Wallet_refresh_balance_impl(
port_: flutter_rust_bridge::for_generated::MessagePort,
ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr,
rust_vec_len_: i32,
data_len_: i32,
) {
FLUTTER_RUST_BRIDGE_HANDLER.wrap_async::<flutter_rust_bridge::for_generated::SseCodec, _, _, _>(
flutter_rust_bridge::for_generated::TaskInfo {
debug_name: "Wallet_refresh_balance",
port: Some(port_),
mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal,
},
move || {
let message = unsafe {
flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire(
ptr_,
rust_vec_len_,
data_len_,
)
};
let mut deserializer =
flutter_rust_bridge::for_generated::SseDeserializer::new(message);
let api_that = <RustOpaqueMoi<
flutter_rust_bridge::for_generated::RustAutoOpaqueInner<Wallet>,
>>::sse_decode(&mut deserializer);
deserializer.end();
move |context| async move {
transform_result_sse::<_, ()>(
(move || async move {
let mut api_that_guard = None;
let decode_indices_ =
flutter_rust_bridge::for_generated::lockable_compute_decode_order(
vec![flutter_rust_bridge::for_generated::LockableOrderInfo::new(
&api_that, 0, false,
)],
);
for i in decode_indices_ {
match i {
0 => {
api_that_guard =
Some(api_that.lockable_decode_async_ref().await)
}
_ => unreachable!(),
}
}
let api_that_guard = api_that_guard.unwrap();
let output_ok = Result::<_, ()>::Ok({
crate::api::wallet::Wallet::refresh_balance(&*api_that_guard).await;
})?;
Ok(output_ok)
})()
.await,
)
}
},
)
}
fn wire__crate__api__wallet__Wallet_restore_impl(
port_: flutter_rust_bridge::for_generated::MessagePort,
ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr,
@@ -4698,33 +4815,42 @@ fn pde_ffi_dispatcher_primary_impl(
rust_vec_len,
data_len,
),
56 => wire__crate__api__wallet__Wallet_recover_incomplete_sagas_impl(
56 => wire__crate__api__wallet__Wallet_reclaim_proofs_by_ys_impl(
port,
ptr,
rust_vec_len,
data_len,
),
57 => wire__crate__api__wallet__Wallet_restore_impl(port, ptr, rust_vec_len, data_len),
58 => wire__crate__api__wallet__Wallet_send_impl(port, ptr, rust_vec_len, data_len),
59 => {
57 => wire__crate__api__wallet__Wallet_recover_incomplete_sagas_impl(
port,
ptr,
rust_vec_len,
data_len,
),
58 => {
wire__crate__api__wallet__Wallet_refresh_balance_impl(port, ptr, rust_vec_len, data_len)
}
59 => wire__crate__api__wallet__Wallet_restore_impl(port, ptr, rust_vec_len, data_len),
60 => wire__crate__api__wallet__Wallet_send_impl(port, ptr, rust_vec_len, data_len),
61 => {
wire__crate__api__wallet__Wallet_stream_balance_impl(port, ptr, rust_vec_len, data_len)
}
60 => wire__crate__api__wallet__Wallet_wait_for_nostr_payment_impl(
62 => wire__crate__api__wallet__Wallet_wait_for_nostr_payment_impl(
port,
ptr,
rust_vec_len,
data_len,
),
63 => wire__crate__api__mint_info__fetch_keysets_impl(port, ptr, rust_vec_len, data_len),
65 => wire__crate__api__mint_info__get_mint_info_impl(port, ptr, rust_vec_len, data_len),
69 => wire__crate__api__mint_info__ping_mint_impl(port, ptr, rust_vec_len, data_len),
70 => wire__crate__api__wallet__receive_options_default_impl(
65 => wire__crate__api__mint_info__fetch_keysets_impl(port, ptr, rust_vec_len, data_len),
67 => wire__crate__api__mint_info__get_mint_info_impl(port, ptr, rust_vec_len, data_len),
71 => wire__crate__api__mint_info__ping_mint_impl(port, ptr, rust_vec_len, data_len),
72 => wire__crate__api__wallet__receive_options_default_impl(
port,
ptr,
rust_vec_len,
data_len,
),
71 => {
73 => {
wire__crate__api__wallet__send_options_default_impl(port, ptr, rust_vec_len, data_len)
}
_ => unreachable!(),
@@ -4774,14 +4900,14 @@ fn pde_ffi_dispatcher_sync_impl(
35 => wire__crate__api__wallet__Wallet_auto_accessor_set_mint_url_impl(ptr, rust_vec_len, data_len),
36 => wire__crate__api__wallet__Wallet_auto_accessor_set_unit_impl(ptr, rust_vec_len, data_len),
50 => wire__crate__api__wallet__Wallet_new_impl(ptr, rust_vec_len, data_len),
61 => wire__crate__api__token__create_offline_token_impl(ptr, rust_vec_len, data_len),
62 => wire__crate__api__token__encode_qr_token_impl(ptr, rust_vec_len, data_len),
64 => wire__crate__api__keys__generate_mnemonic_impl(ptr, rust_vec_len, data_len),
66 => wire__crate__api__keys__get_pub_key_impl(ptr, rust_vec_len, data_len),
67 => wire__crate__api__keys__mnemonic_to_seed_impl(ptr, rust_vec_len, data_len),
68 => wire__crate__api__payment_request__payment_request_info_parse_impl(ptr, rust_vec_len, data_len),
72 => wire__crate__api__token__token_from_raw_bytes_impl(ptr, rust_vec_len, data_len),
73 => wire__crate__api__token__token_parse_impl(ptr, rust_vec_len, data_len),
63 => wire__crate__api__token__create_offline_token_impl(ptr, rust_vec_len, data_len),
64 => wire__crate__api__token__encode_qr_token_impl(ptr, rust_vec_len, data_len),
66 => wire__crate__api__keys__generate_mnemonic_impl(ptr, rust_vec_len, data_len),
68 => wire__crate__api__keys__get_pub_key_impl(ptr, rust_vec_len, data_len),
69 => wire__crate__api__keys__mnemonic_to_seed_impl(ptr, rust_vec_len, data_len),
70 => wire__crate__api__payment_request__payment_request_info_parse_impl(ptr, rust_vec_len, data_len),
74 => wire__crate__api__token__token_from_raw_bytes_impl(ptr, rust_vec_len, data_len),
75 => wire__crate__api__token__token_parse_impl(ptr, rust_vec_len, data_len),
_ => unreachable!(),
}
}