diff --git a/lib/data/pending_send.dart b/lib/data/pending_send.dart new file mode 100644 index 0000000..d89feae --- /dev/null +++ b/lib/data/pending_send.dart @@ -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 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 proofYs, + required this.createdAt, + this.memo, + }) : proofYs = List.unmodifiable(proofYs); + + PendingSend copyWith({ + String? id, + String? encoded, + BigInt? amount, + String? mintUrl, + String? unit, + List? 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 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 map) { + final ysRaw = map['proof_ys'] as String? ?? ''; + final ys = ysRaw.isEmpty + ? [] + : 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})'; +} diff --git a/lib/data/pending_send_storage.dart b/lib/data/pending_send_storage.dart new file mode 100644 index 0000000..eeb380a --- /dev/null +++ b/lib/data/pending_send_storage.dart @@ -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 _cache = {}; + bool _isInitialized = false; + + final StreamController _changesController = + StreamController.broadcast(); + + Stream 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 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 _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 add({ + required String id, + required String encoded, + required BigInt amount, + required String mintUrl, + required String unit, + required List 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 remove(String id) async { + _cache.remove(id); + await _db?.delete(_tableName, where: 'id = ?', whereArgs: [id]); + _changesController.add(null); + } + + List 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 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 clear() async { + _cache.clear(); + await _db?.delete(_tableName); + _changesController.add(null); + } +} diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 7e55139..df2e627 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -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:", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index bae4491..9a002aa 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -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:", diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index 850ac0e..c0466ec 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -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:", diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 3d95d7b..81cb851 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -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 :", diff --git a/lib/l10n/app_it.arb b/lib/l10n/app_it.arb index 107a2b2..ddd6248 100644 --- a/lib/l10n/app_it.arb +++ b/lib/l10n/app_it.arb @@ -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:", diff --git a/lib/l10n/app_ja.arb b/lib/l10n/app_ja.arb index ddce34f..f41435d 100644 --- a/lib/l10n/app_ja.arb +++ b/lib/l10n/app_ja.arb @@ -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": "金額:", diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb index 3b4b304..7f19b9d 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -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": "금액:", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 9a4943a..438b560 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -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: diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index e63f002..e349d0e 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -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'; diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 1802190..93aa8ca 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -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'; diff --git a/lib/l10n/app_localizations_es.dart b/lib/l10n/app_localizations_es.dart index d6cce61..d56c030 100644 --- a/lib/l10n/app_localizations_es.dart +++ b/lib/l10n/app_localizations_es.dart @@ -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'; diff --git a/lib/l10n/app_localizations_fr.dart b/lib/l10n/app_localizations_fr.dart index 8c46c94..0e4990f 100644 --- a/lib/l10n/app_localizations_fr.dart +++ b/lib/l10n/app_localizations_fr.dart @@ -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'; diff --git a/lib/l10n/app_localizations_it.dart b/lib/l10n/app_localizations_it.dart index 31f876e..990a971 100644 --- a/lib/l10n/app_localizations_it.dart +++ b/lib/l10n/app_localizations_it.dart @@ -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'; diff --git a/lib/l10n/app_localizations_ja.dart b/lib/l10n/app_localizations_ja.dart index 16d824d..93f0ec3 100644 --- a/lib/l10n/app_localizations_ja.dart +++ b/lib/l10n/app_localizations_ja.dart @@ -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 => '有効なトークン'; diff --git a/lib/l10n/app_localizations_ko.dart b/lib/l10n/app_localizations_ko.dart index 78b08a4..638dbb1 100644 --- a/lib/l10n/app_localizations_ko.dart +++ b/lib/l10n/app_localizations_ko.dart @@ -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 => '유효한 토큰'; diff --git a/lib/l10n/app_localizations_pt.dart b/lib/l10n/app_localizations_pt.dart index 482e1c7..0658a04 100644 --- a/lib/l10n/app_localizations_pt.dart +++ b/lib/l10n/app_localizations_pt.dart @@ -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'; diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index 0f4ce70..ae4015e 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -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 => 'Токен действителен'; diff --git a/lib/l10n/app_localizations_sw.dart b/lib/l10n/app_localizations_sw.dart index f4238b1..44faa19 100644 --- a/lib/l10n/app_localizations_sw.dart +++ b/lib/l10n/app_localizations_sw.dart @@ -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'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index 530e17c..313879b 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -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 => '有效代币'; diff --git a/lib/l10n/app_pt.arb b/lib/l10n/app_pt.arb index b096fb3..6a89396 100644 --- a/lib/l10n/app_pt.arb +++ b/lib/l10n/app_pt.arb @@ -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:", diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 1a9a662..1dd26c6 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -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": "Сумма:", diff --git a/lib/l10n/app_sw.arb b/lib/l10n/app_sw.arb index e436ad4..630f4f8 100644 --- a/lib/l10n/app_sw.arb +++ b/lib/l10n/app_sw.arb @@ -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:", diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index ca8fd93..fdd0d1c 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -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": "金额:", diff --git a/lib/providers/wallet_provider.dart b/lib/providers/wallet_provider.dart index 67ebf9f..f136e6e 100644 --- a/lib/providers/wallet_provider.dart +++ b/lib/providers/wallet_provider.dart @@ -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 get pendingTokenChanges => _pendingTokenStorage.changes; + // ============================================================ + // PENDING SENDS GETTERS (envíos offline no reclamados) + // ============================================================ + + int get pendingSendCount => _pendingSendStorage.count; + bool get hasPendingSends => _pendingSendStorage.hasPendingSends; + Stream get pendingSendChanges => _pendingSendStorage.changes; + + List listPendingSends() => _pendingSendStorage.listAll(); + + List 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 addPendingSend({ + required String encoded, + required BigInt amount, + required String mintUrl, + required String unit, + required List 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 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 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> 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 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 _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'; diff --git a/lib/screens/5_send/offline_send_screen.dart b/lib/screens/5_send/offline_send_screen.dart index 2946a3a..cd0827b 100644 --- a/lib/screens/5_send/offline_send_screen.dart +++ b/lib/screens/5_send/offline_send_screen.dart @@ -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 { 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 _availableProofs = []; Set _selectedIds = {}; bool _isLoading = true; @@ -42,6 +51,7 @@ class _OfflineSendScreenState extends State { @override void initState() { super.initState(); + _walletProvider = context.read(); _loadProofs(); } @@ -365,6 +375,20 @@ class _OfflineSendScreenState extends State { ); 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( diff --git a/lib/screens/9_history/history_screen.dart b/lib/screens/9_history/history_screen.dart index c5de015..2a8a05c 100644 --- a/lib/screens/9_history/history_screen.dart +++ b/lib/screens/9_history/history_screen.dart @@ -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 { 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() + : []; + return FutureBuilder>( future: walletProvider.getAllTransactions(), builder: (context, snapshot) { @@ -169,22 +181,30 @@ class _HistoryScreenState extends State { } 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(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 _confirmReclaim() async { + final l10n = L10n.of(context)!; + final confirmed = await showModalBottomSheet( + 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 _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 _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 _confirmReclaim() async { + final l10n = L10n.of(context)!; + final confirmed = await showModalBottomSheet( + 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 _doReclaim() async { + final l10n = L10n.of(context)!; + final walletProvider = context.read(); + 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(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), + ], + ), + ), + ), + ), + ); + } +} + diff --git a/lib/src/rust/api/wallet.dart b/lib/src/rust/api/wallet.dart index 8a3abf9..0fc4036 100644 --- a/lib/src/rust/api/wallet.dart +++ b/lib/src/rust/api/wallet.dart @@ -128,8 +128,19 @@ abstract class Wallet implements RustOpaqueInterface { /// Returns count and total amount of proofs recovered. Future 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 reclaimProofsByYs({required List ys}); + Future 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 refreshBalance(); + Future restore(); Future send({ diff --git a/lib/src/rust/frb_generated.dart b/lib/src/rust/frb_generated.dart index 2a35482..89b1989 100644 --- a/lib/src/rust/frb_generated.dart +++ b/lib/src/rust/frb_generated.dart @@ -69,7 +69,7 @@ class RustLib extends BaseEntrypoint { 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 crateApiWalletWalletReclaimProofsByYs({ + required Wallet that, + required List ys, + }); + Future crateApiWalletWalletRecoverIncompleteSagas({ required Wallet that, }); + Future crateApiWalletWalletRefreshBalance({required Wallet that}); + Future crateApiWalletWalletRestore({required Wallet that}); Future crateApiWalletWalletSend({ @@ -2339,6 +2346,44 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { argNames: ["that"], ); + @override + Future crateApiWalletWalletReclaimProofsByYs({ + required Wallet that, + required List 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 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 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 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 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 reclaimProofsByYs({required List ys}) => RustLib + .instance + .api + .crateApiWalletWalletReclaimProofsByYs(that: this, ys: ys); + Future 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 refreshBalance() => + RustLib.instance.api.crateApiWalletWalletRefreshBalance(that: this); + Future restore() => RustLib.instance.api.crateApiWalletWalletRestore(that: this); diff --git a/rust/src/api/wallet.rs b/rust/src/api/wallet.rs index 1e03b4a..fb390c0 100644 --- a/rust/src/api/wallet.rs +++ b/rust/src/api/wallet.rs @@ -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) -> Result { + let target_ys: std::collections::HashSet = 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 = 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 = 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 { @@ -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 diff --git a/rust/src/frb_generated.rs b/rust/src/frb_generated.rs index 7cf5585..8c973fe 100644 --- a/rust/src/frb_generated.rs +++ b/rust/src/frb_generated.rs @@ -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::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 = , + >>::sse_decode(&mut deserializer); + let api_ys = >::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::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 = , + >>::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!(), } }