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:
@@ -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})';
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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:",
|
||||
|
||||
@@ -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:",
|
||||
|
||||
@@ -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:",
|
||||
|
||||
@@ -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 :",
|
||||
|
||||
@@ -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:",
|
||||
|
||||
@@ -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": "金額:",
|
||||
|
||||
@@ -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": "금액:",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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 => '有効なトークン';
|
||||
|
||||
|
||||
@@ -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 => '유효한 토큰';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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 => 'Токен действителен';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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 => '有效代币';
|
||||
|
||||
|
||||
@@ -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:",
|
||||
|
||||
@@ -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": "Сумма:",
|
||||
|
||||
@@ -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:",
|
||||
|
||||
@@ -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": "金额:",
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
|
||||
@@ -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
@@ -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!(),
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user