Compare commits

...
Author SHA1 Message Date
Forte11Cuba 7bc9639e89 fix: show all supported units on mints list, dim zero-balance pills 2026-04-18 03:55:18 -06:00
Forte11andGitHub e11778ff0a Merge pull request #116 from Forte11Cuba/feat/observe-only-reconcile-and-settled-state
fix: observe-only reconcile + settled state for offline sends
2026-04-18 03:44:17 -06:00
Forte11Cuba d5d4b3a6ea fix: address CodeRabbit review 2026-04-18 03:29:51 -06:00
Forte11Cuba a0f9ca738b fix: observe-only reconcile + settled state for offline sends 2026-04-18 02:09:50 -06:00
Forte11andGitHub ca63c56ecf Merge pull request #114 from Forte11Cuba/feat/reclaim-pending-send-from-history
feat: add cancel/reclaim button for pending sends (online + offline)
2026-04-18 01:05:04 -06:00
Forte11Cuba 5cdce99154 fix: address CodeRabbit review on #87 2026-04-17 07:52:38 -06:00
Forte11Cuba f734b35670 feat: add cancel/reclaim button for pending sends (online + offline) 2026-04-17 07:12:33 -06:00
Forte11andGitHub 5eef9ce130 Merge pull request #113 from Forte11Cuba/feat/paste-from-clipboard-in-scanner
feat: add paste-from-clipboard button to QR scanner
2026-04-17 06:04:53 -06:00
Forte11Cuba f2951d2bef feat: add paste-from-clipboard button to QR scanner 2026-04-17 05:07:53 -06:00
Forte11andGitHub 8015b4817b Merge pull request #112 from Forte11Cuba/fix/home-action-arrows-as-icons
fix: render home send/receive arrows as Lucide icons
2026-04-17 04:58:57 -06:00
Forte11Cuba cc1531f3d5 fix: render home send/receive arrows as Lucide icons 2026-04-17 03:55:09 -06:00
Forte11andGitHub aaef4b1cdc Merge pull request #111 from Forte11Cuba/fix/modal-bottom-sheet-safe-area
fix: bottom sheet modals overlap system navigation bar
2026-04-17 03:45:46 -06:00
Forte11Cuba dacec9ea22 fix: bottom sheet modals overlap system navigation bar 2026-04-17 03:39:41 -06:00
Forte11andGitHub 4b8edf9669 Merge pull request #110 from Forte11Cuba/fix/reclaim-per-unit-and-cancel-melt
fix: aggregate reclaimPendingProofs by unit and replace broken cancel Melt/cancelSend
2026-04-15 20:05:54 -06:00
Forte11Cuba f458cfad34 fix: aggregate reclaimPendingProofs by unit and replace broken cancelMelt/cancelSend 2026-04-15 17:37:27 -06:00
Forte11Cuba 428f2c1f45 fix: aggregate reclaimPendingProofs by unit and replace broken cancelMelt/cancelSend 2026-04-15 17:19:38 -06:00
Forte11andGitHub 9a14af33a2 Merge pull request #107 from Forte11Cuba/fix/melt-proofs-lost-and-min-amount-validation
fix: recover reserved proofs on melt failure and validate minimum viable amounts
2026-04-15 06:10:08 -06:00
Forte11Cuba 770868a773 fix: add fee pre-validation to swap and log cancelMelt failures 2026-04-15 04:48:03 -06:00
Forte11Cuba dc78f8958c fix: recover reserved proofs on melt failure and validate minimum viable amounts 2026-04-15 04:20:53 -06:00
Forte11andGitHub 03ba0e532f Merge pull request #103 from Forte11Cuba/refactor/split-settings-screen
refactor: split settings_screen.dart into separate files
2026-04-12 16:34:06 -06:00
Forte11Cuba a6a4365a0d fix: remove unnecessary async from synchronous _loadMints 2026-04-12 16:33:26 -06:00
Forte11Cuba ac23574ae2 fix: count only mints that actually recovered tokens in restore message 2026-04-12 16:07:24 -06:00
Forte11Cuba 87f8e52036 fix: use addPostFrameCallback for safe context access in RecoverTokensModal 2026-04-12 15:56:26 -06:00
Forte11Cuba eb9319fca0 refactor: split settings_screen.dart into separate files 2026-04-12 15:43:08 -06:00
Forte11andGitHub a9e4272762 Merge pull request #102 from Forte11Cuba/feat/privacy-screen
feat: add privacy policy screen with humorous tone
2026-04-11 03:01:53 -06:00
Forte11Cuba f66668f0ec fix: normalize privacyBody split to filter empty lines 2026-04-11 02:32:46 -06:00
Forte11Cuba 100a4943eb fix: handle privacyConclusion lines robustly for any locale line count 2026-04-11 02:27:46 -06:00
Forte11Cuba 5b1317c3e4 fix: address CodeRabbit review Portuguese register, scrollable layout 2026-04-11 02:22:28 -06:00
Forte11Cuba d85b6562dc feat: add privacy policy screen with humorous tone 2026-04-11 01:32:43 -06:00
Forte11andGitHub 56585319cc Merge pull request #101 from Forte11Cuba/feat/swap-chart-improvements
feat: improve swap price chart — real data only, 24h stats, min/max
2026-04-11 00:54:52 -06:00
Forte11Cuba 79ca30c5ed fix: filter non-finite and zero price samples before rendering chart 2026-04-11 00:37:57 -06:00
Forte11Cuba 0bd988a7a6 fix: address CodeRabbit review handle 2 samples, use i18n template for minMax 2026-04-11 00:31:53 -06:00
Forte11Cuba 218d798d2a feat: improve swap price chart — real data only, 24h stats, min/max 2026-04-11 00:19:43 -06:00
Forte11andGitHub e8d8789413 Merge pull request #100 from Forte11Cuba/feat/swap-screen
feat: add swap screen UI (sats -> USD) with price chart
2026-04-10 04:47:37 -06:00
Forte11Cuba a2866d035a fix: truncate conversion amounts with floor instead of round to prevent overpaying 2026-04-10 04:44:32 -06:00
Forte11Cuba 1a8e3e601b fix: show mint name in AppBar, make confirmation sheet non-dismissible, hardcode Cuba Bitcoin mint 2026-04-10 04:06:40 -06:00
Forte11Cuba b3dde35141 fix: add try/catch to _loadBalances, localize fee/total labels, remove redundant ternary 2026-04-10 03:48:58 -06:00
Forte11Cuba 8541222da6 fix: remove redundant ternary, capture provider ref outside async callback 2026-04-10 03:26:49 -06:00
Forte11Cuba d6085f7754 fix: improve swap reliability — remove unsafe tx fallback, add mint stream timeout and issued handler 2026-04-10 03:18:27 -06:00
Forte11Cuba d2035ba416 feat: implement swap logic (sats <-> USD) with bidirectional input, fee preview, and mint validation 2026-04-10 02:57:55 -06:00
Forte11Cuba 42bab5d18a feat: add swap screen UI (sats -> USD) with price chart 2026-04-09 04:54:48 -06:00
Forte11andGitHub 6c05785391 Merge pull request #99 from Forte11Cuba/chore/upgrade-cdk-v0.16.0
docs: update CDK version to 0.16.0 in README
2026-04-09 03:57:31 -06:00
Forte11Cuba 65224314e1 docs: update CDK version to 0.16.0 in README 2026-04-09 03:53:02 -06:00
Forte11andGitHub 2534cf5d1e Merge pull request #98 from Forte11Cuba/chore/upgrade-cdk-v0.16.0
chore: upgrade CDK from v0.15.1 to v0.16.0
2026-04-05 20:32:14 -06:00
Forte11Cuba 262b049c20 chore: upgrade CDK from v0.15.1 to v0.16.0 2026-04-05 20:09:55 -06:00
Forte11Cuba 697f6fa555 chore: upgrade CDK from v0.15.1 to v0.16.0 2026-04-05 19:56:34 -06:00
Forte11andGitHub 02fec140e1 Merge pull request #97 from Forte11Cuba/release/v0.2.0
Release APK / build (push) Canceled after 0s
chore: bump version to v0.2.0+3
2026-04-05 16:08:16 -06:00
Forte11Cuba 7cc92d6732 chore: bump version to v0.2.0+3 2026-04-05 15:51:01 -06:00
Forte11andGitHub 013560d25b Merge pull request #96 from Forte11Cuba/chore/rename-package-id
chore: rename package identifier from com.example.elcaju to me.elcaju
2026-04-03 18:44:20 -06:00
Forte11Cuba d19a58df84 chore: rename package identifier from com.example.elcaju to me.elcaju 2026-04-03 17:51:08 -06:00
Forte11andGitHub fd64d2f269 Merge pull request #95 from Forte11Cuba/fix/mint-http-polling-fallback
fix: add HTTP polling fallback for mint quote payment detection
2026-04-03 17:46:23 -06:00
61 changed files with 9311 additions and 2007 deletions
+2 -2
View File
@@ -214,7 +214,7 @@ flutter build apk --release --target-platform android-arm64
│ flutter_rust_bridge v2.11.1 (FFI) │
├─────────────────────────────────────────┤
│ elcaju_core (Rust interno) │
│ CDK 0.15.1 + SQLite + BIP39 + Tokio │
│ CDK 0.16.0 + SQLite + BIP39 + Tokio │
└─────────────────────────────────────────┘
```
@@ -256,7 +256,7 @@ elcaju/
│ │ └── scanner/ # QR scanner widget
│ └── l10n/ # 11 languages (354 translation keys)
├── rust/
│ ├── Cargo.toml # elcaju_core: CDK 0.15.1, flutter_rust_bridge 2.11.1
│ ├── Cargo.toml # elcaju_core: CDK 0.16.0, flutter_rust_bridge 2.11.1
│ └── src/api/ # wallet, token, keys, mint_info, error
├── rust_builder/ # Cargokit integration (auto Rust compilation)
├── android/ # Android config (NDK 27.0, NFC HCE service)
+6 -6
View File
@@ -368,7 +368,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.example.elcaju;
PRODUCT_BUNDLE_IDENTIFIER = me.elcaju;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_VERSION = 5.0;
@@ -384,7 +384,7 @@
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.example.elcaju.RunnerTests;
PRODUCT_BUNDLE_IDENTIFIER = me.elcaju.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
@@ -401,7 +401,7 @@
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.example.elcaju.RunnerTests;
PRODUCT_BUNDLE_IDENTIFIER = me.elcaju.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
@@ -416,7 +416,7 @@
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.example.elcaju.RunnerTests;
PRODUCT_BUNDLE_IDENTIFIER = me.elcaju.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
@@ -547,7 +547,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.example.elcaju;
PRODUCT_BUNDLE_IDENTIFIER = me.elcaju;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
@@ -569,7 +569,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.example.elcaju;
PRODUCT_BUNDLE_IDENTIFIER = me.elcaju;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_VERSION = 5.0;
+56
View File
@@ -1,4 +1,5 @@
import 'dart:convert';
import 'dart:math';
import 'package:http/http.dart' as http;
/// Servicio para obtener precios de Bitcoin usando Yadio API
@@ -86,4 +87,59 @@ class PriceService {
// Retornar en centavos
return BigInt.from((fiatAmount * 100).round());
}
// --- Blink API (precios históricos) ---
static const _blinkUrl = 'https://api.blink.sv/graphql';
/// Obtiene precios históricos de BTC desde Blink API.
/// range: ONE_DAY, ONE_WEEK, ONE_MONTH, ONE_YEAR, FIVE_YEARS
static Future<List<PricePoint>> getHistoricalPrices({
String range = 'ONE_DAY',
}) async {
try {
final response = await http.post(
Uri.parse(_blinkUrl),
headers: {'Content-Type': 'application/json'},
body: jsonEncode({
'query':
'query { btcPriceList(range: $range) { price { base offset } timestamp } }',
}),
).timeout(const Duration(seconds: 15));
if (response.statusCode != 200) {
throw Exception('Error HTTP: ${response.statusCode}');
}
final json = jsonDecode(response.body) as Map<String, dynamic>;
final data = json['data'] as Map<String, dynamic>?;
if (data == null) throw Exception('No data in response');
final list = data['btcPriceList'] as List?;
if (list == null || list.isEmpty) {
throw Exception('No historical prices in response');
}
return list.map((point) {
final price = point['price'] as Map<String, dynamic>;
final base = (price['base'] as num).toDouble();
final offset = (price['offset'] as num).toInt();
// base / 10^offset = USD cents → /100 = USD
final priceUsd = base / pow(10, offset) / 100;
final timestamp = (point['timestamp'] as num).toInt();
return PricePoint(timestamp: timestamp, priceUsd: priceUsd);
}).toList();
} catch (e) {
throw Exception('Error obteniendo precios históricos: $e');
}
}
}
/// Punto de precio histórico de BTC
class PricePoint {
final int timestamp;
final double priceUsd;
PricePoint({required this.timestamp, required this.priceUsd});
}
+156
View File
@@ -0,0 +1,156 @@
/// Estado del envío dentro del storage propio.
///
/// - [active] : el receptor aún no reclamó; los proofs están PendingSpent
/// localmente. Mostrar con acción de cancelar/reclamar.
/// - [settled] : la reconciliación con el mint confirmó que los proofs
/// fueron gastados (receptor reclamó) o que una reconciliación
/// previa ya los procesó. Mostrar como tx saliente histórica,
/// sin acción de cancel.
enum PendingSendStatus {
active,
settled;
String get wireName => name;
static PendingSendStatus fromWire(String? raw) {
switch (raw) {
case 'settled':
return PendingSendStatus.settled;
case 'active':
case null:
default:
return PendingSendStatus.active;
}
}
}
/// Modelo para rastrear envíos offline.
///
/// Los envíos offline no crean una Transaction en CDK (porque se hacen con
/// selección manual de proofs + createOfflineToken), por lo que necesitamos
/// un storage propio para poder mostrarlos en el historial y permitir
/// cancelar/reclamar por transacción.
///
/// Los proofs se identifican por sus Y values (hex) — mismos que acepta
/// Wallet.reclaimProofsByYs() en el bridge Rust.
class PendingSend {
/// UUID único
final String id;
/// Token codificado completo (cashuA... o cashuB...)
final String encoded;
/// Monto total del envío (suma de los proofs)
final BigInt amount;
/// URL del mint
final String mintUrl;
/// Unidad (sat, usd, eur, etc.)
final String unit;
/// Y values de los proofs incluidos (hex). Se usan para reclamar.
final List<String> proofYs;
/// Fecha de creación del envío
final DateTime createdAt;
/// Memo opcional
final String? memo;
/// Estado del envío. Ver [PendingSendStatus].
final PendingSendStatus status;
/// Timestamp de cuando el envío pasó a [PendingSendStatus.settled].
/// Null mientras esté activo. Se usa para ordenar en el historial.
final DateTime? settledAt;
PendingSend({
required this.id,
required this.encoded,
required this.amount,
required this.mintUrl,
required this.unit,
required List<String> proofYs,
required this.createdAt,
this.memo,
this.status = PendingSendStatus.active,
this.settledAt,
}) : proofYs = List.unmodifiable(proofYs);
bool get isActive => status == PendingSendStatus.active;
bool get isSettled => status == PendingSendStatus.settled;
/// Para ordenar en el historial: si está settled, usar settledAt; si no,
/// usar createdAt. Condicionado en `isSettled` (no sólo en `settledAt != null`)
/// para que una corrupción futura del campo no reubique un record activo.
DateTime get effectiveTimestamp =>
isSettled ? (settledAt ?? createdAt) : createdAt;
PendingSend copyWith({
String? id,
String? encoded,
BigInt? amount,
String? mintUrl,
String? unit,
List<String>? proofYs,
DateTime? createdAt,
String? memo,
PendingSendStatus? status,
DateTime? settledAt,
}) {
return PendingSend(
id: id ?? this.id,
encoded: encoded ?? this.encoded,
amount: amount ?? this.amount,
mintUrl: mintUrl ?? this.mintUrl,
unit: unit ?? this.unit,
proofYs: proofYs ?? this.proofYs,
createdAt: createdAt ?? this.createdAt,
memo: memo ?? this.memo,
status: status ?? this.status,
settledAt: settledAt ?? this.settledAt,
);
}
Map<String, dynamic> toMap() {
return {
'id': id,
'encoded': encoded,
'amount': amount.toString(),
'mint_url': mintUrl,
'unit': unit,
'proof_ys': proofYs.join(','),
'created_at': createdAt.millisecondsSinceEpoch,
'memo': memo,
'status': status.wireName,
'settled_at': settledAt?.millisecondsSinceEpoch,
};
}
factory PendingSend.fromMap(Map<String, dynamic> map) {
final ysRaw = map['proof_ys'] as String? ?? '';
final ys = ysRaw.isEmpty
? <String>[]
: ysRaw.split(',').where((s) => s.isNotEmpty).toList();
final settledAtRaw = map['settled_at'] as int?;
return PendingSend(
id: map['id'] as String,
encoded: map['encoded'] as String,
amount: BigInt.parse(map['amount'] as String),
mintUrl: map['mint_url'] as String,
unit: map['unit'] as String,
proofYs: ys,
createdAt: DateTime.fromMillisecondsSinceEpoch(map['created_at'] as int),
memo: map['memo'] as String?,
status: PendingSendStatus.fromWire(map['status'] as String?),
settledAt: settledAtRaw == null
? null
: DateTime.fromMillisecondsSinceEpoch(settledAtRaw),
);
}
@override
String toString() =>
'PendingSend(id: $id, amount: $amount $unit, mintUrl: $mintUrl, ys: ${proofYs.length}, status: ${status.name})';
}
+214
View File
@@ -0,0 +1,214 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:path_provider/path_provider.dart';
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
import 'pending_send.dart';
/// Storage persistente para envíos offline.
///
/// Los envíos offline no crean una Transaction en CDK, así que necesitamos
/// rastrearlos nosotros para poder:
/// - Mostrarlos en el historial como activos (cancelables) o como
/// liquidados (settled, display-only).
/// - Reclamar las proofs por transacción si el receptor no reclamó.
///
/// Mirrors PendingTokenStorage (singleton + cache + SQLite + change stream).
class PendingSendStorage {
static const _dbName = 'pending_sends.db';
static const _tableName = 'pending_sends';
static const _schemaVersion = 2;
Database? _db;
final Map<String, PendingSend> _cache = {};
bool _isInitialized = false;
final StreamController<void> _changesController =
StreamController<void>.broadcast();
Stream<void> get changes => _changesController.stream;
static final PendingSendStorage _instance = PendingSendStorage._internal();
factory PendingSendStorage() => _instance;
PendingSendStorage._internal();
bool get isInitialized => _isInitialized;
int get count => _cache.length;
bool get hasPendingSends => _cache.isNotEmpty;
int get activeCount =>
_cache.values.where((s) => s.isActive).length;
bool get hasActivePendingSends => activeCount > 0;
Future<void> init() async {
if (_isInitialized) return;
if (Platform.isLinux || Platform.isWindows) {
sqfliteFfiInit();
databaseFactory = databaseFactoryFfi;
}
final dir = await getApplicationDocumentsDirectory();
final dbPath = '${dir.path}/$_dbName';
_db = await openDatabase(
dbPath,
version: _schemaVersion,
onCreate: (db, version) async {
await db.execute('''
CREATE TABLE $_tableName (
id TEXT PRIMARY KEY,
encoded TEXT NOT NULL,
amount TEXT NOT NULL,
mint_url TEXT NOT NULL,
unit TEXT NOT NULL,
proof_ys TEXT NOT NULL,
created_at INTEGER NOT NULL,
memo TEXT,
status TEXT NOT NULL DEFAULT 'active',
settled_at INTEGER
)
''');
await db.execute(
'CREATE INDEX idx_ps_mint_unit ON $_tableName(mint_url, unit)',
);
await db.execute(
'CREATE INDEX idx_ps_created_at ON $_tableName(created_at)',
);
await db.execute(
'CREATE INDEX idx_ps_status ON $_tableName(status)',
);
},
onUpgrade: (db, oldVersion, newVersion) async {
if (oldVersion < 2) {
// v2: status + settled_at. Los records existentes quedan 'active'.
await db.execute(
"ALTER TABLE $_tableName ADD COLUMN status TEXT NOT NULL DEFAULT 'active'",
);
await db.execute(
'ALTER TABLE $_tableName ADD COLUMN settled_at INTEGER',
);
await db.execute(
'CREATE INDEX IF NOT EXISTS idx_ps_status ON $_tableName(status)',
);
}
},
);
await _loadFromDb();
_isInitialized = true;
debugPrint(
'PendingSendStorage inicializado: ${_cache.length} envíos '
'($activeCount activos)',
);
}
Future<void> _loadFromDb() async {
if (_db == null) return;
final results = await _db!.query(_tableName, orderBy: 'created_at DESC');
_cache.clear();
for (final row in results) {
final send = PendingSend.fromMap(row);
_cache[send.id] = send;
}
}
Future<PendingSend> add({
required String id,
required String encoded,
required BigInt amount,
required String mintUrl,
required String unit,
required List<String> proofYs,
String? memo,
}) async {
final send = PendingSend(
id: id,
encoded: encoded,
amount: amount,
mintUrl: mintUrl,
unit: unit,
proofYs: proofYs,
createdAt: DateTime.now(),
memo: memo,
// status default = active
);
// Persistencia primero: si SQLite falla, la cache no queda contaminada
// con un record que el reinicio no va a encontrar.
await _db?.insert(_tableName, send.toMap());
_cache[send.id] = send;
_changesController.add(null);
return send;
}
/// Marca un envío como liquidado (settled). Se usa cuando la reconciliación
/// confirmó que el receptor reclamó o el envío ya no es actionable.
/// Si no existe el record, no hace nada.
Future<void> markSettled(String id) async {
final current = _cache[id];
if (current == null) return;
if (current.isSettled) return; // idempotente
final updated = current.copyWith(
status: PendingSendStatus.settled,
settledAt: DateTime.now(),
);
// Persistencia primero: si SQLite falla, la cache queda activa y un
// próximo reconcile reintenta. Con el orden inverso, la idempotencia
// (`isSettled → early return`) impediría el retry tras una falla.
await _db?.update(
_tableName,
updated.toMap(),
where: 'id = ?',
whereArgs: [id],
);
_cache[id] = updated;
_changesController.add(null);
}
/// Borrado permanente. Reservado para "dismiss manual" (no implementado
/// hoy en UI) o cleanup explícito del wallet (`clear`). Para el caso
/// "receptor reclamó", usar `markSettled` en vez de este.
Future<void> remove(String id) async {
// Persistencia primero: igual que add/markSettled, la cache no se
// adelanta al storage.
await _db?.delete(_tableName, where: 'id = ?', whereArgs: [id]);
_cache.remove(id);
_changesController.add(null);
}
List<PendingSend> listAll() {
final list = _cache.values.toList();
list.sort((a, b) => b.createdAt.compareTo(a.createdAt));
return list;
}
/// Envíos activos (el receptor aún no reclamó). Ordenados por creación
/// descendente. Renderizables en el historial con botón cancel.
List<PendingSend> listActive() {
return _cache.values.where((s) => s.isActive).toList()
..sort((a, b) => b.createdAt.compareTo(a.createdAt));
}
/// Envíos liquidados (el receptor ya reclamó). Ordenados por `settledAt`
/// descendente — representa la línea temporal de finalización.
List<PendingSend> listSettled() {
return _cache.values.where((s) => s.isSettled).toList()
..sort((a, b) => b.effectiveTimestamp.compareTo(a.effectiveTimestamp));
}
PendingSend? get(String id) => _cache[id];
/// Filtra por mint+unit. Útil para mostrar pendientes específicos del
/// wallet activo.
List<PendingSend> listByMintUnit(String mintUrl, String unit) {
return _cache.values
.where((s) => s.mintUrl == mintUrl && s.unit == unit)
.toList()
..sort((a, b) => b.createdAt.compareTo(a.createdAt));
}
Future<void> clear() async {
_cache.clear();
await _db?.delete(_tableName);
_changesController.add(null);
}
}
+43 -3
View File
@@ -63,8 +63,8 @@
"homeTitle": "Start",
"receive": "Empfangen",
"send": "Senden",
"sendAction": "Senden",
"receiveAction": "Empfangen",
"sendAction": "Senden",
"receiveAction": "Empfangen",
"deposit": "Einzahlen",
"withdraw": "Abheben",
"lightning": "Lightning",
@@ -86,6 +86,21 @@
"receiveCashu": "Cashu empfangen",
"pasteTheCashuToken": "Füge den Cashu Token ein:",
"pasteFromClipboard": "Aus Zwischenablage einfügen",
"emptyClipboard": "Zwischenablage leer",
"cancelSend": "Senden abbrechen",
"cancelSendConfirmTitle": "Diesen Versand abbrechen?",
"cancelSendConfirmBody": "Funktioniert nur, wenn der Empfänger das Token noch nicht eingelöst hat. Falls doch, wird der Versand als abgeschlossen markiert.",
"cancelSendSuccess": "Versand abgebrochen. {amount} {unit} zurückerhalten",
"@cancelSendSuccess": {
"placeholders": {
"amount": {"type": "String"},
"unit": {"type": "String"}
}
},
"cancelSendAlreadyClaimed": "Der Empfänger hat das Token bereits eingelöst",
"pendingOfflineSend": "Ausstehender Offline-Versand",
"pendingOfflineSendsHeader": "Nicht eingelöste Offline-Versendungen",
"pendingSendDetailTitle": "Ausstehender Versand",
"validToken": "Token gültig",
"invalidToken": "Ungültiger oder fehlerhafter Token",
"amount": "Betrag:",
@@ -118,6 +133,7 @@
"confirm": "Bestätigen",
"cancel": "Abbrechen",
"insufficientBalance": "Unzureichendes Guthaben",
"feeExceedsAmount": "Die Gebühr übersteigt den Sendebetrag",
"tokenCreationError": "Fehler beim Erstellen des Tokens: {error}",
"@tokenCreationError": {
"placeholders": {
@@ -227,6 +243,8 @@
"pendingStatus": "Ausstehend",
"receivedStatus": "Empfangen",
"sentStatus": "Gesendet",
"receiving": "Wird empfangen",
"sending": "Wird gesendet",
"now": "Jetzt",
"agoMinutes": "Vor {minutes} Min",
"@agoMinutes": {
@@ -559,5 +577,27 @@
"requestPaymentReceived": "Zahlung erhalten",
"requestDescriptionHint": "Beschreibung (optional)",
"universal": "Universal",
"copiedToClipboard": "In die Zwischenablage kopiert"
"copiedToClipboard": "In die Zwischenablage kopiert",
"swap": "Tauschen",
"swapDescription": "Zwischen Sats und USD wechseln",
"swapFrom": "Von",
"swapTo": "Nach",
"swapAction": "Tauschen",
"swapEstimatedFee": "Geschätzte Gebühr",
"swapUseAll": "Alles verwenden",
"swapMinimum": "Minimum: {amount}",
"swapProcessing": "Swap wird verarbeitet...",
"swapSuccess": "Swap abgeschlossen",
"swapErrorInsufficient": "Unzureichendes Guthaben",
"swapErrorExpired": "Angebot abgelaufen",
"swapErrorGeneric": "Swap-Fehler: {error}",
"swapChartUnavailable": "Preis nicht verfügbar · Tippen zum Wiederholen",
"swapChartMinMax": "24h Min: {minPrice} — Max: {maxPrice}",
"privacyPolicy": "Datenschutz",
"privacyTitle": "WIR SAMMELN NICHTS",
"privacyGoodbye": "TSCHÜSS",
"privacyKeepReading": "(lies weiter, wenn du willst…)",
"privacyBody": "Wir wissen nicht, wer du bist\nWir wissen nicht, wie viel du hast\nWir wissen nicht, was du tust",
"privacyConclusion": "Der beste Weg, deine Daten zu schützen,\nist sie nicht zu haben"
}
+43 -3
View File
@@ -53,8 +53,8 @@
"homeTitle": "Home",
"receive": "Receive",
"send": "Send",
"sendAction": "Send",
"receiveAction": "Receive",
"sendAction": "Send",
"receiveAction": "Receive",
"deposit": "Deposit",
"withdraw": "Withdraw",
"lightning": "Lightning",
@@ -76,6 +76,21 @@
"receiveCashu": "Receive Cashu",
"pasteTheCashuToken": "Paste the Cashu token:",
"pasteFromClipboard": "Paste from clipboard",
"emptyClipboard": "Clipboard is empty",
"cancelSend": "Cancel send",
"cancelSendConfirmTitle": "Cancel this send?",
"cancelSendConfirmBody": "Only works if the recipient has not claimed the token yet. If they already did, the send will be marked as completed.",
"cancelSendSuccess": "Send canceled. Recovered {amount} {unit}",
"@cancelSendSuccess": {
"placeholders": {
"amount": {"type": "String"},
"unit": {"type": "String"}
}
},
"cancelSendAlreadyClaimed": "The recipient already claimed the token",
"pendingOfflineSend": "Pending offline send",
"pendingOfflineSendsHeader": "Unclaimed offline sends",
"pendingSendDetailTitle": "Pending send",
"validToken": "Valid token",
"invalidToken": "Invalid or malformed token",
"amount": "Amount:",
@@ -103,6 +118,7 @@
"confirm": "Confirm",
"cancel": "Cancel",
"insufficientBalance": "Insufficient balance",
"feeExceedsAmount": "Fee exceeds the amount to send",
"tokenCreationError": "Error creating token: {error}",
"tokenCreated": "Token created",
@@ -193,6 +209,8 @@
"pendingStatus": "Pending",
"receivedStatus": "Received",
"sentStatus": "Sent",
"receiving": "Receiving",
"sending": "Sending",
"now": "Now",
"agoMinutes": "{minutes} min ago",
"agoHours": "{hours} h ago",
@@ -452,5 +470,27 @@
"requestPaymentReceived": "Payment received",
"requestDescriptionHint": "Description (optional)",
"universal": "Universal",
"copiedToClipboard": "Copied to clipboard"
"copiedToClipboard": "Copied to clipboard",
"swap": "Swap",
"swapDescription": "Convert between sats and USD",
"swapFrom": "From",
"swapTo": "To",
"swapAction": "Swap",
"swapEstimatedFee": "Estimated fee",
"swapUseAll": "Use all",
"swapMinimum": "Minimum: {amount}",
"swapProcessing": "Processing swap...",
"swapSuccess": "Swap completed",
"swapErrorInsufficient": "Insufficient balance",
"swapErrorExpired": "Quote has expired",
"swapErrorGeneric": "Swap error: {error}",
"swapChartUnavailable": "Price unavailable · Tap to retry",
"swapChartMinMax": "24h Min: {minPrice} — Max: {maxPrice}",
"privacyPolicy": "Privacy policy",
"privacyTitle": "WE COLLECT NOTHING",
"privacyGoodbye": "BYE",
"privacyKeepReading": "(keep reading if you want…)",
"privacyBody": "We don't know who you are\nWe don't know how much you have\nWe don't know what you do",
"privacyConclusion": "The best way to protect your data\nis not to have it"
}
+59 -3
View File
@@ -63,8 +63,8 @@
"homeTitle": "Inicio",
"receive": "Recibir",
"send": "Enviar",
"sendAction": "Enviar",
"receiveAction": "Recibir",
"sendAction": "Enviar",
"receiveAction": "Recibir",
"deposit": "Depositar",
"withdraw": "Retirar",
"lightning": "Lightning",
@@ -86,6 +86,21 @@
"receiveCashu": "Recibir Cashu",
"pasteTheCashuToken": "Pega el token Cashu:",
"pasteFromClipboard": "Pegar del portapapeles",
"emptyClipboard": "Portapapeles vacío",
"cancelSend": "Cancelar envío",
"cancelSendConfirmTitle": "¿Cancelar envío?",
"cancelSendConfirmBody": "Solo funciona si el destinatario aún no reclamó el token. Si ya lo hizo, el envío se marcará como completado.",
"cancelSendSuccess": "Envío cancelado. Recuperaste {amount} {unit}",
"@cancelSendSuccess": {
"placeholders": {
"amount": {"type": "String"},
"unit": {"type": "String"}
}
},
"cancelSendAlreadyClaimed": "El destinatario ya reclamó el token",
"pendingOfflineSend": "Envío offline pendiente",
"pendingOfflineSendsHeader": "Envíos offline no reclamados",
"pendingSendDetailTitle": "Envío pendiente",
"validToken": "Token válido",
"invalidToken": "Token inválido o malformado",
"amount": "Monto:",
@@ -118,6 +133,7 @@
"confirm": "Confirmar",
"cancel": "Cancelar",
"insufficientBalance": "Balance insuficiente",
"feeExceedsAmount": "La comisión supera el monto a enviar",
"tokenCreationError": "Error al crear token: {error}",
"@tokenCreationError": {
"placeholders": {
@@ -235,6 +251,8 @@
"pendingStatus": "Pendiente",
"receivedStatus": "Recibido",
"sentStatus": "Enviado",
"receiving": "Recibiendo",
"sending": "Enviando",
"now": "Ahora",
"agoMinutes": "Hace {minutes} min",
"@agoMinutes": {
@@ -572,5 +590,43 @@
"requestPaymentReceived": "Pago recibido",
"requestDescriptionHint": "Descripción (opcional)",
"universal": "Universal",
"copiedToClipboard": "Copiado al portapapeles"
"copiedToClipboard": "Copiado al portapapeles",
"swap": "Cambiar",
"swapDescription": "Convertir entre sats y USD",
"swapFrom": "De",
"swapTo": "A",
"swapAction": "Cambiar",
"swapEstimatedFee": "Fee estimado",
"swapUseAll": "Usar todo",
"swapMinimum": "Mínimo: {amount}",
"@swapMinimum": {
"placeholders": {
"amount": { "type": "String" }
}
},
"swapProcessing": "Procesando swap...",
"swapSuccess": "Swap completado",
"swapErrorInsufficient": "Saldo insuficiente",
"swapErrorExpired": "La cotización ha expirado",
"swapErrorGeneric": "Error en el swap: {error}",
"@swapErrorGeneric": {
"placeholders": {
"error": { "type": "String" }
}
},
"swapChartUnavailable": "Precio no disponible · Toca para reintentar",
"swapChartMinMax": "24h Mín: {minPrice} — Máx: {maxPrice}",
"@swapChartMinMax": {
"placeholders": {
"minPrice": { "type": "String" },
"maxPrice": { "type": "String" }
}
},
"privacyPolicy": "Política de privacidad",
"privacyTitle": "NO RECOPILAMOS NADA",
"privacyGoodbye": "ADIÓS",
"privacyKeepReading": "(sigue leyendo si quieres…)",
"privacyBody": "No sabemos quién eres\nNo sabemos cuánto tienes\nNo sabemos qué haces",
"privacyConclusion": "La mejor forma de proteger tus datos\nes no tenerlos"
}
+43 -3
View File
@@ -63,8 +63,8 @@
"homeTitle": "Accueil",
"receive": "Recevoir",
"send": "Envoyer",
"sendAction": "Envoyer",
"receiveAction": "Recevoir",
"sendAction": "Envoyer",
"receiveAction": "Recevoir",
"deposit": "Déposer",
"withdraw": "Retirer",
"lightning": "Lightning",
@@ -86,6 +86,21 @@
"receiveCashu": "Recevoir Cashu",
"pasteTheCashuToken": "Collez le token Cashu :",
"pasteFromClipboard": "Coller depuis le presse-papiers",
"emptyClipboard": "Presse-papiers vide",
"cancelSend": "Annuler l'envoi",
"cancelSendConfirmTitle": "Annuler cet envoi ?",
"cancelSendConfirmBody": "Ne fonctionne que si le destinataire n'a pas encore réclamé le token. S'il l'a déjà fait, l'envoi sera marqué comme terminé.",
"cancelSendSuccess": "Envoi annulé. Récupéré {amount} {unit}",
"@cancelSendSuccess": {
"placeholders": {
"amount": {"type": "String"},
"unit": {"type": "String"}
}
},
"cancelSendAlreadyClaimed": "Le destinataire a déjà réclamé le token",
"pendingOfflineSend": "Envoi hors ligne en attente",
"pendingOfflineSendsHeader": "Envois hors ligne non réclamés",
"pendingSendDetailTitle": "Envoi en attente",
"validToken": "Token valide",
"invalidToken": "Token invalide ou malformé",
"amount": "Montant :",
@@ -118,6 +133,7 @@
"confirm": "Confirmer",
"cancel": "Annuler",
"insufficientBalance": "Solde insuffisant",
"feeExceedsAmount": "Les frais dépassent le montant à envoyer",
"tokenCreationError": "Erreur de création du token : {error}",
"@tokenCreationError": {
"placeholders": {
@@ -227,6 +243,8 @@
"pendingStatus": "En attente",
"receivedStatus": "Reçu",
"sentStatus": "Envoyé",
"receiving": "En réception",
"sending": "En envoi",
"now": "Maintenant",
"agoMinutes": "Il y a {minutes} min",
"@agoMinutes": {
@@ -559,5 +577,27 @@
"requestPaymentReceived": "Paiement reçu",
"requestDescriptionHint": "Description (facultatif)",
"universal": "Universel",
"copiedToClipboard": "Copié dans le presse-papiers"
"copiedToClipboard": "Copié dans le presse-papiers",
"swap": "Échanger",
"swapDescription": "Convertir entre sats et USD",
"swapFrom": "De",
"swapTo": "Vers",
"swapAction": "Échanger",
"swapEstimatedFee": "Frais estimés",
"swapUseAll": "Tout utiliser",
"swapMinimum": "Minimum : {amount}",
"swapProcessing": "Swap en cours...",
"swapSuccess": "Swap terminé",
"swapErrorInsufficient": "Solde insuffisant",
"swapErrorExpired": "Le devis a expiré",
"swapErrorGeneric": "Erreur de swap : {error}",
"swapChartUnavailable": "Prix indisponible · Appuyez pour réessayer",
"swapChartMinMax": "24h Min : {minPrice} — Max : {maxPrice}",
"privacyPolicy": "Politique de confidentialité",
"privacyTitle": "NOUS NE COLLECTONS RIEN",
"privacyGoodbye": "AU REVOIR",
"privacyKeepReading": "(continue à lire si tu veux…)",
"privacyBody": "Nous ne savons pas qui tu es\nNous ne savons pas combien tu as\nNous ne savons pas ce que tu fais",
"privacyConclusion": "La meilleure façon de protéger tes données,\nc'est de ne pas les avoir"
}
+43 -3
View File
@@ -63,8 +63,8 @@
"homeTitle": "Home",
"receive": "Ricevi",
"send": "Invia",
"sendAction": "Invia",
"receiveAction": "Ricevi",
"sendAction": "Invia",
"receiveAction": "Ricevi",
"deposit": "Deposita",
"withdraw": "Preleva",
"lightning": "Lightning",
@@ -86,6 +86,21 @@
"receiveCashu": "Ricevi Cashu",
"pasteTheCashuToken": "Incolla il token Cashu:",
"pasteFromClipboard": "Incolla dagli appunti",
"emptyClipboard": "Appunti vuoti",
"cancelSend": "Annulla invio",
"cancelSendConfirmTitle": "Annullare questo invio?",
"cancelSendConfirmBody": "Funziona solo se il destinatario non ha ancora riscattato il token. Se l'ha già fatto, l'invio sarà contrassegnato come completato.",
"cancelSendSuccess": "Invio annullato. Recuperati {amount} {unit}",
"@cancelSendSuccess": {
"placeholders": {
"amount": {"type": "String"},
"unit": {"type": "String"}
}
},
"cancelSendAlreadyClaimed": "Il destinatario ha già riscattato il token",
"pendingOfflineSend": "Invio offline in sospeso",
"pendingOfflineSendsHeader": "Invii offline non riscattati",
"pendingSendDetailTitle": "Invio in sospeso",
"validToken": "Token valido",
"invalidToken": "Token non valido o malformato",
"amount": "Importo:",
@@ -118,6 +133,7 @@
"confirm": "Conferma",
"cancel": "Annulla",
"insufficientBalance": "Saldo insufficiente",
"feeExceedsAmount": "La commissione supera l'importo da inviare",
"tokenCreationError": "Errore creazione token: {error}",
"@tokenCreationError": {
"placeholders": {
@@ -227,6 +243,8 @@
"pendingStatus": "In attesa",
"receivedStatus": "Ricevuto",
"sentStatus": "Inviato",
"receiving": "In ricezione",
"sending": "In invio",
"now": "Adesso",
"agoMinutes": "{minutes} min fa",
"@agoMinutes": {
@@ -559,5 +577,27 @@
"requestPaymentReceived": "Pagamento ricevuto",
"requestDescriptionHint": "Descrizione (opzionale)",
"universal": "Universale",
"copiedToClipboard": "Copiato negli appunti"
"copiedToClipboard": "Copiato negli appunti",
"swap": "Scambia",
"swapDescription": "Converti tra sats e USD",
"swapFrom": "Da",
"swapTo": "A",
"swapAction": "Scambia",
"swapEstimatedFee": "Commissione stimata",
"swapUseAll": "Usa tutto",
"swapMinimum": "Minimo: {amount}",
"swapProcessing": "Swap in corso...",
"swapSuccess": "Swap completato",
"swapErrorInsufficient": "Saldo insufficiente",
"swapErrorExpired": "Il preventivo è scaduto",
"swapErrorGeneric": "Errore swap: {error}",
"swapChartUnavailable": "Prezzo non disponibile · Tocca per riprovare",
"swapChartMinMax": "24h Min: {minPrice} — Max: {maxPrice}",
"privacyPolicy": "Informativa sulla privacy",
"privacyTitle": "NON RACCOGLIAMO NULLA",
"privacyGoodbye": "CIAO",
"privacyKeepReading": "(continua a leggere se vuoi…)",
"privacyBody": "Non sappiamo chi sei\nNon sappiamo quanto hai\nNon sappiamo cosa fai",
"privacyConclusion": "Il modo migliore per proteggere i tuoi dati\nè non averli"
}
+43 -3
View File
@@ -63,8 +63,8 @@
"homeTitle": "ホーム",
"receive": "受取",
"send": "送金",
"sendAction": "送金",
"receiveAction": "受取",
"sendAction": "送金",
"receiveAction": "受取",
"deposit": "入金",
"withdraw": "出金",
"lightning": "Lightning",
@@ -86,6 +86,21 @@
"receiveCashu": "Cashuを受け取る",
"pasteTheCashuToken": "Cashuトークンを貼り付け:",
"pasteFromClipboard": "クリップボードから貼り付け",
"emptyClipboard": "クリップボードは空です",
"cancelSend": "送金をキャンセル",
"cancelSendConfirmTitle": "この送金をキャンセルしますか?",
"cancelSendConfirmBody": "受取人がまだトークンを引き換えていない場合のみ機能します。既に引き換え済みの場合、送金は完了として記録されます。",
"cancelSendSuccess": "送金をキャンセルしました。{amount} {unit}を回復しました",
"@cancelSendSuccess": {
"placeholders": {
"amount": {"type": "String"},
"unit": {"type": "String"}
}
},
"cancelSendAlreadyClaimed": "受取人は既にトークンを引き換えました",
"pendingOfflineSend": "保留中のオフライン送金",
"pendingOfflineSendsHeader": "未請求のオフライン送金",
"pendingSendDetailTitle": "保留中の送金",
"validToken": "有効なトークン",
"invalidToken": "無効または不正なトークン",
"amount": "金額:",
@@ -118,6 +133,7 @@
"confirm": "確認",
"cancel": "キャンセル",
"insufficientBalance": "残高不足",
"feeExceedsAmount": "手数料が送金額を超えています",
"tokenCreationError": "トークン作成エラー:{error}",
"@tokenCreationError": {
"placeholders": {
@@ -227,6 +243,8 @@
"pendingStatus": "保留中",
"receivedStatus": "受取済み",
"sentStatus": "送金済み",
"receiving": "受取中",
"sending": "送金中",
"now": "たった今",
"agoMinutes": "{minutes}分前",
"@agoMinutes": {
@@ -559,5 +577,27 @@
"requestPaymentReceived": "支払いを受け取りました",
"requestDescriptionHint": "説明(任意)",
"universal": "ユニバーサル",
"copiedToClipboard": "クリップボードにコピーしました"
"copiedToClipboard": "クリップボードにコピーしました",
"swap": "交換",
"swapDescription": "SatsとUSDを変換",
"swapFrom": "送信元",
"swapTo": "送信先",
"swapAction": "交換",
"swapEstimatedFee": "推定手数料",
"swapUseAll": "全額使用",
"swapMinimum": "最小: {amount}",
"swapProcessing": "スワップ処理中...",
"swapSuccess": "スワップ完了",
"swapErrorInsufficient": "残高不足",
"swapErrorExpired": "見積もりの有効期限切れ",
"swapErrorGeneric": "スワップエラー: {error}",
"swapChartUnavailable": "価格を取得できません・タップで再試行",
"swapChartMinMax": "24h 安値: {minPrice} — 高値: {maxPrice}",
"privacyPolicy": "プライバシーポリシー",
"privacyTitle": "何も収集しません",
"privacyGoodbye": "さようなら",
"privacyKeepReading": "(読み続けたければどうぞ…)",
"privacyBody": "あなたが誰か知りません\nいくら持っているか知りません\n何をしているか知りません",
"privacyConclusion": "データを守る最善の方法は\nデータを持たないことです"
}
+43 -3
View File
@@ -63,8 +63,8 @@
"homeTitle": "홈",
"receive": "받기",
"send": "보내기",
"sendAction": "보내기",
"receiveAction": "받기",
"sendAction": "보내기",
"receiveAction": "받기",
"deposit": "입금",
"withdraw": "출금",
"lightning": "Lightning",
@@ -86,6 +86,21 @@
"receiveCashu": "Cashu 받기",
"pasteTheCashuToken": "Cashu 토큰을 붙여넣으세요:",
"pasteFromClipboard": "클립보드에서 붙여넣기",
"emptyClipboard": "클립보드가 비어 있음",
"cancelSend": "전송 취소",
"cancelSendConfirmTitle": "이 전송을 취소하시겠습니까?",
"cancelSendConfirmBody": "수신자가 아직 토큰을 받지 않은 경우에만 작동합니다. 이미 받았다면 전송이 완료로 표시됩니다.",
"cancelSendSuccess": "전송이 취소되었습니다. {amount} {unit}를 복구했습니다",
"@cancelSendSuccess": {
"placeholders": {
"amount": {"type": "String"},
"unit": {"type": "String"}
}
},
"cancelSendAlreadyClaimed": "수신자가 이미 토큰을 받았습니다",
"pendingOfflineSend": "대기 중인 오프라인 전송",
"pendingOfflineSendsHeader": "청구되지 않은 오프라인 전송",
"pendingSendDetailTitle": "대기 중인 전송",
"validToken": "유효한 토큰",
"invalidToken": "유효하지 않거나 손상된 토큰",
"amount": "금액:",
@@ -118,6 +133,7 @@
"confirm": "확인",
"cancel": "취소",
"insufficientBalance": "잔액 부족",
"feeExceedsAmount": "수수료가 송금액을 초과합니다",
"tokenCreationError": "토큰 생성 오류: {error}",
"@tokenCreationError": {
"placeholders": {
@@ -227,6 +243,8 @@
"pendingStatus": "대기 중",
"receivedStatus": "받음",
"sentStatus": "보냄",
"receiving": "받는 중",
"sending": "보내는 중",
"now": "방금",
"agoMinutes": "{minutes}분 전",
"@agoMinutes": {
@@ -559,5 +577,27 @@
"requestPaymentReceived": "결제가 수신되었습니다",
"requestDescriptionHint": "설명 (선택사항)",
"universal": "유니버설",
"copiedToClipboard": "클립보드에 복사되었습니다"
"copiedToClipboard": "클립보드에 복사되었습니다",
"swap": "교환",
"swapDescription": "Sats와 USD 간 변환",
"swapFrom": "에서",
"swapTo": "으로",
"swapAction": "교환",
"swapEstimatedFee": "예상 수수료",
"swapUseAll": "전액 사용",
"swapMinimum": "최소: {amount}",
"swapProcessing": "교환 처리 중...",
"swapSuccess": "교환 완료",
"swapErrorInsufficient": "잔액 부족",
"swapErrorExpired": "견적이 만료되었습니다",
"swapErrorGeneric": "교환 오류: {error}",
"swapChartUnavailable": "가격 불러오기 실패 · 탭하여 재시도",
"swapChartMinMax": "24h 최저: {minPrice} — 최고: {maxPrice}",
"privacyPolicy": "개인정보 처리방침",
"privacyTitle": "우리는 아무것도 수집하지 않습니다",
"privacyGoodbye": "안녕",
"privacyKeepReading": "(계속 읽고 싶으면…)",
"privacyBody": "당신이 누구인지 모릅니다\n얼마를 가지고 있는지 모릅니다\n무엇을 하는지 모릅니다",
"privacyConclusion": "데이터를 보호하는 가장 좋은 방법은\n데이터를 갖지 않는 것입니다"
}
+200 -2
View File
@@ -388,13 +388,13 @@ abstract class L10n {
/// No description provided for @sendAction.
///
/// In es, this message translates to:
/// **'Enviar'**
/// **'Enviar'**
String get sendAction;
/// No description provided for @receiveAction.
///
/// In es, this message translates to:
/// **'Recibir'**
/// **'Recibir'**
String get receiveAction;
/// No description provided for @deposit.
@@ -505,6 +505,60 @@ abstract class L10n {
/// **'Pegar del portapapeles'**
String get pasteFromClipboard;
/// No description provided for @emptyClipboard.
///
/// In es, this message translates to:
/// **'Portapapeles vacío'**
String get emptyClipboard;
/// No description provided for @cancelSend.
///
/// In es, this message translates to:
/// **'Cancelar envío'**
String get cancelSend;
/// No description provided for @cancelSendConfirmTitle.
///
/// In es, this message translates to:
/// **'¿Cancelar envío?'**
String get cancelSendConfirmTitle;
/// No description provided for @cancelSendConfirmBody.
///
/// In es, this message translates to:
/// **'Solo funciona si el destinatario aún no reclamó el token. Si ya lo hizo, el envío se marcará como completado.'**
String get cancelSendConfirmBody;
/// No description provided for @cancelSendSuccess.
///
/// In es, this message translates to:
/// **'Envío cancelado. Recuperaste {amount} {unit}'**
String cancelSendSuccess(String amount, String unit);
/// No description provided for @cancelSendAlreadyClaimed.
///
/// In es, this message translates to:
/// **'El destinatario ya reclamó el token'**
String get cancelSendAlreadyClaimed;
/// No description provided for @pendingOfflineSend.
///
/// In es, this message translates to:
/// **'Envío offline pendiente'**
String get pendingOfflineSend;
/// No description provided for @pendingOfflineSendsHeader.
///
/// In es, this message translates to:
/// **'Envíos offline no reclamados'**
String get pendingOfflineSendsHeader;
/// No description provided for @pendingSendDetailTitle.
///
/// In es, this message translates to:
/// **'Envío pendiente'**
String get pendingSendDetailTitle;
/// No description provided for @validToken.
///
/// In es, this message translates to:
@@ -661,6 +715,12 @@ abstract class L10n {
/// **'Balance insuficiente'**
String get insufficientBalance;
/// No description provided for @feeExceedsAmount.
///
/// In es, this message translates to:
/// **'La comisión supera el monto a enviar'**
String get feeExceedsAmount;
/// No description provided for @tokenCreationError.
///
/// In es, this message translates to:
@@ -1075,6 +1135,18 @@ abstract class L10n {
/// **'Enviado'**
String get sentStatus;
/// No description provided for @receiving.
///
/// In es, this message translates to:
/// **'Recibiendo'**
String get receiving;
/// No description provided for @sending.
///
/// In es, this message translates to:
/// **'Enviando'**
String get sending;
/// No description provided for @now.
///
/// In es, this message translates to:
@@ -2352,6 +2424,132 @@ abstract class L10n {
/// In es, this message translates to:
/// **'Copiado al portapapeles'**
String get copiedToClipboard;
/// No description provided for @swap.
///
/// In es, this message translates to:
/// **'Cambiar'**
String get swap;
/// No description provided for @swapDescription.
///
/// In es, this message translates to:
/// **'Convertir entre sats y USD'**
String get swapDescription;
/// No description provided for @swapFrom.
///
/// In es, this message translates to:
/// **'De'**
String get swapFrom;
/// No description provided for @swapTo.
///
/// In es, this message translates to:
/// **'A'**
String get swapTo;
/// No description provided for @swapAction.
///
/// In es, this message translates to:
/// **'Cambiar'**
String get swapAction;
/// No description provided for @swapEstimatedFee.
///
/// In es, this message translates to:
/// **'Fee estimado'**
String get swapEstimatedFee;
/// No description provided for @swapUseAll.
///
/// In es, this message translates to:
/// **'Usar todo'**
String get swapUseAll;
/// No description provided for @swapMinimum.
///
/// In es, this message translates to:
/// **'Mínimo: {amount}'**
String swapMinimum(String amount);
/// No description provided for @swapProcessing.
///
/// In es, this message translates to:
/// **'Procesando swap...'**
String get swapProcessing;
/// No description provided for @swapSuccess.
///
/// In es, this message translates to:
/// **'Swap completado'**
String get swapSuccess;
/// No description provided for @swapErrorInsufficient.
///
/// In es, this message translates to:
/// **'Saldo insuficiente'**
String get swapErrorInsufficient;
/// No description provided for @swapErrorExpired.
///
/// In es, this message translates to:
/// **'La cotización ha expirado'**
String get swapErrorExpired;
/// No description provided for @swapErrorGeneric.
///
/// In es, this message translates to:
/// **'Error en el swap: {error}'**
String swapErrorGeneric(String error);
/// No description provided for @swapChartUnavailable.
///
/// In es, this message translates to:
/// **'Precio no disponible · Toca para reintentar'**
String get swapChartUnavailable;
/// No description provided for @swapChartMinMax.
///
/// In es, this message translates to:
/// **'24h Mín: {minPrice} — Máx: {maxPrice}'**
String swapChartMinMax(String minPrice, String maxPrice);
/// No description provided for @privacyPolicy.
///
/// In es, this message translates to:
/// **'Política de privacidad'**
String get privacyPolicy;
/// No description provided for @privacyTitle.
///
/// In es, this message translates to:
/// **'NO RECOPILAMOS NADA'**
String get privacyTitle;
/// No description provided for @privacyGoodbye.
///
/// In es, this message translates to:
/// **'ADIÓS'**
String get privacyGoodbye;
/// No description provided for @privacyKeepReading.
///
/// In es, this message translates to:
/// **'(sigue leyendo si quieres…)'**
String get privacyKeepReading;
/// No description provided for @privacyBody.
///
/// In es, this message translates to:
/// **'No sabemos quién eres\nNo sabemos cuánto tienes\nNo sabemos qué haces'**
String get privacyBody;
/// No description provided for @privacyConclusion.
///
/// In es, this message translates to:
/// **'La mejor forma de proteger tus datos\nes no tenerlos'**
String get privacyConclusion;
}
class _L10nDelegate extends LocalizationsDelegate<L10n> {
+115 -2
View File
@@ -156,10 +156,10 @@ class L10nDe extends L10n {
String get send => 'Senden';
@override
String get sendAction => 'Senden ↗';
String get sendAction => 'Senden';
@override
String get receiveAction => '↘ Empfangen';
String get receiveAction => 'Empfangen';
@override
String get deposit => 'Einzahlen';
@@ -215,6 +215,38 @@ class L10nDe extends L10n {
@override
String get pasteFromClipboard => 'Aus Zwischenablage einfügen';
@override
String get emptyClipboard => 'Zwischenablage leer';
@override
String get cancelSend => 'Senden abbrechen';
@override
String get cancelSendConfirmTitle => 'Diesen Versand abbrechen?';
@override
String get cancelSendConfirmBody =>
'Funktioniert nur, wenn der Empfänger das Token noch nicht eingelöst hat. Falls doch, wird der Versand als abgeschlossen markiert.';
@override
String cancelSendSuccess(String amount, String unit) {
return 'Versand abgebrochen. $amount $unit zurückerhalten';
}
@override
String get cancelSendAlreadyClaimed =>
'Der Empfänger hat das Token bereits eingelöst';
@override
String get pendingOfflineSend => 'Ausstehender Offline-Versand';
@override
String get pendingOfflineSendsHeader =>
'Nicht eingelöste Offline-Versendungen';
@override
String get pendingSendDetailTitle => 'Ausstehender Versand';
@override
String get validToken => 'Token gültig';
@@ -295,6 +327,9 @@ class L10nDe extends L10n {
@override
String get insufficientBalance => 'Unzureichendes Guthaben';
@override
String get feeExceedsAmount => 'Die Gebühr übersteigt den Sendebetrag';
@override
String tokenCreationError(String error) {
return 'Fehler beim Erstellen des Tokens: $error';
@@ -523,6 +558,12 @@ class L10nDe extends L10n {
@override
String get sentStatus => 'Gesendet';
@override
String get receiving => 'Wird empfangen';
@override
String get sending => 'Wird gesendet';
@override
String get now => 'Jetzt';
@@ -1241,4 +1282,76 @@ class L10nDe extends L10n {
@override
String get copiedToClipboard => 'In die Zwischenablage kopiert';
@override
String get swap => 'Tauschen';
@override
String get swapDescription => 'Zwischen Sats und USD wechseln';
@override
String get swapFrom => 'Von';
@override
String get swapTo => 'Nach';
@override
String get swapAction => 'Tauschen';
@override
String get swapEstimatedFee => 'Geschätzte Gebühr';
@override
String get swapUseAll => 'Alles verwenden';
@override
String swapMinimum(String amount) {
return 'Minimum: $amount';
}
@override
String get swapProcessing => 'Swap wird verarbeitet...';
@override
String get swapSuccess => 'Swap abgeschlossen';
@override
String get swapErrorInsufficient => 'Unzureichendes Guthaben';
@override
String get swapErrorExpired => 'Angebot abgelaufen';
@override
String swapErrorGeneric(String error) {
return 'Swap-Fehler: $error';
}
@override
String get swapChartUnavailable =>
'Preis nicht verfügbar · Tippen zum Wiederholen';
@override
String swapChartMinMax(String minPrice, String maxPrice) {
return '24h Min: $minPrice — Max: $maxPrice';
}
@override
String get privacyPolicy => 'Datenschutz';
@override
String get privacyTitle => 'WIR SAMMELN NICHTS';
@override
String get privacyGoodbye => 'TSCHÜSS';
@override
String get privacyKeepReading => '(lies weiter, wenn du willst…)';
@override
String get privacyBody =>
'Wir wissen nicht, wer du bist\nWir wissen nicht, wie viel du hast\nWir wissen nicht, was du tust';
@override
String get privacyConclusion =>
'Der beste Weg, deine Daten zu schützen,\nist sie nicht zu haben';
}
+113 -2
View File
@@ -153,10 +153,10 @@ class L10nEn extends L10n {
String get send => 'Send';
@override
String get sendAction => 'Send ↗';
String get sendAction => 'Send';
@override
String get receiveAction => '↘ Receive';
String get receiveAction => 'Receive';
@override
String get deposit => 'Deposit';
@@ -212,6 +212,37 @@ class L10nEn extends L10n {
@override
String get pasteFromClipboard => 'Paste from clipboard';
@override
String get emptyClipboard => 'Clipboard is empty';
@override
String get cancelSend => 'Cancel send';
@override
String get cancelSendConfirmTitle => 'Cancel this send?';
@override
String get cancelSendConfirmBody =>
'Only works if the recipient has not claimed the token yet. If they already did, the send will be marked as completed.';
@override
String cancelSendSuccess(String amount, String unit) {
return 'Send canceled. Recovered $amount $unit';
}
@override
String get cancelSendAlreadyClaimed =>
'The recipient already claimed the token';
@override
String get pendingOfflineSend => 'Pending offline send';
@override
String get pendingOfflineSendsHeader => 'Unclaimed offline sends';
@override
String get pendingSendDetailTitle => 'Pending send';
@override
String get validToken => 'Valid token';
@@ -292,6 +323,9 @@ class L10nEn extends L10n {
@override
String get insufficientBalance => 'Insufficient balance';
@override
String get feeExceedsAmount => 'Fee exceeds the amount to send';
@override
String tokenCreationError(String error) {
return 'Error creating token: $error';
@@ -517,6 +551,12 @@ class L10nEn extends L10n {
@override
String get sentStatus => 'Sent';
@override
String get receiving => 'Receiving';
@override
String get sending => 'Sending';
@override
String get now => 'Now';
@@ -1222,4 +1262,75 @@ class L10nEn extends L10n {
@override
String get copiedToClipboard => 'Copied to clipboard';
@override
String get swap => 'Swap';
@override
String get swapDescription => 'Convert between sats and USD';
@override
String get swapFrom => 'From';
@override
String get swapTo => 'To';
@override
String get swapAction => 'Swap';
@override
String get swapEstimatedFee => 'Estimated fee';
@override
String get swapUseAll => 'Use all';
@override
String swapMinimum(String amount) {
return 'Minimum: $amount';
}
@override
String get swapProcessing => 'Processing swap...';
@override
String get swapSuccess => 'Swap completed';
@override
String get swapErrorInsufficient => 'Insufficient balance';
@override
String get swapErrorExpired => 'Quote has expired';
@override
String swapErrorGeneric(String error) {
return 'Swap error: $error';
}
@override
String get swapChartUnavailable => 'Price unavailable · Tap to retry';
@override
String swapChartMinMax(String minPrice, String maxPrice) {
return '24h Min: $minPrice — Max: $maxPrice';
}
@override
String get privacyPolicy => 'Privacy policy';
@override
String get privacyTitle => 'WE COLLECT NOTHING';
@override
String get privacyGoodbye => 'BYE';
@override
String get privacyKeepReading => '(keep reading if you want…)';
@override
String get privacyBody =>
'We don\'t know who you are\nWe don\'t know how much you have\nWe don\'t know what you do';
@override
String get privacyConclusion =>
'The best way to protect your data\nis not to have it';
}
+113 -2
View File
@@ -153,10 +153,10 @@ class L10nEs extends L10n {
String get send => 'Enviar';
@override
String get sendAction => 'Enviar ↗';
String get sendAction => 'Enviar';
@override
String get receiveAction => '↘ Recibir';
String get receiveAction => 'Recibir';
@override
String get deposit => 'Depositar';
@@ -212,6 +212,36 @@ class L10nEs extends L10n {
@override
String get pasteFromClipboard => 'Pegar del portapapeles';
@override
String get emptyClipboard => 'Portapapeles vacío';
@override
String get cancelSend => 'Cancelar envío';
@override
String get cancelSendConfirmTitle => '¿Cancelar envío?';
@override
String get cancelSendConfirmBody =>
'Solo funciona si el destinatario aún no reclamó el token. Si ya lo hizo, el envío se marcará como completado.';
@override
String cancelSendSuccess(String amount, String unit) {
return 'Envío cancelado. Recuperaste $amount $unit';
}
@override
String get cancelSendAlreadyClaimed => 'El destinatario ya reclamó el token';
@override
String get pendingOfflineSend => 'Envío offline pendiente';
@override
String get pendingOfflineSendsHeader => 'Envíos offline no reclamados';
@override
String get pendingSendDetailTitle => 'Envío pendiente';
@override
String get validToken => 'Token válido';
@@ -292,6 +322,9 @@ class L10nEs extends L10n {
@override
String get insufficientBalance => 'Balance insuficiente';
@override
String get feeExceedsAmount => 'La comisión supera el monto a enviar';
@override
String tokenCreationError(String error) {
return 'Error al crear token: $error';
@@ -518,6 +551,12 @@ class L10nEs extends L10n {
@override
String get sentStatus => 'Enviado';
@override
String get receiving => 'Recibiendo';
@override
String get sending => 'Enviando';
@override
String get now => 'Ahora';
@@ -1230,4 +1269,76 @@ class L10nEs extends L10n {
@override
String get copiedToClipboard => 'Copiado al portapapeles';
@override
String get swap => 'Cambiar';
@override
String get swapDescription => 'Convertir entre sats y USD';
@override
String get swapFrom => 'De';
@override
String get swapTo => 'A';
@override
String get swapAction => 'Cambiar';
@override
String get swapEstimatedFee => 'Fee estimado';
@override
String get swapUseAll => 'Usar todo';
@override
String swapMinimum(String amount) {
return 'Mínimo: $amount';
}
@override
String get swapProcessing => 'Procesando swap...';
@override
String get swapSuccess => 'Swap completado';
@override
String get swapErrorInsufficient => 'Saldo insuficiente';
@override
String get swapErrorExpired => 'La cotización ha expirado';
@override
String swapErrorGeneric(String error) {
return 'Error en el swap: $error';
}
@override
String get swapChartUnavailable =>
'Precio no disponible · Toca para reintentar';
@override
String swapChartMinMax(String minPrice, String maxPrice) {
return '24h Mín: $minPrice — Máx: $maxPrice';
}
@override
String get privacyPolicy => 'Política de privacidad';
@override
String get privacyTitle => 'NO RECOPILAMOS NADA';
@override
String get privacyGoodbye => 'ADIÓS';
@override
String get privacyKeepReading => '(sigue leyendo si quieres…)';
@override
String get privacyBody =>
'No sabemos quién eres\nNo sabemos cuánto tienes\nNo sabemos qué haces';
@override
String get privacyConclusion =>
'La mejor forma de proteger tus datos\nes no tenerlos';
}
+114 -2
View File
@@ -157,10 +157,10 @@ class L10nFr extends L10n {
String get send => 'Envoyer';
@override
String get sendAction => 'Envoyer ↗';
String get sendAction => 'Envoyer';
@override
String get receiveAction => '↘ Recevoir';
String get receiveAction => 'Recevoir';
@override
String get deposit => 'Déposer';
@@ -216,6 +216,37 @@ class L10nFr extends L10n {
@override
String get pasteFromClipboard => 'Coller depuis le presse-papiers';
@override
String get emptyClipboard => 'Presse-papiers vide';
@override
String get cancelSend => 'Annuler l\'envoi';
@override
String get cancelSendConfirmTitle => 'Annuler cet envoi ?';
@override
String get cancelSendConfirmBody =>
'Ne fonctionne que si le destinataire n\'a pas encore réclamé le token. S\'il l\'a déjà fait, l\'envoi sera marqué comme terminé.';
@override
String cancelSendSuccess(String amount, String unit) {
return 'Envoi annulé. Récupéré $amount $unit';
}
@override
String get cancelSendAlreadyClaimed =>
'Le destinataire a déjà réclamé le token';
@override
String get pendingOfflineSend => 'Envoi hors ligne en attente';
@override
String get pendingOfflineSendsHeader => 'Envois hors ligne non réclamés';
@override
String get pendingSendDetailTitle => 'Envoi en attente';
@override
String get validToken => 'Token valide';
@@ -296,6 +327,9 @@ class L10nFr extends L10n {
@override
String get insufficientBalance => 'Solde insuffisant';
@override
String get feeExceedsAmount => 'Les frais dépassent le montant à envoyer';
@override
String tokenCreationError(String error) {
return 'Erreur de création du token : $error';
@@ -524,6 +558,12 @@ class L10nFr extends L10n {
@override
String get sentStatus => 'Envoyé';
@override
String get receiving => 'En réception';
@override
String get sending => 'En envoi';
@override
String get now => 'Maintenant';
@@ -1246,4 +1286,76 @@ class L10nFr extends L10n {
@override
String get copiedToClipboard => 'Copié dans le presse-papiers';
@override
String get swap => 'Échanger';
@override
String get swapDescription => 'Convertir entre sats et USD';
@override
String get swapFrom => 'De';
@override
String get swapTo => 'Vers';
@override
String get swapAction => 'Échanger';
@override
String get swapEstimatedFee => 'Frais estimés';
@override
String get swapUseAll => 'Tout utiliser';
@override
String swapMinimum(String amount) {
return 'Minimum : $amount';
}
@override
String get swapProcessing => 'Swap en cours...';
@override
String get swapSuccess => 'Swap terminé';
@override
String get swapErrorInsufficient => 'Solde insuffisant';
@override
String get swapErrorExpired => 'Le devis a expiré';
@override
String swapErrorGeneric(String error) {
return 'Erreur de swap : $error';
}
@override
String get swapChartUnavailable =>
'Prix indisponible · Appuyez pour réessayer';
@override
String swapChartMinMax(String minPrice, String maxPrice) {
return '24h Min : $minPrice — Max : $maxPrice';
}
@override
String get privacyPolicy => 'Politique de confidentialité';
@override
String get privacyTitle => 'NOUS NE COLLECTONS RIEN';
@override
String get privacyGoodbye => 'AU REVOIR';
@override
String get privacyKeepReading => '(continue à lire si tu veux…)';
@override
String get privacyBody =>
'Nous ne savons pas qui tu es\nNous ne savons pas combien tu as\nNous ne savons pas ce que tu fais';
@override
String get privacyConclusion =>
'La meilleure façon de protéger tes données,\nc\'est de ne pas les avoir';
}
+114 -2
View File
@@ -154,10 +154,10 @@ class L10nIt extends L10n {
String get send => 'Invia';
@override
String get sendAction => 'Invia ↗';
String get sendAction => 'Invia';
@override
String get receiveAction => '↘ Ricevi';
String get receiveAction => 'Ricevi';
@override
String get deposit => 'Deposita';
@@ -213,6 +213,37 @@ class L10nIt extends L10n {
@override
String get pasteFromClipboard => 'Incolla dagli appunti';
@override
String get emptyClipboard => 'Appunti vuoti';
@override
String get cancelSend => 'Annulla invio';
@override
String get cancelSendConfirmTitle => 'Annullare questo invio?';
@override
String get cancelSendConfirmBody =>
'Funziona solo se il destinatario non ha ancora riscattato il token. Se l\'ha già fatto, l\'invio sarà contrassegnato come completato.';
@override
String cancelSendSuccess(String amount, String unit) {
return 'Invio annullato. Recuperati $amount $unit';
}
@override
String get cancelSendAlreadyClaimed =>
'Il destinatario ha già riscattato il token';
@override
String get pendingOfflineSend => 'Invio offline in sospeso';
@override
String get pendingOfflineSendsHeader => 'Invii offline non riscattati';
@override
String get pendingSendDetailTitle => 'Invio in sospeso';
@override
String get validToken => 'Token valido';
@@ -293,6 +324,9 @@ class L10nIt extends L10n {
@override
String get insufficientBalance => 'Saldo insufficiente';
@override
String get feeExceedsAmount => 'La commissione supera l\'importo da inviare';
@override
String tokenCreationError(String error) {
return 'Errore creazione token: $error';
@@ -519,6 +553,12 @@ class L10nIt extends L10n {
@override
String get sentStatus => 'Inviato';
@override
String get receiving => 'In ricezione';
@override
String get sending => 'In invio';
@override
String get now => 'Adesso';
@@ -1234,4 +1274,76 @@ class L10nIt extends L10n {
@override
String get copiedToClipboard => 'Copiato negli appunti';
@override
String get swap => 'Scambia';
@override
String get swapDescription => 'Converti tra sats e USD';
@override
String get swapFrom => 'Da';
@override
String get swapTo => 'A';
@override
String get swapAction => 'Scambia';
@override
String get swapEstimatedFee => 'Commissione stimata';
@override
String get swapUseAll => 'Usa tutto';
@override
String swapMinimum(String amount) {
return 'Minimo: $amount';
}
@override
String get swapProcessing => 'Swap in corso...';
@override
String get swapSuccess => 'Swap completato';
@override
String get swapErrorInsufficient => 'Saldo insufficiente';
@override
String get swapErrorExpired => 'Il preventivo è scaduto';
@override
String swapErrorGeneric(String error) {
return 'Errore swap: $error';
}
@override
String get swapChartUnavailable =>
'Prezzo non disponibile · Tocca per riprovare';
@override
String swapChartMinMax(String minPrice, String maxPrice) {
return '24h Min: $minPrice — Max: $maxPrice';
}
@override
String get privacyPolicy => 'Informativa sulla privacy';
@override
String get privacyTitle => 'NON RACCOGLIAMO NULLA';
@override
String get privacyGoodbye => 'CIAO';
@override
String get privacyKeepReading => '(continua a leggere se vuoi…)';
@override
String get privacyBody =>
'Non sappiamo chi sei\nNon sappiamo quanto hai\nNon sappiamo cosa fai';
@override
String get privacyConclusion =>
'Il modo migliore per proteggere i tuoi dati\nè non averli';
}
+110 -2
View File
@@ -149,10 +149,10 @@ class L10nJa extends L10n {
String get send => '送金';
@override
String get sendAction => '送金';
String get sendAction => '送金';
@override
String get receiveAction => '受取';
String get receiveAction => '受取';
@override
String get deposit => '入金';
@@ -208,6 +208,36 @@ class L10nJa extends L10n {
@override
String get pasteFromClipboard => 'クリップボードから貼り付け';
@override
String get emptyClipboard => 'クリップボードは空です';
@override
String get cancelSend => '送金をキャンセル';
@override
String get cancelSendConfirmTitle => 'この送金をキャンセルしますか?';
@override
String get cancelSendConfirmBody =>
'受取人がまだトークンを引き換えていない場合のみ機能します。既に引き換え済みの場合、送金は完了として記録されます。';
@override
String cancelSendSuccess(String amount, String unit) {
return '送金をキャンセルしました。$amount $unitを回復しました';
}
@override
String get cancelSendAlreadyClaimed => '受取人は既にトークンを引き換えました';
@override
String get pendingOfflineSend => '保留中のオフライン送金';
@override
String get pendingOfflineSendsHeader => '未請求のオフライン送金';
@override
String get pendingSendDetailTitle => '保留中の送金';
@override
String get validToken => '有効なトークン';
@@ -288,6 +318,9 @@ class L10nJa extends L10n {
@override
String get insufficientBalance => '残高不足';
@override
String get feeExceedsAmount => '手数料が送金額を超えています';
@override
String tokenCreationError(String error) {
return 'トークン作成エラー:$error';
@@ -511,6 +544,12 @@ class L10nJa extends L10n {
@override
String get sentStatus => '送金済み';
@override
String get receiving => '受取中';
@override
String get sending => '送金中';
@override
String get now => 'たった今';
@@ -1207,4 +1246,73 @@ class L10nJa extends L10n {
@override
String get copiedToClipboard => 'クリップボードにコピーしました';
@override
String get swap => '交換';
@override
String get swapDescription => 'SatsとUSDを変換';
@override
String get swapFrom => '送信元';
@override
String get swapTo => '送信先';
@override
String get swapAction => '交換';
@override
String get swapEstimatedFee => '推定手数料';
@override
String get swapUseAll => '全額使用';
@override
String swapMinimum(String amount) {
return '最小: $amount';
}
@override
String get swapProcessing => 'スワップ処理中...';
@override
String get swapSuccess => 'スワップ完了';
@override
String get swapErrorInsufficient => '残高不足';
@override
String get swapErrorExpired => '見積もりの有効期限切れ';
@override
String swapErrorGeneric(String error) {
return 'スワップエラー: $error';
}
@override
String get swapChartUnavailable => '価格を取得できません・タップで再試行';
@override
String swapChartMinMax(String minPrice, String maxPrice) {
return '24h 安値: $minPrice — 高値: $maxPrice';
}
@override
String get privacyPolicy => 'プライバシーポリシー';
@override
String get privacyTitle => '何も収集しません';
@override
String get privacyGoodbye => 'さようなら';
@override
String get privacyKeepReading => '(読み続けたければどうぞ…)';
@override
String get privacyBody => 'あなたが誰か知りません\nいくら持っているか知りません\n何をしているか知りません';
@override
String get privacyConclusion => 'データを守る最善の方法は\nデータを持たないことです';
}
+110 -2
View File
@@ -151,10 +151,10 @@ class L10nKo extends L10n {
String get send => '보내기';
@override
String get sendAction => '보내기';
String get sendAction => '보내기';
@override
String get receiveAction => '받기';
String get receiveAction => '받기';
@override
String get deposit => '입금';
@@ -210,6 +210,36 @@ class L10nKo extends L10n {
@override
String get pasteFromClipboard => '클립보드에서 붙여넣기';
@override
String get emptyClipboard => '클립보드가 비어 있음';
@override
String get cancelSend => '전송 취소';
@override
String get cancelSendConfirmTitle => '이 전송을 취소하시겠습니까?';
@override
String get cancelSendConfirmBody =>
'수신자가 아직 토큰을 받지 않은 경우에만 작동합니다. 이미 받았다면 전송이 완료로 표시됩니다.';
@override
String cancelSendSuccess(String amount, String unit) {
return '전송이 취소되었습니다. $amount $unit를 복구했습니다';
}
@override
String get cancelSendAlreadyClaimed => '수신자가 이미 토큰을 받았습니다';
@override
String get pendingOfflineSend => '대기 중인 오프라인 전송';
@override
String get pendingOfflineSendsHeader => '청구되지 않은 오프라인 전송';
@override
String get pendingSendDetailTitle => '대기 중인 전송';
@override
String get validToken => '유효한 토큰';
@@ -290,6 +320,9 @@ class L10nKo extends L10n {
@override
String get insufficientBalance => '잔액 부족';
@override
String get feeExceedsAmount => '수수료가 송금액을 초과합니다';
@override
String tokenCreationError(String error) {
return '토큰 생성 오류: $error';
@@ -513,6 +546,12 @@ class L10nKo extends L10n {
@override
String get sentStatus => '보냄';
@override
String get receiving => '받는 중';
@override
String get sending => '보내는 중';
@override
String get now => '방금';
@@ -1209,4 +1248,73 @@ class L10nKo extends L10n {
@override
String get copiedToClipboard => '클립보드에 복사되었습니다';
@override
String get swap => '교환';
@override
String get swapDescription => 'Sats와 USD 간 변환';
@override
String get swapFrom => '에서';
@override
String get swapTo => '으로';
@override
String get swapAction => '교환';
@override
String get swapEstimatedFee => '예상 수수료';
@override
String get swapUseAll => '전액 사용';
@override
String swapMinimum(String amount) {
return '최소: $amount';
}
@override
String get swapProcessing => '교환 처리 중...';
@override
String get swapSuccess => '교환 완료';
@override
String get swapErrorInsufficient => '잔액 부족';
@override
String get swapErrorExpired => '견적이 만료되었습니다';
@override
String swapErrorGeneric(String error) {
return '교환 오류: $error';
}
@override
String get swapChartUnavailable => '가격 불러오기 실패 · 탭하여 재시도';
@override
String swapChartMinMax(String minPrice, String maxPrice) {
return '24h 최저: $minPrice — 최고: $maxPrice';
}
@override
String get privacyPolicy => '개인정보 처리방침';
@override
String get privacyTitle => '우리는 아무것도 수집하지 않습니다';
@override
String get privacyGoodbye => '안녕';
@override
String get privacyKeepReading => '(계속 읽고 싶으면…)';
@override
String get privacyBody => '당신이 누구인지 모릅니다\n얼마를 가지고 있는지 모릅니다\n무엇을 하는지 모릅니다';
@override
String get privacyConclusion => '데이터를 보호하는 가장 좋은 방법은\n데이터를 갖지 않는 것입니다';
}
+113 -2
View File
@@ -154,10 +154,10 @@ class L10nPt extends L10n {
String get send => 'Enviar';
@override
String get sendAction => 'Enviar ↗';
String get sendAction => 'Enviar';
@override
String get receiveAction => '↘ Receber';
String get receiveAction => 'Receber';
@override
String get deposit => 'Depositar';
@@ -213,6 +213,36 @@ class L10nPt extends L10n {
@override
String get pasteFromClipboard => 'Colar da área de transferência';
@override
String get emptyClipboard => 'Área de transferência vazia';
@override
String get cancelSend => 'Cancelar envio';
@override
String get cancelSendConfirmTitle => 'Cancelar este envio?';
@override
String get cancelSendConfirmBody =>
'Só funciona se o destinatário ainda não resgatou o token. Se já resgatou, o envio será marcado como concluído.';
@override
String cancelSendSuccess(String amount, String unit) {
return 'Envio cancelado. Recuperou $amount $unit';
}
@override
String get cancelSendAlreadyClaimed => 'O destinatário já resgatou o token';
@override
String get pendingOfflineSend => 'Envio offline pendente';
@override
String get pendingOfflineSendsHeader => 'Envios offline não resgatados';
@override
String get pendingSendDetailTitle => 'Envio pendente';
@override
String get validToken => 'Token válido';
@@ -293,6 +323,9 @@ class L10nPt extends L10n {
@override
String get insufficientBalance => 'Saldo insuficiente';
@override
String get feeExceedsAmount => 'A taxa excede o valor a enviar';
@override
String tokenCreationError(String error) {
return 'Erro ao criar token: $error';
@@ -521,6 +554,12 @@ class L10nPt extends L10n {
@override
String get sentStatus => 'Enviado';
@override
String get receiving => 'Recebendo';
@override
String get sending => 'Enviando';
@override
String get now => 'Agora';
@@ -1234,4 +1273,76 @@ class L10nPt extends L10n {
@override
String get copiedToClipboard => 'Copiado para a área de transferência';
@override
String get swap => 'Trocar';
@override
String get swapDescription => 'Converter entre sats e USD';
@override
String get swapFrom => 'De';
@override
String get swapTo => 'Para';
@override
String get swapAction => 'Trocar';
@override
String get swapEstimatedFee => 'Taxa estimada';
@override
String get swapUseAll => 'Usar tudo';
@override
String swapMinimum(String amount) {
return 'Mínimo: $amount';
}
@override
String get swapProcessing => 'Processando swap...';
@override
String get swapSuccess => 'Swap concluído';
@override
String get swapErrorInsufficient => 'Saldo insuficiente';
@override
String get swapErrorExpired => 'A cotação expirou';
@override
String swapErrorGeneric(String error) {
return 'Erro no swap: $error';
}
@override
String get swapChartUnavailable =>
'Preço indisponível · Toque para tentar novamente';
@override
String swapChartMinMax(String minPrice, String maxPrice) {
return '24h Mín: $minPrice — Máx: $maxPrice';
}
@override
String get privacyPolicy => 'Política de privacidade';
@override
String get privacyTitle => 'NÃO RECOLHEMOS NADA';
@override
String get privacyGoodbye => 'ADEUS';
@override
String get privacyKeepReading => '(continue lendo se quiser…)';
@override
String get privacyBody =>
'Não sabemos quem você é\nNão sabemos quanto você tem\nNão sabemos o que você faz';
@override
String get privacyConclusion =>
'A melhor forma de proteger seus dados\né não tê-los';
}
+112 -2
View File
@@ -153,10 +153,10 @@ class L10nRu extends L10n {
String get send => 'Отправить';
@override
String get sendAction => 'Отправить';
String get sendAction => 'Отправить';
@override
String get receiveAction => 'Получить';
String get receiveAction => 'Получить';
@override
String get deposit => 'Пополнить';
@@ -212,6 +212,36 @@ class L10nRu extends L10n {
@override
String get pasteFromClipboard => 'Вставить из буфера обмена';
@override
String get emptyClipboard => 'Буфер обмена пуст';
@override
String get cancelSend => 'Отменить отправку';
@override
String get cancelSendConfirmTitle => 'Отменить эту отправку?';
@override
String get cancelSendConfirmBody =>
'Работает только если получатель еще не получил токен. Если уже получил, отправка будет помечена как завершенная.';
@override
String cancelSendSuccess(String amount, String unit) {
return 'Отправка отменена. Восстановлено $amount $unit';
}
@override
String get cancelSendAlreadyClaimed => 'Получатель уже получил токен';
@override
String get pendingOfflineSend => 'Ожидающая оффлайн-отправка';
@override
String get pendingOfflineSendsHeader => 'Неполученные оффлайн-отправки';
@override
String get pendingSendDetailTitle => 'Ожидающая отправка';
@override
String get validToken => 'Токен действителен';
@@ -292,6 +322,9 @@ class L10nRu extends L10n {
@override
String get insufficientBalance => 'Недостаточный баланс';
@override
String get feeExceedsAmount => 'Комиссия превышает сумму отправки';
@override
String tokenCreationError(String error) {
return 'Ошибка создания токена: $error';
@@ -519,6 +552,12 @@ class L10nRu extends L10n {
@override
String get sentStatus => 'Отправлено';
@override
String get receiving => 'Получение';
@override
String get sending => 'Отправка';
@override
String get now => 'Сейчас';
@@ -1230,4 +1269,75 @@ class L10nRu extends L10n {
@override
String get copiedToClipboard => 'Скопировано в буфер обмена';
@override
String get swap => 'Обменять';
@override
String get swapDescription => 'Конвертировать между sats и USD';
@override
String get swapFrom => 'Из';
@override
String get swapTo => 'В';
@override
String get swapAction => 'Обменять';
@override
String get swapEstimatedFee => 'Ориентировочная комиссия';
@override
String get swapUseAll => 'Использовать всё';
@override
String swapMinimum(String amount) {
return 'Минимум: $amount';
}
@override
String get swapProcessing => 'Обработка обмена...';
@override
String get swapSuccess => 'Обмен завершён';
@override
String get swapErrorInsufficient => 'Недостаточный баланс';
@override
String get swapErrorExpired => 'Котировка истекла';
@override
String swapErrorGeneric(String error) {
return 'Ошибка обмена: $error';
}
@override
String get swapChartUnavailable => 'Цена недоступна · Нажмите для повтора';
@override
String swapChartMinMax(String minPrice, String maxPrice) {
return '24h Мин: $minPrice — Макс: $maxPrice';
}
@override
String get privacyPolicy => 'Политика конфиденциальности';
@override
String get privacyTitle => 'МЫ НЕ СОБИРАЕМ НИЧЕГО';
@override
String get privacyGoodbye => 'ПОКА';
@override
String get privacyKeepReading => '(читай дальше, если хочешь…)';
@override
String get privacyBody =>
'Мы не знаем, кто ты\nМы не знаем, сколько у тебя\nМы не знаем, что ты делаешь';
@override
String get privacyConclusion =>
'Лучший способ защитить твои данные —\nне иметь их';
}
+112 -2
View File
@@ -153,10 +153,10 @@ class L10nSw extends L10n {
String get send => 'Tuma';
@override
String get sendAction => 'Tuma ↗';
String get sendAction => 'Tuma';
@override
String get receiveAction => '↘ Pokea';
String get receiveAction => 'Pokea';
@override
String get deposit => 'Weka';
@@ -212,6 +212,36 @@ class L10nSw extends L10n {
@override
String get pasteFromClipboard => 'Bandika kutoka ubao';
@override
String get emptyClipboard => 'Ubao wa kunakili ni tupu';
@override
String get cancelSend => 'Ghairi kutuma';
@override
String get cancelSendConfirmTitle => 'Ghairi kutuma huku?';
@override
String get cancelSendConfirmBody =>
'Hufanya kazi tu ikiwa mpokeaji hajatumia tokeni. Ikiwa tayari alitumia, kutuma kutaonyeshwa kama kumekamilika.';
@override
String cancelSendSuccess(String amount, String unit) {
return 'Kutuma kumeghairiwa. Umerudisha $amount $unit';
}
@override
String get cancelSendAlreadyClaimed => 'Mpokeaji tayari alitumia tokeni';
@override
String get pendingOfflineSend => 'Kutuma nje ya mtandao kunasubiri';
@override
String get pendingOfflineSendsHeader => 'Kutuma nje ya mtandao bila kudaiwa';
@override
String get pendingSendDetailTitle => 'Kutuma kunakusubiri';
@override
String get validToken => 'Tokeni halali';
@@ -293,6 +323,9 @@ class L10nSw extends L10n {
@override
String get insufficientBalance => 'Salio halitoshi';
@override
String get feeExceedsAmount => 'Ada inazidi kiasi cha kutuma';
@override
String tokenCreationError(String error) {
return 'Hitilafu ya kuunda tokeni: $error';
@@ -518,6 +551,12 @@ class L10nSw extends L10n {
@override
String get sentStatus => 'Imetumwa';
@override
String get receiving => 'Inapokea';
@override
String get sending => 'Inatumwa';
@override
String get now => 'Sasa';
@@ -1231,4 +1270,75 @@ class L10nSw extends L10n {
@override
String get copiedToClipboard => 'Imenakiliwa kwenye ubao wa kunakili';
@override
String get swap => 'Badilisha';
@override
String get swapDescription => 'Badilisha kati ya sats na USD';
@override
String get swapFrom => 'Kutoka';
@override
String get swapTo => 'Kwenda';
@override
String get swapAction => 'Badilisha';
@override
String get swapEstimatedFee => 'Ada inayokadiriwa';
@override
String get swapUseAll => 'Tumia yote';
@override
String swapMinimum(String amount) {
return 'Kiwango cha chini: $amount';
}
@override
String get swapProcessing => 'Inashughulikia ubadilishaji...';
@override
String get swapSuccess => 'Ubadilishaji umekamilika';
@override
String get swapErrorInsufficient => 'Salio haitoshi';
@override
String get swapErrorExpired => 'Bei imeisha muda';
@override
String swapErrorGeneric(String error) {
return 'Kosa la ubadilishaji: $error';
}
@override
String get swapChartUnavailable => 'Bei haipatikani · Gusa kurudia';
@override
String swapChartMinMax(String minPrice, String maxPrice) {
return '24h Chini: $minPrice — Juu: $maxPrice';
}
@override
String get privacyPolicy => 'Sera ya faragha';
@override
String get privacyTitle => 'HATUKUSANYI CHOCHOTE';
@override
String get privacyGoodbye => 'KWAHERI';
@override
String get privacyKeepReading => '(endelea kusoma ukitaka…)';
@override
String get privacyBody =>
'Hatujui wewe ni nani\nHatujui una kiasi gani\nHatujui unafanya nini';
@override
String get privacyConclusion =>
'Njia bora ya kulinda data yako\nni kutokuwa nayo';
}
+109 -2
View File
@@ -148,10 +148,10 @@ class L10nZh extends L10n {
String get send => '付款';
@override
String get sendAction => '付款';
String get sendAction => '付款';
@override
String get receiveAction => '收款';
String get receiveAction => '收款';
@override
String get deposit => '存入';
@@ -207,6 +207,35 @@ class L10nZh extends L10n {
@override
String get pasteFromClipboard => '从剪贴板粘贴';
@override
String get emptyClipboard => '剪贴板为空';
@override
String get cancelSend => '取消发送';
@override
String get cancelSendConfirmTitle => '取消此次发送?';
@override
String get cancelSendConfirmBody => '仅当接收方尚未领取代币时有效。如已领取,发送将标记为已完成。';
@override
String cancelSendSuccess(String amount, String unit) {
return '发送已取消。已恢复 $amount $unit';
}
@override
String get cancelSendAlreadyClaimed => '接收方已领取代币';
@override
String get pendingOfflineSend => '待处理的离线发送';
@override
String get pendingOfflineSendsHeader => '未领取的离线发送';
@override
String get pendingSendDetailTitle => '待处理发送';
@override
String get validToken => '有效代币';
@@ -287,6 +316,9 @@ class L10nZh extends L10n {
@override
String get insufficientBalance => '余额不足';
@override
String get feeExceedsAmount => '手续费超过发送金额';
@override
String tokenCreationError(String error) {
return '创建代币错误:$error';
@@ -510,6 +542,12 @@ class L10nZh extends L10n {
@override
String get sentStatus => '已发送';
@override
String get receiving => '接收中';
@override
String get sending => '发送中';
@override
String get now => '刚刚';
@@ -1202,4 +1240,73 @@ class L10nZh extends L10n {
@override
String get copiedToClipboard => '已复制到剪贴板';
@override
String get swap => '兑换';
@override
String get swapDescription => '在 Sats 和 USD 之间转换';
@override
String get swapFrom => '';
@override
String get swapTo => '';
@override
String get swapAction => '兑换';
@override
String get swapEstimatedFee => '预估手续费';
@override
String get swapUseAll => '全部使用';
@override
String swapMinimum(String amount) {
return '最低: $amount';
}
@override
String get swapProcessing => '兑换处理中...';
@override
String get swapSuccess => '兑换完成';
@override
String get swapErrorInsufficient => '余额不足';
@override
String get swapErrorExpired => '报价已过期';
@override
String swapErrorGeneric(String error) {
return '兑换错误: $error';
}
@override
String get swapChartUnavailable => '价格不可用 · 点击重试';
@override
String swapChartMinMax(String minPrice, String maxPrice) {
return '24h 最低: $minPrice — 最高: $maxPrice';
}
@override
String get privacyPolicy => '隐私政策';
@override
String get privacyTitle => '我们什么都不收集';
@override
String get privacyGoodbye => '再见';
@override
String get privacyKeepReading => '(想继续看就看吧…)';
@override
String get privacyBody => '我们不知道你是谁\n我们不知道你有多少\n我们不知道你在做什么';
@override
String get privacyConclusion => '保护数据的最好方式\n就是不拥有它';
}
+43 -3
View File
@@ -63,8 +63,8 @@
"homeTitle": "Início",
"receive": "Receber",
"send": "Enviar",
"sendAction": "Enviar",
"receiveAction": "Receber",
"sendAction": "Enviar",
"receiveAction": "Receber",
"deposit": "Depositar",
"withdraw": "Sacar",
"lightning": "Lightning",
@@ -86,6 +86,21 @@
"receiveCashu": "Receber Cashu",
"pasteTheCashuToken": "Cole o token Cashu:",
"pasteFromClipboard": "Colar da área de transferência",
"emptyClipboard": "Área de transferência vazia",
"cancelSend": "Cancelar envio",
"cancelSendConfirmTitle": "Cancelar este envio?",
"cancelSendConfirmBody": "Só funciona se o destinatário ainda não resgatou o token. Se já resgatou, o envio será marcado como concluído.",
"cancelSendSuccess": "Envio cancelado. Recuperou {amount} {unit}",
"@cancelSendSuccess": {
"placeholders": {
"amount": {"type": "String"},
"unit": {"type": "String"}
}
},
"cancelSendAlreadyClaimed": "O destinatário já resgatou o token",
"pendingOfflineSend": "Envio offline pendente",
"pendingOfflineSendsHeader": "Envios offline não resgatados",
"pendingSendDetailTitle": "Envio pendente",
"validToken": "Token válido",
"invalidToken": "Token inválido ou malformado",
"amount": "Valor:",
@@ -118,6 +133,7 @@
"confirm": "Confirmar",
"cancel": "Cancelar",
"insufficientBalance": "Saldo insuficiente",
"feeExceedsAmount": "A taxa excede o valor a enviar",
"tokenCreationError": "Erro ao criar token: {error}",
"@tokenCreationError": {
"placeholders": {
@@ -227,6 +243,8 @@
"pendingStatus": "Pendente",
"receivedStatus": "Recebido",
"sentStatus": "Enviado",
"receiving": "Recebendo",
"sending": "Enviando",
"now": "Agora",
"agoMinutes": "Há {minutes} min",
"@agoMinutes": {
@@ -559,5 +577,27 @@
"requestPaymentReceived": "Pagamento recebido",
"requestDescriptionHint": "Descrição (opcional)",
"universal": "Universal",
"copiedToClipboard": "Copiado para a área de transferência"
"copiedToClipboard": "Copiado para a área de transferência",
"swap": "Trocar",
"swapDescription": "Converter entre sats e USD",
"swapFrom": "De",
"swapTo": "Para",
"swapAction": "Trocar",
"swapEstimatedFee": "Taxa estimada",
"swapUseAll": "Usar tudo",
"swapMinimum": "Mínimo: {amount}",
"swapProcessing": "Processando swap...",
"swapSuccess": "Swap concluído",
"swapErrorInsufficient": "Saldo insuficiente",
"swapErrorExpired": "A cotação expirou",
"swapErrorGeneric": "Erro no swap: {error}",
"swapChartUnavailable": "Preço indisponível · Toque para tentar novamente",
"swapChartMinMax": "24h Mín: {minPrice} — Máx: {maxPrice}",
"privacyPolicy": "Política de privacidade",
"privacyTitle": "NÃO RECOLHEMOS NADA",
"privacyGoodbye": "ADEUS",
"privacyKeepReading": "(continue lendo se quiser…)",
"privacyBody": "Não sabemos quem você é\nNão sabemos quanto você tem\nNão sabemos o que você faz",
"privacyConclusion": "A melhor forma de proteger seus dados\né não tê-los"
}
+43 -3
View File
@@ -63,8 +63,8 @@
"homeTitle": "Главная",
"receive": "Получить",
"send": "Отправить",
"sendAction": "Отправить",
"receiveAction": "Получить",
"sendAction": "Отправить",
"receiveAction": "Получить",
"deposit": "Пополнить",
"withdraw": "Вывести",
"lightning": "Lightning",
@@ -86,6 +86,21 @@
"receiveCashu": "Получить Cashu",
"pasteTheCashuToken": "Вставьте Cashu токен:",
"pasteFromClipboard": "Вставить из буфера обмена",
"emptyClipboard": "Буфер обмена пуст",
"cancelSend": "Отменить отправку",
"cancelSendConfirmTitle": "Отменить эту отправку?",
"cancelSendConfirmBody": "Работает только если получатель еще не получил токен. Если уже получил, отправка будет помечена как завершенная.",
"cancelSendSuccess": "Отправка отменена. Восстановлено {amount} {unit}",
"@cancelSendSuccess": {
"placeholders": {
"amount": {"type": "String"},
"unit": {"type": "String"}
}
},
"cancelSendAlreadyClaimed": "Получатель уже получил токен",
"pendingOfflineSend": "Ожидающая оффлайн-отправка",
"pendingOfflineSendsHeader": "Неполученные оффлайн-отправки",
"pendingSendDetailTitle": "Ожидающая отправка",
"validToken": "Токен действителен",
"invalidToken": "Недействительный или повреждённый токен",
"amount": "Сумма:",
@@ -118,6 +133,7 @@
"confirm": "Подтвердить",
"cancel": "Отмена",
"insufficientBalance": "Недостаточный баланс",
"feeExceedsAmount": "Комиссия превышает сумму отправки",
"tokenCreationError": "Ошибка создания токена: {error}",
"@tokenCreationError": {
"placeholders": {
@@ -227,6 +243,8 @@
"pendingStatus": "Ожидание",
"receivedStatus": "Получено",
"sentStatus": "Отправлено",
"receiving": "Получение",
"sending": "Отправка",
"now": "Сейчас",
"agoMinutes": "{minutes} мин назад",
"@agoMinutes": {
@@ -559,5 +577,27 @@
"requestPaymentReceived": "Платёж получен",
"requestDescriptionHint": "Описание (необязательно)",
"universal": "Универсальный",
"copiedToClipboard": "Скопировано в буфер обмена"
"copiedToClipboard": "Скопировано в буфер обмена",
"swap": "Обменять",
"swapDescription": "Конвертировать между sats и USD",
"swapFrom": "Из",
"swapTo": "В",
"swapAction": "Обменять",
"swapEstimatedFee": "Ориентировочная комиссия",
"swapUseAll": "Использовать всё",
"swapMinimum": "Минимум: {amount}",
"swapProcessing": "Обработка обмена...",
"swapSuccess": "Обмен завершён",
"swapErrorInsufficient": "Недостаточный баланс",
"swapErrorExpired": "Котировка истекла",
"swapErrorGeneric": "Ошибка обмена: {error}",
"swapChartUnavailable": "Цена недоступна · Нажмите для повтора",
"swapChartMinMax": "24h Мин: {minPrice} — Макс: {maxPrice}",
"privacyPolicy": "Политика конфиденциальности",
"privacyTitle": "МЫ НЕ СОБИРАЕМ НИЧЕГО",
"privacyGoodbye": "ПОКА",
"privacyKeepReading": "(читай дальше, если хочешь…)",
"privacyBody": "Мы не знаем, кто ты\nМы не знаем, сколько у тебя\nМы не знаем, что ты делаешь",
"privacyConclusion": "Лучший способ защитить твои данные —\nне иметь их"
}
+43 -3
View File
@@ -63,8 +63,8 @@
"homeTitle": "Nyumbani",
"receive": "Pokea",
"send": "Tuma",
"sendAction": "Tuma",
"receiveAction": "Pokea",
"sendAction": "Tuma",
"receiveAction": "Pokea",
"deposit": "Weka",
"withdraw": "Toa",
"lightning": "Lightning",
@@ -86,6 +86,21 @@
"receiveCashu": "Pokea Cashu",
"pasteTheCashuToken": "Bandika tokeni ya Cashu:",
"pasteFromClipboard": "Bandika kutoka ubao",
"emptyClipboard": "Ubao wa kunakili ni tupu",
"cancelSend": "Ghairi kutuma",
"cancelSendConfirmTitle": "Ghairi kutuma huku?",
"cancelSendConfirmBody": "Hufanya kazi tu ikiwa mpokeaji hajatumia tokeni. Ikiwa tayari alitumia, kutuma kutaonyeshwa kama kumekamilika.",
"cancelSendSuccess": "Kutuma kumeghairiwa. Umerudisha {amount} {unit}",
"@cancelSendSuccess": {
"placeholders": {
"amount": {"type": "String"},
"unit": {"type": "String"}
}
},
"cancelSendAlreadyClaimed": "Mpokeaji tayari alitumia tokeni",
"pendingOfflineSend": "Kutuma nje ya mtandao kunasubiri",
"pendingOfflineSendsHeader": "Kutuma nje ya mtandao bila kudaiwa",
"pendingSendDetailTitle": "Kutuma kunakusubiri",
"validToken": "Tokeni halali",
"invalidToken": "Tokeni batili au imeharibika",
"amount": "Kiasi:",
@@ -118,6 +133,7 @@
"confirm": "Thibitisha",
"cancel": "Ghairi",
"insufficientBalance": "Salio halitoshi",
"feeExceedsAmount": "Ada inazidi kiasi cha kutuma",
"tokenCreationError": "Hitilafu ya kuunda tokeni: {error}",
"@tokenCreationError": {
"placeholders": {
@@ -227,6 +243,8 @@
"pendingStatus": "Inasubiri",
"receivedStatus": "Imepokelewa",
"sentStatus": "Imetumwa",
"receiving": "Inapokea",
"sending": "Inatumwa",
"now": "Sasa",
"agoMinutes": "Dakika {minutes} zilizopita",
"@agoMinutes": {
@@ -559,5 +577,27 @@
"requestPaymentReceived": "Malipo limepokelewa",
"requestDescriptionHint": "Maelezo (si lazima)",
"universal": "Universal",
"copiedToClipboard": "Imenakiliwa kwenye ubao wa kunakili"
"copiedToClipboard": "Imenakiliwa kwenye ubao wa kunakili",
"swap": "Badilisha",
"swapDescription": "Badilisha kati ya sats na USD",
"swapFrom": "Kutoka",
"swapTo": "Kwenda",
"swapAction": "Badilisha",
"swapEstimatedFee": "Ada inayokadiriwa",
"swapUseAll": "Tumia yote",
"swapMinimum": "Kiwango cha chini: {amount}",
"swapProcessing": "Inashughulikia ubadilishaji...",
"swapSuccess": "Ubadilishaji umekamilika",
"swapErrorInsufficient": "Salio haitoshi",
"swapErrorExpired": "Bei imeisha muda",
"swapErrorGeneric": "Kosa la ubadilishaji: {error}",
"swapChartUnavailable": "Bei haipatikani · Gusa kurudia",
"swapChartMinMax": "24h Chini: {minPrice} — Juu: {maxPrice}",
"privacyPolicy": "Sera ya faragha",
"privacyTitle": "HATUKUSANYI CHOCHOTE",
"privacyGoodbye": "KWAHERI",
"privacyKeepReading": "(endelea kusoma ukitaka…)",
"privacyBody": "Hatujui wewe ni nani\nHatujui una kiasi gani\nHatujui unafanya nini",
"privacyConclusion": "Njia bora ya kulinda data yako\nni kutokuwa nayo"
}
+43 -3
View File
@@ -63,8 +63,8 @@
"homeTitle": "首页",
"receive": "收款",
"send": "付款",
"sendAction": "付款",
"receiveAction": "收款",
"sendAction": "付款",
"receiveAction": "收款",
"deposit": "存入",
"withdraw": "提取",
"lightning": "闪电网络",
@@ -86,6 +86,21 @@
"receiveCashu": "接收 Cashu",
"pasteTheCashuToken": "粘贴 Cashu 代币:",
"pasteFromClipboard": "从剪贴板粘贴",
"emptyClipboard": "剪贴板为空",
"cancelSend": "取消发送",
"cancelSendConfirmTitle": "取消此次发送?",
"cancelSendConfirmBody": "仅当接收方尚未领取代币时有效。如已领取,发送将标记为已完成。",
"cancelSendSuccess": "发送已取消。已恢复 {amount} {unit}",
"@cancelSendSuccess": {
"placeholders": {
"amount": {"type": "String"},
"unit": {"type": "String"}
}
},
"cancelSendAlreadyClaimed": "接收方已领取代币",
"pendingOfflineSend": "待处理的离线发送",
"pendingOfflineSendsHeader": "未领取的离线发送",
"pendingSendDetailTitle": "待处理发送",
"validToken": "有效代币",
"invalidToken": "无效或格式错误的代币",
"amount": "金额:",
@@ -118,6 +133,7 @@
"confirm": "确认",
"cancel": "取消",
"insufficientBalance": "余额不足",
"feeExceedsAmount": "手续费超过发送金额",
"tokenCreationError": "创建代币错误:{error}",
"@tokenCreationError": {
"placeholders": {
@@ -227,6 +243,8 @@
"pendingStatus": "待处理",
"receivedStatus": "已收到",
"sentStatus": "已发送",
"receiving": "接收中",
"sending": "发送中",
"now": "刚刚",
"agoMinutes": "{minutes} 分钟前",
"@agoMinutes": {
@@ -559,5 +577,27 @@
"requestPaymentReceived": "已收到付款",
"requestDescriptionHint": "描述(可选)",
"universal": "通用",
"copiedToClipboard": "已复制到剪贴板"
"copiedToClipboard": "已复制到剪贴板",
"swap": "兑换",
"swapDescription": "在 Sats 和 USD 之间转换",
"swapFrom": "从",
"swapTo": "到",
"swapAction": "兑换",
"swapEstimatedFee": "预估手续费",
"swapUseAll": "全部使用",
"swapMinimum": "最低: {amount}",
"swapProcessing": "兑换处理中...",
"swapSuccess": "兑换完成",
"swapErrorInsufficient": "余额不足",
"swapErrorExpired": "报价已过期",
"swapErrorGeneric": "兑换错误: {error}",
"swapChartUnavailable": "价格不可用 · 点击重试",
"swapChartMinMax": "24h 最低: {minPrice} — 最高: {maxPrice}",
"privacyPolicy": "隐私政策",
"privacyTitle": "我们什么都不收集",
"privacyGoodbye": "再见",
"privacyKeepReading": "(想继续看就看吧…)",
"privacyBody": "我们不知道你是谁\n我们不知道你有多少\n我们不知道你在做什么",
"privacyConclusion": "保护数据的最好方式\n就是不拥有它"
}
+299 -34
View File
@@ -15,6 +15,8 @@ import 'package:uuid/uuid.dart';
import '../data/transaction_meta_storage.dart';
import '../data/pending_token.dart';
import '../data/pending_token_storage.dart';
import '../data/pending_send.dart';
import '../data/pending_send_storage.dart';
import '../core/utils/keyset_debug.dart';
import '../core/utils/p2pk_utils.dart';
import '../widgets/effects/cashu_confetti.dart';
@@ -47,6 +49,9 @@ class WalletProvider extends ChangeNotifier {
/// Storage para tokens pendientes de reclamar (Receive Later)
final PendingTokenStorage _pendingTokenStorage = PendingTokenStorage();
/// Storage para envíos offline pendientes de ser reclamados por el receptor
final PendingSendStorage _pendingSendStorage = PendingSendStorage();
/// Controller global de confetti para celebrar recepciones
final CashuConfettiController confettiController = CashuConfettiController();
@@ -125,6 +130,159 @@ class WalletProvider extends ChangeNotifier {
/// Stream de cambios en pending tokens
Stream<void> get pendingTokenChanges => _pendingTokenStorage.changes;
// ============================================================
// PENDING SENDS GETTERS (envíos offline no reclamados)
// ============================================================
int get pendingSendCount => _pendingSendStorage.count;
int get activePendingSendCount => _pendingSendStorage.activeCount;
bool get hasPendingSends => _pendingSendStorage.hasPendingSends;
bool get hasActivePendingSends => _pendingSendStorage.hasActivePendingSends;
Stream<void> get pendingSendChanges => _pendingSendStorage.changes;
List<PendingSend> listPendingSends() => _pendingSendStorage.listAll();
/// Envíos activos (receptor aún no reclamó). Render: tile warning con
/// botón cancel.
List<PendingSend> listActivePendingSends() =>
_pendingSendStorage.listActive();
/// Envíos liquidados (receptor reclamó). Render: tile outgoing settled,
/// sin botón cancel.
List<PendingSend> listSettledPendingSends() =>
_pendingSendStorage.listSettled();
List<PendingSend> listPendingSendsByMintUnit(String mintUrl, String unit) =>
_pendingSendStorage.listByMintUnit(mintUrl, unit);
/// Persiste un envío offline pendiente para poder reclamarlo después.
/// Llamar desde el flujo de offline_send_screen después de crear el token.
Future<PendingSend> addPendingSend({
required String encoded,
required BigInt amount,
required String mintUrl,
required String unit,
required List<String> proofYs,
String? memo,
}) async {
final send = await _pendingSendStorage.add(
id: _uuid.v4(),
encoded: encoded,
amount: amount,
mintUrl: mintUrl,
unit: unit,
proofYs: proofYs,
memo: memo,
);
// El flujo offline modifica el DB directamente (markProofsPendingSpent),
// así que CDK no sabe que cambió. Forzamos refresh del stream de balance
// para que el home se actualice al instante.
try {
final wallet = await getWallet(mintUrl, unit);
await wallet.refreshBalance();
} catch (e) {
debugPrint('refreshBalance tras addPendingSend falló: $e');
}
notifyListeners();
return send;
}
/// Reclama las proofs de un envío offline pendiente. Verifica con el mint:
/// - Si algunas están UNSPENT → revierte localmente y borra el PendingSend.
/// - Si ninguna UNSPENT y ninguna PENDING (todas SPENT o ya reconciliadas)
/// → el receptor reclamó: borra el PendingSend.
/// - Si hay proofs PENDING en el mint → conserva el PendingSend para retry.
Future<ReclaimResult> reclaimPendingSend(PendingSend send) async {
final wallet = await getWallet(send.mintUrl, send.unit);
final result = await wallet.reclaimProofsByYs(ys: send.proofYs);
await _settlePendingSendOutcome(send, result);
notifyListeners();
return result;
}
/// Reconcilia todos los PendingSend contra el mint. Análogo a
/// `checkPendingTransactions` pero para el storage propio de envíos
/// offline (que CDK no conoce). Pensado para correr en pull-to-refresh,
/// history mount y startup.
///
/// **Importante:** usa `checkProofsByYs` (observe-only), no
/// `reclaimProofsByYs`. La reconciliación automática NO debe revertir
/// proofs Unspent — eso cancelaría envíos legítimos que el receptor
/// todavía no reclamó, abriendo una ventana de doble-gasto.
///
/// Sólo marca `settled` cuando el mint confirma que todos los proofs
/// están gastados. Cualquier otro caso (pending mid-swap o unspent
/// esperando al receptor) mantiene el record activo.
///
/// Tolera errores por-record (un mint offline no rompe el resto).
/// Devuelve cuántos records pasaron a settled.
Future<int> reconcilePendingSends() async {
// Sólo activos: los settled ya están resueltos.
final sends = _pendingSendStorage.listActive();
if (sends.isEmpty) return 0;
var settled = 0;
for (final send in sends) {
try {
final wallet = await getWallet(send.mintUrl, send.unit);
final result = await wallet.checkProofsByYs(ys: send.proofYs);
final total = BigInt.from(send.proofYs.length);
final allSpent = result.pendingCount == BigInt.zero &&
result.spentCount >= total;
if (allSpent) {
await _pendingSendStorage.markSettled(send.id);
settled++;
}
} catch (e) {
debugPrint('reconcilePendingSends: ${send.id} failed: $e');
}
}
if (settled > 0) notifyListeners();
return settled;
}
/// Decisión central sobre qué hacer con un PendingSend tras consultar
/// el mint. Devuelve `true` si el record dejó de estar activo.
///
/// - `count > 0` → recuperamos nosotros:
/// el envío se abortó, no hay ledger que mostrar. REMOVE.
/// - `pending == 0 && spent >= proofYs.length` → mint confirmó todos
/// spent: receptor reclamó. SETTLE para histórico.
/// - cualquier otro caso (pending > 0, o cobertura parcial de spent)
/// → conservar activo; próxima reconciliación reintenta.
Future<bool> _settlePendingSendOutcome(
PendingSend send,
ReclaimResult result,
) async {
if (result.count > BigInt.zero) {
// Recuperamos las proofs; el envío se canceló desde nuestro lado.
await _pendingSendStorage.remove(send.id);
return true;
}
// Defensa en profundidad: aunque `pending_count == 0` suele bastar para
// decir "todas consumidas", exigimos además que `spent_count` cubra
// todos los proofs del send. Un resultado parcial o malformado podría
// dejar `pending == 0` sin haber observado spent para todos los ys.
final expected = BigInt.from(send.proofYs.length);
final allAccountedSpent = expected > BigInt.zero &&
result.pendingCount == BigInt.zero &&
result.spentCount >= expected;
if (allAccountedSpent) {
// Ninguna unspent, ninguna pending, todas spent → receptor reclamó.
// Pasa a histórico como "outgoing liquidado".
await _pendingSendStorage.markSettled(send.id);
return true;
}
// pending > 0 (receptor mid-swap) o cobertura parcial → conservamos
// activo para que la próxima reconciliación lo resuelva.
return false;
}
Future<void> removePendingSend(String id) async {
await _pendingSendStorage.remove(id);
notifyListeners();
}
/// Lista de mints ordenados por balance.
/// Cuba Bitcoin siempre primero, luego ordenados por: sats → usd → eur → otros.
Future<List<String>> getSortedMintUrls() async {
@@ -333,6 +491,9 @@ class WalletProvider extends ChangeNotifier {
// Inicializar storage de tokens pendientes
await _pendingTokenStorage.init();
// Inicializar storage de envíos offline pendientes
await _pendingSendStorage.init();
// Obtener directorio de documentos (path absoluto requerido)
final dir = await getApplicationDocumentsDirectory();
final dbPath = '${dir.path}/elcaju_wallet.sqlite';
@@ -1092,43 +1253,27 @@ class WalletProvider extends ChangeNotifier {
}
/// Método de conveniencia: prepara y confirma en un solo paso.
/// Si confirmSend falla, libera proofs reservados con cancelSend.
/// Si confirmSend falla, los proofs quedan en PendingSpent.
/// No llamamos reclaimPendingProofs aquí porque podría revocar
/// tokens de otros envíos no reclamados. El usuario puede recuperar
/// manualmente desde Settings → Recover Tokens.
Future<String> sendTokens(BigInt amount, String? memo) async {
final prepared = await prepareSend(amount);
debugPrint('[SEND] prepareSend OK - fee=${prepared.fee}');
try {
final token = await confirmSend(prepared, memo);
debugPrint('[SEND] Send completed');
return token;
} catch (e) {
try {
await cancelSend(prepared);
} catch (cancelErr) {
debugPrint('[SEND] cancelSend failed: $cancelErr');
}
rethrow;
}
final token = await confirmSend(prepared, memo);
debugPrint('[SEND] Send completed');
return token;
}
/// Envía tokens P2PK (bloqueados a una clave pública).
/// CDK 0.15+ con includeFee: true maneja correctamente mints con ppk>0.
/// Si confirmSend falla, libera proofs reservados con cancelSend.
Future<String> sendTokensP2pk(BigInt amount, String pubkey, String? memo) async {
debugPrint('[P2PK] Sending $amount to ${pubkey.length > 16 ? pubkey.substring(0, 16) : pubkey}...');
final prepared = await prepareSendP2pk(amount, pubkey);
debugPrint('[P2PK] prepareSend OK - fee=${prepared.fee}');
try {
final token = await confirmSend(prepared, memo);
debugPrint('[P2PK] Send completed');
return token;
} catch (e) {
try {
await cancelSend(prepared);
} catch (cancelErr) {
debugPrint('[P2PK] cancelSend failed: $cancelErr');
}
rethrow;
}
final token = await confirmSend(prepared, memo);
debugPrint('[P2PK] Send completed');
return token;
}
/// Verifica si hay transacciones salientes pendientes en el wallet activo.
@@ -1498,19 +1643,82 @@ class WalletProvider extends ChangeNotifier {
}
/// Ejecuta el pago del invoice.
/// Guarda metadata type=lightning para identificar en historial.
/// FRB consume PreparedMelt por valor, así que cancelMelt no es posible.
/// Si falla, usamos recoverIncompleteSagas para reanudar solo esta operación
/// sin afectar otros proofs pendientes (como tokens enviados no reclamados).
Future<BigInt> melt(MeltQuote quote) async {
final wallet = await getActiveWallet();
final totalPaid = await wallet.melt(quote: quote);
final prepared = await wallet.prepareMelt(quote: quote);
try {
final totalPaid = await wallet.confirmMelt(melt: prepared);
// Guardar metadata con el invoice
if (_pendingMeltInvoice != null) {
await _saveMeltMetadata(wallet, _pendingMeltInvoice!);
_pendingMeltInvoice = null;
// Guardar metadata con el invoice
if (_pendingMeltInvoice != null) {
await _saveMeltMetadata(wallet, _pendingMeltInvoice!);
_pendingMeltInvoice = null;
}
notifyListeners();
return totalPaid;
} catch (e) {
// PreparedMelt ya fue consumido por FRB — cancelMelt es imposible.
// recoverIncompleteSagas reanuda solo la saga interrumpida,
// sin afectar otros proofs en PendingSpent (sends no reclamados).
try {
await wallet.recoverIncompleteSagas();
} catch (recoverErr) {
debugPrint('[MELT] recoverIncompleteSagas failed: $recoverErr');
}
rethrow;
}
}
notifyListeners();
return totalPaid;
/// Recupera proofs huérfanas en PendingSpent consultando el mint.
/// Solo revierte proofs que el mint confirma como no gastadas.
/// Si [mintUrl] es null, escanea todos los mints.
/// Retorna Map<unit, ReclaimResult> con resultados agrupados por unidad.
Future<Map<String, ReclaimResult>> reclaimPendingProofs({String? mintUrl}) async {
final perUnit = <String, ReclaimResult>{};
final mints = mintUrl != null
? {mintUrl: _mintUnits[mintUrl] ?? ['sat']}
: _mintUnits;
for (final entry in mints.entries) {
for (final unit in entry.value) {
try {
final wallet = await getWallet(entry.key, unit);
final result = await wallet.reclaimPendingProofs();
if (result.count > BigInt.zero) {
final prev = perUnit[unit];
perUnit[unit] = ReclaimResult(
count: (prev?.count ?? BigInt.zero) + result.count,
amount: (prev?.amount ?? BigInt.zero) + result.amount,
pendingCount:
(prev?.pendingCount ?? BigInt.zero) + result.pendingCount,
spentCount:
(prev?.spentCount ?? BigInt.zero) + result.spentCount,
);
}
} catch (e) {
debugPrint('Reclaim pending proofs failed for ${entry.key}:$unit: $e');
}
}
}
if (perUnit.isNotEmpty) {
notifyListeners();
}
return perUnit;
}
/// Cancela un envío pendiente reclamando sus proofs.
/// Usa tx.ys para identificar las proofs exactas de esa transacción.
/// Solo revierte las que el mint confirma como no gastadas.
Future<ReclaimResult> reclaimTransaction(Transaction tx) async {
final wallet = await getWallet(tx.mintUrl, tx.unit);
final result = await wallet.reclaimProofsByYs(ys: tx.ys);
if (result.count > BigInt.zero) {
notifyListeners();
}
return result;
}
/// Guarda metadata para una transacción de melt (Lightning withdrawal).
@@ -1537,6 +1745,58 @@ class WalletProvider extends ChangeNotifier {
}
}
/// Guarda metadata para el lado melt (enviado) de un swap.
/// Busca la tx outgoing más reciente que coincida con el monto.
Future<void> saveSwapMeltMetadata(Wallet wallet, String invoice, BigInt amount) async {
try {
final txs = await wallet.listTransactions(
direction: TransactionDirection.outgoing,
);
final tx = txs.cast<Transaction?>().firstWhere(
(t) => t!.amount == amount && !_txMetaStorage.has(t.id),
orElse: () => null,
);
if (tx != null) {
await _txMetaStorage.save(
tx.id,
TransactionMeta(type: TransactionType.lightning, invoice: invoice),
);
debugPrint('Swap melt metadata guardada para tx ${tx.id}');
} else {
debugPrint('No matching untagged outgoing tx found for amount $amount');
}
} catch (e) {
debugPrint('Error guardando swap melt metadata: $e');
}
notifyListeners();
}
/// Guarda metadata para el lado mint (recibido) de un swap.
/// Busca la tx incoming más reciente que coincida con el monto.
Future<void> saveSwapMintMetadata(Wallet wallet, String invoice, BigInt amount) async {
try {
final txs = await wallet.listTransactions(
direction: TransactionDirection.incoming,
);
final tx = txs.cast<Transaction?>().firstWhere(
(t) => t!.amount == amount && !_txMetaStorage.has(t.id),
orElse: () => null,
);
if (tx != null) {
await _txMetaStorage.save(
tx.id,
TransactionMeta(type: TransactionType.lightning, invoice: invoice),
);
debugPrint('Swap mint metadata guardada para tx ${tx.id}');
} else {
debugPrint('No matching untagged incoming tx found for amount $amount');
}
} catch (e) {
debugPrint('Error guardando swap mint metadata: $e');
}
notifyListeners();
}
// ============================================================
// HISTORIAL
// ============================================================
@@ -1642,6 +1902,7 @@ class WalletProvider extends ChangeNotifier {
// Silencioso - puede fallar offline
debugPrint('Check pending failed: $e');
}
} catch (e) {
debugPrint('Error getting wallet ${entry.key}:$unit: $e');
}
@@ -1859,6 +2120,10 @@ class WalletProvider extends ChangeNotifier {
// Limpiar metadata de transacciones
await _txMetaStorage.clear();
// Limpiar envíos offline pendientes (storage separado, sobrevive al
// borrado del wallet.sqlite si no se limpia acá).
await _pendingSendStorage.clear();
// Borrar archivo
final dir = await getApplicationDocumentsDirectory();
final dbPath = '${dir.path}/elcaju_wallet.sqlite';
+89 -27
View File
@@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import 'package:lucide_icons/lucide_icons.dart';
import 'package:elcaju/l10n/app_localizations.dart';
@@ -59,38 +60,81 @@ class _ScanScreenState extends State<ScanScreen> {
children: [
// Scanner
QrScannerWidget(
onDetect: _onCodeDetected,
showFlashControl: true,
showCameraSwitch: false,
),
// Instrucciones en la parte inferior
Positioned(
bottom: 100,
left: 24,
right: 24,
child: Text(
_getInstructionForMode(l10n),
style: TextStyle(
fontFamily: 'Inter',
fontSize: 16,
color: Colors.white.withValues(alpha: 0.8),
),
textAlign: TextAlign.center,
onDetect: _onCodeDetected,
showFlashControl: true,
showCameraSwitch: false,
),
),
// Indicador de procesamiento
if (_isProcessing)
Container(
color: Colors.black54,
child: const Center(
child: CircularProgressIndicator(
color: AppColors.primaryAction,
// Instrucciones en la parte inferior
Positioned(
bottom: 170,
left: 24,
right: 24,
child: Text(
_getInstructionForMode(l10n),
style: TextStyle(
fontFamily: 'Inter',
fontSize: 16,
color: Colors.white.withValues(alpha: 0.8),
),
textAlign: TextAlign.center,
),
),
// Botón pegar del portapapeles (encima del flash)
Positioned(
bottom: 100,
left: 24,
right: 24,
child: Center(
child: Material(
color: Colors.white.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(24),
child: InkWell(
onTap: _isProcessing ? null : _pasteFromClipboard,
borderRadius: BorderRadius.circular(24),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 20,
vertical: 12,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
LucideIcons.clipboard,
color: Colors.white,
size: 20,
),
const SizedBox(width: 8),
Text(
l10n.pasteFromClipboard,
style: const TextStyle(
fontFamily: 'Inter',
fontSize: 14,
fontWeight: FontWeight.w600,
color: Colors.white,
),
),
],
),
),
),
),
),
),
],
// Indicador de procesamiento
if (_isProcessing)
Container(
color: Colors.black54,
child: const Center(
child: CircularProgressIndicator(
color: AppColors.primaryAction,
),
),
),
],
),
),
);
@@ -118,6 +162,24 @@ class _ScanScreenState extends State<ScanScreen> {
}
}
Future<void> _pasteFromClipboard() async {
final l10n = L10n.of(context)!;
final clipboardData = await Clipboard.getData(Clipboard.kTextPlain);
final text = clipboardData?.text?.trim();
if (!mounted) return;
if (text == null || text.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(l10n.emptyClipboard),
backgroundColor: AppColors.warning,
duration: const Duration(seconds: 2),
),
);
return;
}
_onCodeDetected(text);
}
void _onCodeDetected(String rawData) async {
if (_isProcessing) return;
File diff suppressed because it is too large Load Diff
+6 -1
View File
@@ -166,7 +166,12 @@ class _SplashScreenState extends State<SplashScreen>
/// Se ejecuta sin bloquear la navegación.
Future<void> _checkPendingTransactions(WalletProvider wallet) async {
try {
await wallet.checkPendingTransactions();
// CDK reconcilia online; reconcilePendingSends reconcilia los envíos
// offline (storage propio).
await Future.wait([
wallet.checkPendingTransactions(),
wallet.reconcilePendingSends(),
]);
debugPrint('Verificación de proofs completada');
} catch (e) {
debugPrint('Error verificando proofs: $e');
+74 -80
View File
@@ -9,6 +9,7 @@ import '../../core/utils/incoming_data_parser.dart';
import '../../widgets/common/gradient_background.dart';
import '../../widgets/common/glass_card.dart';
import '../../widgets/common/animated_action_button.dart';
import '../../widgets/common/bottom_sheet_container.dart';
import '../../providers/wallet_provider.dart';
import '../../providers/settings_provider.dart';
import '../../providers/p2pk_provider.dart';
@@ -356,6 +357,9 @@ class _HomeScreenState extends State<HomeScreen> {
Expanded(
child: AnimatedActionButton(
label: l10n.sendAction,
icon: LucideIcons.arrowUpRight,
showIcon: true,
iconTrailing: true,
type: ButtonType.criticalAction,
onTap: _showSendOptions,
),
@@ -368,6 +372,8 @@ class _HomeScreenState extends State<HomeScreen> {
Expanded(
child: AnimatedActionButton(
label: l10n.receiveAction,
icon: LucideIcons.arrowDownRight,
showIcon: true,
type: ButtonType.primaryAction,
onTap: _showReceiveOptions,
),
@@ -573,29 +579,11 @@ class _MethodSelectorModal extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(AppDimensions.paddingMedium),
decoration: BoxDecoration(
color: AppColors.deepVoidPurple,
borderRadius: const BorderRadius.vertical(top: Radius.circular(24)),
border: Border.all(
color: Colors.white.withValues(alpha: 0.1),
width: 1,
),
),
return BottomSheetContainer(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Handle
Container(
margin: const EdgeInsets.only(bottom: 16),
width: 40,
height: 4,
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.3),
borderRadius: BorderRadius.circular(2),
),
),
const BottomSheetHandle(),
// Título
Text(
@@ -618,9 +606,6 @@ class _MethodSelectorModal extends StatelessWidget {
child: _MethodOptionTile(option: option),
),
),
// Espacio para la barra de navegación del sistema
SizedBox(height: MediaQuery.of(context).padding.bottom + AppDimensions.paddingSmall),
],
),
);
@@ -635,67 +620,76 @@ class _MethodOptionTile extends StatelessWidget {
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: option.onTap,
child: Container(
width: double.infinity,
padding: const EdgeInsets.all(AppDimensions.paddingMedium),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.05),
return Semantics(
button: true,
label: option.label,
hint: option.description,
child: Material(
color: Colors.white.withValues(alpha: 0.05),
borderRadius: BorderRadius.circular(16),
child: InkWell(
onTap: option.onTap,
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: Colors.white.withValues(alpha: 0.1),
width: 1,
),
),
child: Row(
children: [
// Icono
Container(
width: 48,
height: 48,
decoration: BoxDecoration(
gradient: const LinearGradient(
colors: AppColors.buttonGradient,
child: Container(
width: double.infinity,
padding: const EdgeInsets.all(AppDimensions.paddingMedium),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: Colors.white.withValues(alpha: 0.1),
width: 1,
),
),
child: Row(
children: [
// Icono
Container(
width: 48,
height: 48,
decoration: BoxDecoration(
gradient: const LinearGradient(
colors: AppColors.buttonGradient,
),
borderRadius: BorderRadius.circular(12),
),
child: Icon(option.icon, color: Colors.white, size: 24),
),
borderRadius: BorderRadius.circular(12),
),
child: Icon(option.icon, color: Colors.white, size: 24),
),
const SizedBox(width: AppDimensions.paddingMedium),
// Texto
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
option.label,
style: const TextStyle(
fontFamily: 'Inter',
fontSize: 18,
fontWeight: FontWeight.w600,
color: Colors.white,
),
const SizedBox(width: AppDimensions.paddingMedium),
// Texto
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
option.label,
style: const TextStyle(
fontFamily: 'Inter',
fontSize: 18,
fontWeight: FontWeight.w600,
color: Colors.white,
),
),
const SizedBox(height: 2),
Text(
option.description,
style: TextStyle(
fontFamily: 'Inter',
fontSize: 14,
color: AppColors.textSecondary.withValues(alpha: 0.7),
),
),
],
),
const SizedBox(height: 2),
Text(
option.description,
style: TextStyle(
fontFamily: 'Inter',
fontSize: 14,
color: AppColors.textSecondary.withValues(alpha: 0.7),
),
),
],
),
),
// Flecha
Icon(
LucideIcons.chevronRight,
color: Colors.white.withValues(alpha: 0.5),
size: 24,
),
],
),
// Flecha
Icon(
LucideIcons.chevronRight,
color: Colors.white.withValues(alpha: 0.5),
size: 24,
),
],
),
),
),
);
@@ -1,7 +1,9 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:lucide_icons/lucide_icons.dart';
import 'package:provider/provider.dart';
import 'package:elcaju/l10n/app_localizations.dart';
import '../../providers/wallet_provider.dart';
import '../../core/constants/colors.dart';
import '../../core/constants/dimensions.dart';
import '../../core/models/proof.dart';
@@ -11,6 +13,7 @@ import '../../widgets/common/gradient_background.dart';
import '../../widgets/common/glass_card.dart';
import '../../widgets/common/primary_button.dart';
import '../../widgets/proof/proof_selector.dart';
import '../../core/utils/keyset_debug.dart';
import 'share_token_screen.dart';
/// Pantalla para enviar tokens de forma offline seleccionando proofs manualmente.
@@ -32,6 +35,13 @@ class _OfflineSendScreenState extends State<OfflineSendScreen> {
final TextEditingController _memoController = TextEditingController();
final ProofService _proofService = ProofService();
// Capturado en initState para poder persistir el PendingSend incluso si
// el widget se desmonta durante el await de markProofsPendingSpent.
// Usar `context.read` después del await es inseguro (widget puede no
// existir), y gatear con `mounted` perdería el record — los proofs
// quedan marcados PENDING_SPENT pero sin handle para reclamar.
late final WalletProvider _walletProvider;
List<LocalProof> _availableProofs = [];
Set<String> _selectedIds = {};
bool _isLoading = true;
@@ -41,6 +51,7 @@ class _OfflineSendScreenState extends State<OfflineSendScreen> {
@override
void initState() {
super.initState();
_walletProvider = context.read<WalletProvider>();
_loadProofs();
}
@@ -325,6 +336,21 @@ class _OfflineSendScreenState extends State<OfflineSendScreen> {
Future<void> _createOfflineToken() async {
final selectedProofs = _selectedProofs;
final selectedTotal = _proofService.calculateTotal(selectedProofs);
// Validar monto mínimo viable para el receptor
// Leer ppk del keyset local, fallback a 100 (estándar)
var ppk = await KeysetDebug.getInputFeePpk(widget.mintUrl, widget.unit);
if (ppk < 0) ppk = 100; // -1 = error leyendo DB, asumir fee estándar
final minReceiveFee = ppk > 0 ? BigInt.from((ppk + 999) ~/ 1000) : BigInt.zero;
if (selectedTotal <= minReceiveFee) {
if (mounted) {
setState(() {
_errorMessage = L10n.of(context)!.feeExceedsAmount;
});
}
return;
}
var proofsMarkedPending = false;
setState(() {
@@ -349,6 +375,20 @@ class _OfflineSendScreenState extends State<OfflineSendScreen> {
);
final token = cdkToken.encoded;
// Rastrear el envío offline como pendiente para poder cancelarlo
// desde el historial. Los offline sends no crean CDK Transaction.
// Usamos el provider capturado en initState (no context.read) para
// que el record se persista aunque el widget se haya desmontado
// mientras esperábamos markProofsPendingSpent.
await _walletProvider.addPendingSend(
encoded: token,
amount: selectedTotal,
mintUrl: widget.mintUrl,
unit: widget.unit,
proofYs: selectedProofs.map((p) => p.yHex).toList(),
memo: memo,
);
if (mounted) {
_isCreating = false;
Navigator.pushReplacement(
+60 -44
View File
@@ -1,3 +1,4 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:http/http.dart' as http;
@@ -7,9 +8,12 @@ import 'package:elcaju/l10n/app_localizations.dart';
import '../../core/constants/colors.dart';
import '../../core/constants/dimensions.dart';
import '../../core/utils/formatters.dart';
import '../../core/utils/keyset_debug.dart';
import '../../widgets/common/gradient_background.dart';
import '../../widgets/common/glass_card.dart';
import '../../widgets/common/primary_button.dart';
import '../../widgets/common/secondary_button.dart';
import '../../widgets/common/bottom_sheet_container.dart';
import '../../widgets/common/numpad_widget.dart';
import '../../providers/wallet_provider.dart';
import '../../core/utils/nostr_utils.dart';
@@ -572,11 +576,32 @@ class _SendScreenState extends State<SendScreen> {
});
return;
}
// Offline: verificar monto mínimo con ppk del keyset local (fallback 100)
var ppk = await KeysetDebug.getInputFeePpk(mintUrl, _activeUnit);
if (ppk < 0) ppk = 100;
final minReceiveFee = ppk > 0 ? BigInt.from((ppk + 999) ~/ 1000) : BigInt.zero;
if (!mounted) return;
if (_amount <= minReceiveFee) {
setState(() {
_errorMessage = L10n.of(context)!.feeExceedsAmount;
});
return;
}
// Offline: ir directo a selección de monedas
_goToOfflineModeWithMessage();
return;
}
// Online: verificar monto mínimo con ppk real del mint
final minReceiveFee = await _getMinReceiveFee(mintUrl);
if (!mounted) return;
if (_amount <= minReceiveFee) {
setState(() {
_errorMessage = L10n.of(context)!.feeExceedsAmount;
});
return;
}
// Online: mostrar modal de confirmación normal
showModalBottomSheet(
context: context,
@@ -595,6 +620,32 @@ class _SendScreenState extends State<SendScreen> {
);
}
/// Calcula el fee mínimo que el receptor pagará al reclamar el token.
/// Consulta /v1/keysets del mint para obtener el ppk real.
/// Fallback: ppk=100 (estándar) → fee mínimo = 1 sat.
Future<BigInt> _getMinReceiveFee(String mintUrl) async {
try {
final response = await http.get(
Uri.parse('$mintUrl/v1/keysets'),
).timeout(const Duration(seconds: 3));
if (response.statusCode == 200) {
final data = json.decode(response.body) as Map<String, dynamic>;
final keysets = data['keysets'] as List<dynamic>? ?? [];
for (final ks in keysets) {
if (ks['active'] == true && ks['unit'] == _activeUnit) {
final ppk = (ks['input_fee_ppk'] as num?)?.toInt() ?? 0;
if (ppk > 0) {
return BigInt.from((ppk + 999) ~/ 1000); // ceil(ppk / 1000)
}
return BigInt.zero;
}
}
}
} catch (_) {}
// Fallback: ppk=100 estándar
return BigInt.one;
}
/// Verifica conectividad haciendo petición HTTP real al mint.
Future<bool> _checkConnectivity(String mintUrl) async {
try {
@@ -748,29 +799,12 @@ class _ConfirmationModal extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(AppDimensions.paddingMedium),
decoration: BoxDecoration(
color: AppColors.deepVoidPurple,
borderRadius: const BorderRadius.vertical(top: Radius.circular(24)),
border: Border.all(
color: Colors.white.withValues(alpha: 0.1),
width: 1,
),
),
final l10n = L10n.of(context)!;
return BottomSheetContainer(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Handle
Container(
margin: const EdgeInsets.only(bottom: 16),
width: 40,
height: 4,
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.3),
borderRadius: BorderRadius.circular(2),
),
),
const BottomSheetHandle(),
// Icono
Container(
@@ -790,7 +824,7 @@ class _ConfirmationModal extends StatelessWidget {
// Titulo
Text(
L10n.of(context)!.confirmSend,
l10n.confirmSend,
style: const TextStyle(
fontFamily: 'Inter',
fontSize: 20,
@@ -831,40 +865,22 @@ class _ConfirmationModal extends StatelessWidget {
Row(
children: [
Expanded(
child: GestureDetector(
onTap: onCancel,
child: Container(
padding: const EdgeInsets.symmetric(vertical: 16),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(16),
),
child: Center(
child: Text(
L10n.of(context)!.cancel,
style: const TextStyle(
fontFamily: 'Inter',
fontSize: 16,
fontWeight: FontWeight.w600,
color: Colors.white,
),
),
),
),
child: SecondaryButton(
text: l10n.cancel,
onPressed: onCancel,
height: 52,
),
),
const SizedBox(width: AppDimensions.paddingMedium),
Expanded(
child: PrimaryButton(
text: L10n.of(context)!.confirm,
text: l10n.confirm,
onPressed: onConfirm,
height: 52,
),
),
],
),
const SizedBox(height: AppDimensions.paddingSmall),
],
),
);
+23 -45
View File
@@ -9,6 +9,8 @@ import '../../core/services/lnurl_service.dart';
import '../../widgets/common/gradient_background.dart';
import '../../widgets/common/glass_card.dart';
import '../../widgets/common/primary_button.dart';
import '../../widgets/common/secondary_button.dart';
import '../../widgets/common/bottom_sheet_container.dart';
import '../../widgets/common/numpad_widget.dart';
import '../../providers/wallet_provider.dart';
import '../../providers/price_provider.dart';
@@ -404,6 +406,8 @@ class _AmountScreenState extends State<AmountScreen> {
Future<void> _processPayment() async {
if (!_canPay) return;
final walletProvider = context.read<WalletProvider>();
setState(() {
_isProcessing = true;
_errorMessage = null;
@@ -431,14 +435,22 @@ class _AmountScreenState extends State<AmountScreen> {
if (!mounted) return;
// 3. Obtener quote del mint (en la unidad del mint)
final walletProvider = context.read<WalletProvider>();
final quote = await walletProvider.getMeltQuote(invoiceResult.invoice);
if (!mounted) return;
final total = quote.amount + quote.feeReserve;
// 4. Verificar balance suficiente
// 4a. Verificar que el fee no supere el monto (operación inviable)
if (quote.amount <= quote.feeReserve) {
setState(() {
_isProcessing = false;
_errorMessage = L10n.of(context)!.feeExceedsAmount;
});
return;
}
// 4b. Verificar balance suficiente
if (total > _availableBalance) {
setState(() {
_isProcessing = false;
@@ -506,32 +518,16 @@ class _AmountScreenState extends State<AmountScreen> {
Future<bool> _showConfirmation(BigInt amount, BigInt fee, BigInt total) async {
final unitLabel = UnitFormatter.getUnitLabel(_activeUnit);
final l10n = L10n.of(context)!;
final result = await showModalBottomSheet<bool>(
context: context,
backgroundColor: Colors.transparent,
builder: (context) => Container(
padding: const EdgeInsets.all(AppDimensions.paddingMedium),
decoration: BoxDecoration(
color: AppColors.deepVoidPurple,
borderRadius: const BorderRadius.vertical(top: Radius.circular(24)),
border: Border.all(
color: Colors.white.withValues(alpha: 0.1),
),
),
builder: (context) => BottomSheetContainer(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Handle
Container(
margin: const EdgeInsets.only(bottom: 16),
width: 40,
height: 4,
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.3),
borderRadius: BorderRadius.circular(2),
),
),
const BottomSheetHandle(),
// Icono
Container(
@@ -551,7 +547,7 @@ class _AmountScreenState extends State<AmountScreen> {
// Título
Text(
L10n.of(context)!.confirmPayment,
l10n.confirmPayment,
style: const TextStyle(
fontFamily: 'Inter',
fontSize: 20,
@@ -596,40 +592,22 @@ class _AmountScreenState extends State<AmountScreen> {
Row(
children: [
Expanded(
child: GestureDetector(
onTap: () => Navigator.pop(context, false),
child: Container(
padding: const EdgeInsets.symmetric(vertical: 16),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(16),
),
child: Center(
child: Text(
L10n.of(context)!.cancel,
style: const TextStyle(
fontFamily: 'Inter',
fontSize: 16,
fontWeight: FontWeight.w600,
color: Colors.white,
),
),
),
),
child: SecondaryButton(
text: l10n.cancel,
onPressed: () => Navigator.pop(context, false),
height: 52,
),
),
const SizedBox(width: AppDimensions.paddingMedium),
Expanded(
child: PrimaryButton(
text: L10n.of(context)!.pay,
text: l10n.pay,
onPressed: () => Navigator.pop(context, true),
height: 52,
),
),
],
),
const SizedBox(height: AppDimensions.paddingSmall),
],
),
),
+11 -44
View File
@@ -12,6 +12,8 @@ import '../../core/services/lnurl_service.dart';
import '../../widgets/common/gradient_background.dart';
import '../../widgets/common/glass_card.dart';
import '../../widgets/common/primary_button.dart';
import '../../widgets/common/secondary_button.dart';
import '../../widgets/common/bottom_sheet_container.dart';
import '../../providers/wallet_provider.dart';
import '../10_scanner/scan_screen.dart';
import 'amount_screen.dart';
@@ -858,29 +860,12 @@ class _ConfirmationModal extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(AppDimensions.paddingMedium),
decoration: BoxDecoration(
color: AppColors.deepVoidPurple,
borderRadius: const BorderRadius.vertical(top: Radius.circular(24)),
border: Border.all(
color: Colors.white.withValues(alpha: 0.1),
width: 1,
),
),
final l10n = L10n.of(context)!;
return BottomSheetContainer(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Handle
Container(
margin: const EdgeInsets.only(bottom: 16),
width: 40,
height: 4,
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.3),
borderRadius: BorderRadius.circular(2),
),
),
const BottomSheetHandle(),
// Icono
Container(
@@ -900,7 +885,7 @@ class _ConfirmationModal extends StatelessWidget {
// Título
Text(
L10n.of(context)!.confirmPayment,
l10n.confirmPayment,
style: const TextStyle(
fontFamily: 'Inter',
fontSize: 20,
@@ -945,40 +930,22 @@ class _ConfirmationModal extends StatelessWidget {
Row(
children: [
Expanded(
child: GestureDetector(
onTap: onCancel,
child: Container(
padding: const EdgeInsets.symmetric(vertical: 16),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(16),
),
child: Center(
child: Text(
L10n.of(context)!.cancel,
style: const TextStyle(
fontFamily: 'Inter',
fontSize: 16,
fontWeight: FontWeight.w600,
color: Colors.white,
),
),
),
),
child: SecondaryButton(
text: l10n.cancel,
onPressed: onCancel,
height: 52,
),
),
const SizedBox(width: AppDimensions.paddingMedium),
Expanded(
child: PrimaryButton(
text: L10n.of(context)!.pay,
text: l10n.pay,
onPressed: onConfirm,
height: 52,
),
),
],
),
const SizedBox(height: AppDimensions.paddingSmall),
],
),
);
+144
View File
@@ -0,0 +1,144 @@
import 'package:flutter/material.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:elcaju/l10n/app_localizations.dart';
import '../../core/constants/colors.dart';
/// Muestra el diálogo "Acerca de" de ElCaju
void showElCajuAboutDialog(BuildContext context, String appVersion) {
showDialog(
context: context,
builder: (context) => AlertDialog(
backgroundColor: AppColors.deepVoidPurple,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Logo
Container(
width: 80,
height: 80,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(20),
),
child: ClipRRect(
borderRadius: BorderRadius.circular(20),
child: Image.asset(
'assets/img/elcajucubano.png',
fit: BoxFit.cover,
),
),
),
const SizedBox(height: 16),
const Text(
'El Caju',
style: TextStyle(
fontFamily: 'Inter',
fontSize: 24,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
const SizedBox(height: 4),
Text(
'v$appVersion',
style: TextStyle(
fontFamily: 'Inter',
fontSize: 14,
color: AppColors.textSecondary,
),
),
const SizedBox(height: 16),
Text(
L10n.of(context)!.aboutTagline,
style: TextStyle(
fontFamily: 'Inter',
fontSize: 16,
color: AppColors.textSecondary,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
_buildAboutDescription(context),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(
L10n.of(context)!.close,
style: const TextStyle(
fontFamily: 'Inter',
color: AppColors.textSecondary,
),
),
),
],
),
);
}
Widget _buildAboutDescription(BuildContext context) {
final description = L10n.of(context)!.aboutDescription;
const keyword = 'LaChispa';
final index = description.indexOf(keyword);
if (index == -1) {
return Text(
description,
style: TextStyle(
fontFamily: 'Inter',
fontSize: 14,
color: AppColors.textSecondary.withValues(alpha: 0.7),
),
textAlign: TextAlign.center,
);
}
final before = description.substring(0, index);
final after = description.substring(index + keyword.length);
return RichText(
textAlign: TextAlign.center,
text: TextSpan(
style: TextStyle(
fontFamily: 'Inter',
fontSize: 14,
color: AppColors.textSecondary.withValues(alpha: 0.7),
),
children: [
TextSpan(text: before),
WidgetSpan(
alignment: PlaceholderAlignment.baseline,
baseline: TextBaseline.alphabetic,
child: GestureDetector(
onTap: () => _openLaChispa(context),
child: Text(
keyword,
style: TextStyle(
fontFamily: 'Inter',
fontSize: 14,
color: AppColors.secondaryAction,
decoration: TextDecoration.underline,
decorationColor: AppColors.secondaryAction,
),
),
),
),
TextSpan(text: after),
],
),
);
}
Future<void> _openLaChispa(BuildContext context) async {
final url = Uri.parse('https://app.lachispa.me');
try {
await launchUrl(url, mode: LaunchMode.externalApplication);
} catch (_) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(L10n.of(context)!.couldNotOpenLink),
backgroundColor: AppColors.error,
),
);
}
}
}
@@ -0,0 +1,240 @@
import 'package:flutter/material.dart';
import 'package:lucide_icons/lucide_icons.dart';
import 'package:elcaju/l10n/app_localizations.dart';
import '../../core/constants/colors.dart';
import '../../core/constants/dimensions.dart';
import '../../widgets/common/bottom_sheet_container.dart';
import '../../widgets/common/secondary_button.dart';
/// Modal para confirmar borrado de wallet
class DeleteWalletModal extends StatefulWidget {
final VoidCallback onConfirm;
final VoidCallback onCancel;
const DeleteWalletModal({
super.key,
required this.onConfirm,
required this.onCancel,
});
@override
State<DeleteWalletModal> createState() => _DeleteWalletModalState();
}
class _DeleteWalletModalState extends State<DeleteWalletModal> {
final TextEditingController _controller = TextEditingController();
bool _canDelete = false;
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final l10n = L10n.of(context)!;
final confirmWord = l10n.deleteConfirmWord;
return BottomSheetContainer(
respectKeyboard: true,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const BottomSheetHandle(),
// Icono advertencia
Container(
width: 64,
height: 64,
decoration: BoxDecoration(
color: AppColors.error.withValues(alpha: 0.2),
shape: BoxShape.circle,
),
child: Icon(
LucideIcons.alertTriangle,
color: AppColors.error,
size: 32,
),
),
const SizedBox(height: AppDimensions.paddingMedium),
// Título
Text(
l10n.deleteWalletQuestion,
style: const TextStyle(
fontFamily: 'Inter',
fontSize: 20,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
const SizedBox(height: AppDimensions.paddingSmall),
// Advertencia
Container(
margin: const EdgeInsets.symmetric(horizontal: 16),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: AppColors.error.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
),
child: Column(
children: [
Row(
children: [
Icon(
LucideIcons.alertCircle,
color: AppColors.error,
size: 16,
),
const SizedBox(width: 8),
Expanded(
child: Text(
l10n.actionIrreversible,
style: const TextStyle(
fontFamily: 'Inter',
fontSize: 14,
fontWeight: FontWeight.w600,
color: AppColors.error,
),
),
),
],
),
const SizedBox(height: 8),
Text(
l10n.deleteWalletWarning,
style: TextStyle(
fontFamily: 'Inter',
fontSize: 12,
color: AppColors.error.withValues(alpha: 0.8),
),
),
],
),
),
const SizedBox(height: AppDimensions.paddingMedium),
// Input de confirmación
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.typeDeleteToConfirm,
style: TextStyle(
fontFamily: 'Inter',
fontSize: 14,
color: AppColors.textSecondary,
),
),
const SizedBox(height: 8),
TextField(
controller: _controller,
style: const TextStyle(
fontFamily: 'Inter',
fontSize: 16,
color: Colors.white,
),
decoration: InputDecoration(
hintText: confirmWord,
hintStyle: TextStyle(
fontFamily: 'Inter',
fontSize: 16,
color: Colors.white.withValues(alpha: 0.3),
),
filled: true,
fillColor: Colors.white.withValues(alpha: 0.05),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
),
),
onChanged: (value) {
setState(() {
_canDelete = value == confirmWord;
});
},
),
],
),
),
const SizedBox(height: AppDimensions.paddingLarge),
// Botones
Row(
children: [
Expanded(
child: SecondaryButton(
text: l10n.cancel,
onPressed: widget.onCancel,
height: 52,
),
),
const SizedBox(width: AppDimensions.paddingMedium),
Expanded(
child: _DestructiveButton(
text: l10n.deleteWallet,
enabled: _canDelete,
onPressed: widget.onConfirm,
),
),
],
),
],
),
);
}
}
/// Botón destructivo con semantics accesibles.
/// Separado de los botones estándar porque la acción es irreversible.
class _DestructiveButton extends StatelessWidget {
final String text;
final bool enabled;
final VoidCallback onPressed;
const _DestructiveButton({
required this.text,
required this.enabled,
required this.onPressed,
});
@override
Widget build(BuildContext context) {
return Semantics(
button: true,
enabled: enabled,
label: text,
child: SizedBox(
height: 52,
child: Material(
color: enabled
? AppColors.error
: AppColors.error.withValues(alpha: 0.3),
borderRadius: BorderRadius.circular(16),
child: InkWell(
onTap: enabled ? onPressed : null,
borderRadius: BorderRadius.circular(16),
child: Center(
child: Text(
text,
style: TextStyle(
fontFamily: 'Inter',
fontSize: 16,
fontWeight: FontWeight.w600,
color: enabled
? Colors.white
: Colors.white.withValues(alpha: 0.5),
),
),
),
),
),
),
);
}
}
+26 -25
View File
@@ -275,52 +275,53 @@ class _MintsScreenState extends State<MintsScreen> {
);
}
/// Badges de balance compactos (estilo cashu.me)
/// Badges de balance compactos (estilo cashu.me).
/// Muestra todas las unidades soportadas por el mint: las de saldo > 0
/// con estilo activo y las de saldo 0 atenuadas, para reflejar las
/// capacidades del mint aunque aún no se hayan usado.
Widget _buildBalanceBadges(List<String> units, Map<String, BigInt> balances) {
final nonZeroBalances = balances.entries
.where((e) => e.value > BigInt.zero)
.toList();
final entries = balances.isNotEmpty
? balances.entries.toList()
: units.map((u) => MapEntry(u, BigInt.zero)).toList();
if (nonZeroBalances.isEmpty) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
),
child: Text(
'0 ${units.isNotEmpty ? units.first : "sat"}',
style: TextStyle(
fontFamily: 'Inter',
fontSize: 12,
color: Colors.white.withValues(alpha: 0.6),
),
),
);
if (entries.isEmpty) {
return const SizedBox.shrink();
}
entries.sort((a, b) {
final aZero = a.value == BigInt.zero;
final bZero = b.value == BigInt.zero;
if (aZero == bZero) return 0;
return aZero ? 1 : -1;
});
return Wrap(
spacing: 6,
runSpacing: 4,
children: nonZeroBalances.map((entry) {
children: entries.map((entry) {
final unit = entry.key;
final balance = entry.value;
final isZero = balance == BigInt.zero;
final formatted = UnitFormatter.formatBalance(balance, unit);
final label = UnitFormatter.getUnitLabel(unit);
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: AppColors.primaryAction.withValues(alpha: 0.15),
color: isZero
? Colors.white.withValues(alpha: 0.08)
: AppColors.primaryAction.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(8),
),
child: Text(
'$formatted $label',
style: const TextStyle(
style: TextStyle(
fontFamily: 'Inter',
fontSize: 12,
fontWeight: FontWeight.w500,
color: AppColors.primaryAction,
fontWeight: isZero ? FontWeight.w400 : FontWeight.w500,
color: isZero
? Colors.white.withValues(alpha: 0.5)
: AppColors.primaryAction,
),
),
);
+194
View File
@@ -0,0 +1,194 @@
import 'package:flutter/material.dart';
import 'package:lucide_icons/lucide_icons.dart';
import '../../core/constants/colors.dart';
/// Diálogo para ingresar PIN de 4 dígitos
class PinDialog extends StatefulWidget {
final String title;
final String subtitle;
const PinDialog({
super.key,
required this.title,
required this.subtitle,
});
@override
State<PinDialog> createState() => _PinDialogState();
}
class _PinDialogState extends State<PinDialog> {
final List<String> _pin = [];
@override
Widget build(BuildContext context) {
return AlertDialog(
backgroundColor: AppColors.deepVoidPurple,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)),
title: Text(
widget.title,
style: const TextStyle(
fontFamily: 'Inter',
fontSize: 20,
fontWeight: FontWeight.bold,
color: Colors.white,
),
textAlign: TextAlign.center,
),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
widget.subtitle,
style: TextStyle(
fontFamily: 'Inter',
fontSize: 14,
color: AppColors.textSecondary,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 24),
// Indicadores de PIN
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: List.generate(4, (index) {
final filled = index < _pin.length;
return Container(
margin: const EdgeInsets.symmetric(horizontal: 8),
width: 16,
height: 16,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: filled
? AppColors.primaryAction
: Colors.white.withValues(alpha: 0.2),
border: Border.all(
color: filled
? AppColors.primaryAction
: Colors.white.withValues(alpha: 0.3),
width: 2,
),
),
);
}),
),
const SizedBox(height: 24),
// Teclado numérico
_buildNumPad(),
],
),
);
}
Widget _buildNumPad() {
return Column(
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: ['1', '2', '3'].map((n) => _buildNumKey(n)).toList(),
),
const SizedBox(height: 12),
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: ['4', '5', '6'].map((n) => _buildNumKey(n)).toList(),
),
const SizedBox(height: 12),
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: ['7', '8', '9'].map((n) => _buildNumKey(n)).toList(),
),
const SizedBox(height: 12),
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
// Cancelar
GestureDetector(
onTap: () => Navigator.pop(context),
child: Container(
width: 60,
height: 60,
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.05),
borderRadius: BorderRadius.circular(30),
),
child: Icon(
LucideIcons.x,
color: AppColors.textSecondary,
size: 24,
),
),
),
_buildNumKey('0'),
// Borrar
GestureDetector(
onTap: _deleteDigit,
child: Container(
width: 60,
height: 60,
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.05),
borderRadius: BorderRadius.circular(30),
),
child: Icon(
LucideIcons.delete,
color: AppColors.textSecondary,
size: 24,
),
),
),
],
),
],
);
}
Widget _buildNumKey(String number) {
return GestureDetector(
onTap: () => _addDigit(number),
child: Container(
width: 60,
height: 60,
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(30),
),
child: Center(
child: Text(
number,
style: const TextStyle(
fontFamily: 'Inter',
fontSize: 24,
fontWeight: FontWeight.w600,
color: Colors.white,
),
),
),
),
);
}
void _addDigit(String digit) {
if (_pin.length < 4) {
setState(() {
_pin.add(digit);
});
if (_pin.length == 4) {
// PIN completo, cerrar con resultado
Future.delayed(const Duration(milliseconds: 200), () {
if (mounted) {
Navigator.pop(context, _pin.join());
}
});
}
}
}
void _deleteDigit() {
if (_pin.isNotEmpty) {
setState(() {
_pin.removeLast();
});
}
}
}
+131
View File
@@ -0,0 +1,131 @@
import 'package:flutter/material.dart';
import 'package:lucide_icons/lucide_icons.dart';
import 'package:elcaju/l10n/app_localizations.dart';
import '../../core/constants/colors.dart';
import '../../core/constants/dimensions.dart';
import '../../widgets/common/gradient_background.dart';
class PrivacyScreen extends StatelessWidget {
const PrivacyScreen({super.key});
@override
Widget build(BuildContext context) {
final l10n = L10n.of(context)!;
final conclusionLines = l10n.privacyConclusion
.split('\n')
.map((s) => s.trim())
.where((s) => s.isNotEmpty)
.toList();
return GradientBackground(
child: Scaffold(
backgroundColor: Colors.transparent,
appBar: AppBar(
backgroundColor: Colors.transparent,
elevation: 0,
leading: IconButton(
icon: const Icon(LucideIcons.arrowLeft, color: Colors.white),
onPressed: () => Navigator.pop(context),
),
title: Text(
l10n.privacyPolicy,
style: const TextStyle(
fontFamily: 'Inter',
fontWeight: FontWeight.w600,
color: Colors.white,
),
),
),
body: SafeArea(
child: LayoutBuilder(
builder: (context, constraints) => SingleChildScrollView(
padding: const EdgeInsets.all(AppDimensions.paddingLarge),
child: ConstrainedBox(
constraints: BoxConstraints(minHeight: constraints.maxHeight),
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
l10n.privacyTitle,
style: const TextStyle(
fontFamily: 'Inter',
fontSize: 24,
fontWeight: FontWeight.bold,
color: Colors.white,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 12),
Text(
l10n.privacyGoodbye,
style: const TextStyle(
fontFamily: 'Inter',
fontSize: 24,
fontWeight: FontWeight.w800,
color: AppColors.primaryAction,
),
),
const SizedBox(height: 40),
Text(
l10n.privacyKeepReading,
style: TextStyle(
fontFamily: 'Inter',
fontSize: 13,
fontStyle: FontStyle.italic,
color: AppColors.textSecondary.withValues(alpha: 0.5),
),
),
const SizedBox(height: 28),
...l10n.privacyBody.split('\n')
.map((s) => s.trim())
.where((s) => s.isNotEmpty)
.map(
(line) => Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Text(
line,
style: TextStyle(
fontFamily: 'Inter',
fontSize: 16,
color: AppColors.textSecondary.withValues(alpha: 0.85),
),
textAlign: TextAlign.center,
),
),
),
const SizedBox(height: 28),
...conclusionLines.asMap().entries.map((entry) {
final isLast = entry.key == conclusionLines.length - 1;
return Padding(
padding: EdgeInsets.only(bottom: isLast ? 0 : 8),
child: Text(
entry.value,
style: isLast
? const TextStyle(
fontFamily: 'Inter',
fontSize: 18,
fontWeight: FontWeight.w700,
color: Colors.white,
)
: TextStyle(
fontFamily: 'Inter',
fontSize: 15,
fontWeight: FontWeight.w500,
color: AppColors.textSecondary.withValues(alpha: 0.7),
),
textAlign: TextAlign.center,
),
);
}),
],
),
),
),
),
),
),
),
);
}
}
@@ -0,0 +1,704 @@
import 'package:flutter/material.dart';
import 'package:lucide_icons/lucide_icons.dart';
import 'package:provider/provider.dart';
import 'package:elcaju/l10n/app_localizations.dart';
import '../../core/constants/colors.dart';
import '../../core/constants/dimensions.dart';
import '../../core/utils/formatters.dart';
import '../../providers/wallet_provider.dart';
import '../../widgets/common/bottom_sheet_container.dart';
/// Modal para recuperar tokens usando NUT-13
class RecoverTokensModal extends StatefulWidget {
const RecoverTokensModal({super.key});
@override
State<RecoverTokensModal> createState() => _RecoverTokensModalState();
}
class _RecoverTokensModalState extends State<RecoverTokensModal> {
final TextEditingController _mnemonicController = TextEditingController();
bool _useCurrentMnemonic = true;
bool _scanAllMints = true;
String? _selectedMintUrl;
List<String> _availableMints = [];
bool _isLoading = false;
bool _isLoadingMints = true;
String? _result;
bool _isSuccess = false;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) => _loadMints());
}
void _loadMints() {
final walletProvider = context.read<WalletProvider>();
setState(() {
_availableMints = walletProvider.mintUrls;
_isLoadingMints = false;
if (_availableMints.isNotEmpty) {
_selectedMintUrl = _availableMints.first;
}
});
}
@override
void dispose() {
_mnemonicController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return BottomSheetContainer(
respectKeyboard: true,
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const BottomSheetHandle(),
// Icono
Container(
width: 64,
height: 64,
decoration: BoxDecoration(
color: AppColors.primaryAction.withValues(alpha: 0.2),
shape: BoxShape.circle,
),
child: Icon(
LucideIcons.refreshCw,
color: AppColors.primaryAction,
size: 32,
),
),
const SizedBox(height: AppDimensions.paddingMedium),
// Título
Text(
L10n.of(context)!.recoverTokensTitle,
style: const TextStyle(
fontFamily: 'Inter',
fontSize: 20,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
const SizedBox(height: AppDimensions.paddingSmall),
// Descripción
Text(
L10n.of(context)!.recoverTokensDescription,
style: TextStyle(
fontFamily: 'Inter',
fontSize: 14,
color: AppColors.textSecondary,
),
textAlign: TextAlign.center,
),
const SizedBox(height: AppDimensions.paddingLarge),
// Opciones de mnemonic
_buildMnemonicOptions(),
const SizedBox(height: AppDimensions.paddingMedium),
// Selector de mint (solo si usa mnemonic actual)
if (_useCurrentMnemonic) _buildMintSelector(),
// Input para mnemonic personalizado
if (!_useCurrentMnemonic) _buildMnemonicInput(),
// Resultado
if (_result != null) _buildResult(),
const SizedBox(height: AppDimensions.paddingMedium),
// Botón de acción
_buildActionButton(),
const SizedBox(height: AppDimensions.paddingSmall),
],
),
),
);
}
Widget _buildMnemonicOptions() {
final l10n = L10n.of(context)!;
return Column(
children: [
// Opción: Usar mnemonic actual
GestureDetector(
onTap: () => setState(() => _useCurrentMnemonic = true),
child: Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: _useCurrentMnemonic
? AppColors.primaryAction.withValues(alpha: 0.2)
: Colors.white.withValues(alpha: 0.05),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: _useCurrentMnemonic
? AppColors.primaryAction.withValues(alpha: 0.5)
: Colors.transparent,
),
),
child: Row(
children: [
Icon(
_useCurrentMnemonic
? LucideIcons.checkCircle
: LucideIcons.circle,
color: _useCurrentMnemonic
? AppColors.primaryAction
: AppColors.textSecondary,
size: 20,
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.useCurrentSeedPhrase,
style: const TextStyle(
fontFamily: 'Inter',
fontSize: 16,
fontWeight: FontWeight.w500,
color: Colors.white,
),
),
Text(
l10n.scanWithSavedWords,
style: TextStyle(
fontFamily: 'Inter',
fontSize: 12,
color: AppColors.textSecondary,
),
),
],
),
),
],
),
),
),
const SizedBox(height: 8),
// Opción: Usar otro mnemonic
GestureDetector(
onTap: () => setState(() => _useCurrentMnemonic = false),
child: Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: !_useCurrentMnemonic
? AppColors.primaryAction.withValues(alpha: 0.2)
: Colors.white.withValues(alpha: 0.05),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: !_useCurrentMnemonic
? AppColors.primaryAction.withValues(alpha: 0.5)
: Colors.transparent,
),
),
child: Row(
children: [
Icon(
!_useCurrentMnemonic
? LucideIcons.checkCircle
: LucideIcons.circle,
color: !_useCurrentMnemonic
? AppColors.primaryAction
: AppColors.textSecondary,
size: 20,
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.useOtherSeedPhrase,
style: const TextStyle(
fontFamily: 'Inter',
fontSize: 16,
fontWeight: FontWeight.w500,
color: Colors.white,
),
),
Text(
l10n.recoverFromOtherWords,
style: TextStyle(
fontFamily: 'Inter',
fontSize: 12,
color: AppColors.textSecondary,
),
),
],
),
),
],
),
),
),
],
);
}
Widget _buildMintSelector() {
final l10n = L10n.of(context)!;
if (_isLoadingMints) {
return const Padding(
padding: EdgeInsets.all(16),
child: Center(
child: SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(
color: AppColors.primaryAction,
strokeWidth: 2,
),
),
),
);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.mintsToScan,
style: TextStyle(
fontFamily: 'Inter',
fontSize: 14,
fontWeight: FontWeight.w500,
color: AppColors.textSecondary,
),
),
const SizedBox(height: 8),
// Opción: Escanear todos
GestureDetector(
onTap: () => setState(() => _scanAllMints = true),
child: Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: _scanAllMints
? AppColors.primaryAction.withValues(alpha: 0.2)
: Colors.white.withValues(alpha: 0.05),
borderRadius: BorderRadius.circular(10),
border: Border.all(
color: _scanAllMints
? AppColors.primaryAction.withValues(alpha: 0.5)
: Colors.transparent,
),
),
child: Row(
children: [
Icon(
_scanAllMints ? LucideIcons.checkCircle : LucideIcons.circle,
color: _scanAllMints
? AppColors.primaryAction
: AppColors.textSecondary,
size: 18,
),
const SizedBox(width: 10),
Text(
l10n.allMints(_availableMints.length),
style: TextStyle(
fontFamily: 'Inter',
fontSize: 14,
color: Colors.white,
fontWeight: _scanAllMints ? FontWeight.w500 : FontWeight.normal,
),
),
],
),
),
),
const SizedBox(height: 8),
// Opción: Mint específico
GestureDetector(
onTap: () => setState(() => _scanAllMints = false),
child: Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: !_scanAllMints
? AppColors.primaryAction.withValues(alpha: 0.2)
: Colors.white.withValues(alpha: 0.05),
borderRadius: BorderRadius.circular(10),
border: Border.all(
color: !_scanAllMints
? AppColors.primaryAction.withValues(alpha: 0.5)
: Colors.transparent,
),
),
child: Row(
children: [
Icon(
!_scanAllMints ? LucideIcons.checkCircle : LucideIcons.circle,
color: !_scanAllMints
? AppColors.primaryAction
: AppColors.textSecondary,
size: 18,
),
const SizedBox(width: 10),
Text(
l10n.specificMint,
style: const TextStyle(
fontFamily: 'Inter',
fontSize: 14,
color: Colors.white,
),
),
],
),
),
),
// Dropdown de mints (solo si selecciona específico)
if (!_scanAllMints && _availableMints.isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: 8),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.05),
borderRadius: BorderRadius.circular(10),
border: Border.all(
color: Colors.white.withValues(alpha: 0.1),
),
),
child: DropdownButton<String>(
value: _selectedMintUrl,
isExpanded: true,
dropdownColor: AppColors.deepVoidPurple,
underline: const SizedBox(),
icon: Icon(
LucideIcons.chevronDown,
color: AppColors.textSecondary,
size: 20,
),
items: _availableMints.map((url) {
final host = Uri.parse(url).host;
return DropdownMenuItem(
value: url,
child: Text(
host,
style: const TextStyle(
fontFamily: 'Inter',
fontSize: 14,
color: Colors.white,
),
),
);
}).toList(),
onChanged: (value) {
if (value != null) {
setState(() => _selectedMintUrl = value);
}
},
),
),
),
],
);
}
Widget _buildMnemonicInput() {
return Padding(
padding: const EdgeInsets.only(top: AppDimensions.paddingMedium),
child: TextField(
controller: _mnemonicController,
autocorrect: false,
enableSuggestions: false,
textCapitalization: TextCapitalization.none,
maxLines: 3,
style: const TextStyle(
fontFamily: 'Inter',
fontSize: 14,
color: Colors.white,
),
decoration: InputDecoration(
hintText: L10n.of(context)!.enterMnemonicWords,
hintStyle: TextStyle(
fontFamily: 'Inter',
fontSize: 14,
color: Colors.white.withValues(alpha: 0.3),
),
filled: true,
fillColor: Colors.white.withValues(alpha: 0.05),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
),
contentPadding: const EdgeInsets.all(16),
),
),
);
}
Widget _buildResult() {
return Container(
margin: const EdgeInsets.only(top: AppDimensions.paddingMedium),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: _isSuccess
? AppColors.success.withValues(alpha: 0.1)
: AppColors.error.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: _isSuccess
? AppColors.success.withValues(alpha: 0.3)
: AppColors.error.withValues(alpha: 0.3),
),
),
child: Row(
children: [
Icon(
_isSuccess ? LucideIcons.checkCircle : LucideIcons.alertCircle,
color: _isSuccess ? AppColors.success : AppColors.error,
size: 20,
),
const SizedBox(width: 12),
Expanded(
child: Text(
_result!,
style: TextStyle(
fontFamily: 'Inter',
fontSize: 14,
color: _isSuccess ? AppColors.success : AppColors.error,
),
),
),
],
),
);
}
Widget _buildActionButton() {
return SizedBox(
width: double.infinity,
child: GestureDetector(
onTap: _isLoading ? null : _startRecover,
child: Container(
padding: const EdgeInsets.symmetric(vertical: 16),
decoration: BoxDecoration(
gradient: _isLoading
? null
: const LinearGradient(
colors: [AppColors.primaryAction, Color(0xFFFF9100)],
),
color: _isLoading ? Colors.grey : null,
borderRadius: BorderRadius.circular(16),
),
child: Center(
child: _isLoading
? const SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(
color: Colors.white,
strokeWidth: 2,
),
)
: Text(
L10n.of(context)!.scanMints,
style: const TextStyle(
fontFamily: 'Inter',
fontSize: 16,
fontWeight: FontWeight.w600,
color: Colors.white,
),
),
),
),
),
);
}
Future<void> _startRecover() async {
if (!mounted) return;
final l10n = L10n.of(context)!;
setState(() {
_isLoading = true;
_result = null;
});
try {
final walletProvider = context.read<WalletProvider>();
if (_useCurrentMnemonic) {
// Usar mnemonic actual
if (_scanAllMints) {
// Escanear todos los mints (retorna Map<String, Map<String, BigInt>>)
final results = await walletProvider.restoreAllMints();
int mintsRecovered = 0;
int mintsWithError = 0;
final recoveredDetails = <String>[];
for (final mintEntry in results.entries) {
final unitBalances = mintEntry.value;
bool hasError = false;
bool hasRecovered = false;
for (final unitEntry in unitBalances.entries) {
final unit = unitEntry.key;
final balance = unitEntry.value;
if (balance < BigInt.zero) {
hasError = true;
} else if (balance > BigInt.zero) {
final formatted = UnitFormatter.formatBalance(balance, unit);
final label = UnitFormatter.getUnitLabel(unit);
recoveredDetails.add('$formatted $label');
hasRecovered = true;
}
}
if (hasError) mintsWithError++;
if (hasRecovered) mintsRecovered++;
}
// Recuperar proofs pendientes (sends no reclamados, melts fallidos)
final reclaimMap = await walletProvider.reclaimPendingProofs();
if (!mounted) return;
// Agregar cada unidad reclamada por separado
if (reclaimMap.isNotEmpty) {
for (final entry in reclaimMap.entries) {
final unit = entry.key;
final reclaim = entry.value;
final formatted = UnitFormatter.formatBalance(reclaim.amount, unit);
final label = UnitFormatter.getUnitLabel(unit);
recoveredDetails.add('$formatted $label (${reclaim.count} proofs)');
}
mintsRecovered++;
}
setState(() {
_isSuccess = true;
if (recoveredDetails.isNotEmpty) {
_result = l10n.recoveredTokens(recoveredDetails.join(", "), mintsRecovered);
} else {
_result = l10n.scanCompleteNoTokens;
}
if (mintsWithError > 0) {
_result = '$_result ${l10n.mintsWithError(mintsWithError)}';
}
});
} else {
// Escanear mint específico (retorna Map<String, BigInt>)
if (_selectedMintUrl == null) {
if (!mounted) return;
setState(() {
_isSuccess = false;
_result = l10n.selectMintToScan;
});
return;
}
final unitBalances = await walletProvider.restoreFromMint(_selectedMintUrl!);
final mintHost = UnitFormatter.getMintDisplayName(_selectedMintUrl!);
final recoveredDetails = <String>[];
for (final entry in unitBalances.entries) {
final unit = entry.key;
final balance = entry.value;
if (balance > BigInt.zero) {
final formatted = UnitFormatter.formatBalance(balance, unit);
final label = UnitFormatter.getUnitLabel(unit);
recoveredDetails.add('$formatted $label');
}
}
// Recuperar proofs pendientes de este mint
final reclaimMap = await walletProvider.reclaimPendingProofs(mintUrl: _selectedMintUrl);
if (!mounted) return;
// Agregar cada unidad reclamada por separado
for (final entry in reclaimMap.entries) {
final unit = entry.key;
final reclaim = entry.value;
final formatted = UnitFormatter.formatBalance(reclaim.amount, unit);
final label = UnitFormatter.getUnitLabel(unit);
recoveredDetails.add('$formatted $label (${reclaim.count} proofs)');
}
setState(() {
_isSuccess = true;
if (recoveredDetails.isNotEmpty) {
_result = l10n.recoveredFromMint(recoveredDetails.join(", "), mintHost);
} else {
_result = l10n.noTokensFoundInMint(mintHost);
}
});
}
} else {
// Usar otro mnemonic
final mnemonic = _mnemonicController.text.trim().toLowerCase();
final words = mnemonic.split(RegExp(r'\s+'));
if (words.length != 12 && words.length != 24) {
if (!mounted) return;
setState(() {
_isSuccess = false;
_result = l10n.mnemonicMustHaveWords;
});
return;
}
// Obtener lista de mints actuales para escanear
final mintUrls = walletProvider.mintUrls;
if (mintUrls.isEmpty) {
if (!mounted) return;
setState(() {
_isSuccess = false;
_result = l10n.noConnectedMintsToScan;
});
return;
}
final recovered = await walletProvider.restoreWithMnemonic(
mnemonic,
mintUrls,
);
if (!mounted) return;
setState(() {
_isSuccess = true;
if (recovered > BigInt.zero) {
// Usamos la unidad activa como aproximación para el formato
final activeUnit = walletProvider.activeUnit;
final formatted = UnitFormatter.formatBalance(recovered, activeUnit);
final label = UnitFormatter.getUnitLabel(activeUnit);
_result = l10n.recoveredAndTransferred(formatted, label);
} else {
_result = l10n.noTokensForMnemonic;
}
});
}
} catch (e) {
if (!mounted) return;
setState(() {
_isSuccess = false;
_result = '${l10n.error}: $e';
});
} finally {
if (mounted) {
setState(() {
_isLoading = false;
});
}
}
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+108 -6
View File
@@ -10,9 +10,29 @@ import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated.dart';
import 'payment_request.dart';
import 'token.dart';
// These functions are ignored because they are not marked as `pub`: `mint_url`, `unit`, `update_balance_streams`
// These functions are ignored because they are not marked as `pub`: `empty`, `from_states`, `into_result`, `mint_url`, `unit`, `update_balance_streams`
// These types are ignored because they are neither used by any `pub` functions nor (for structs and enums) marked `#[frb(unignore)]`: `StateBuckets`
// These function are ignored because they are on traits that is not defined in current crate (put an empty `#[frb]` on it to unignore): `assert_receiver_is_total_eq`, `assert_receiver_is_total_eq`, `assert_receiver_is_total_eq`, `clone`, `clone`, `clone`, `clone`, `cmp`, `eq`, `eq`, `eq`, `fmt`, `fmt`, `from`, `from`, `from`, `from`, `from`, `from`, `into`, `partial_cmp`, `try_into`, `try_into`
// Rust type: RustOpaqueMoi<flutter_rust_bridge::for_generated::RustAutoOpaqueInner<PreparedMelt>>
abstract class PreparedMelt implements RustOpaqueInterface {
BigInt get amount;
BigInt get feeReserve;
BigInt get inputFee;
BigInt get swapFee;
set amount(BigInt amount);
set feeReserve(BigInt feeReserve);
set inputFee(BigInt inputFee);
set swapFee(BigInt swapFee);
}
// Rust type: RustOpaqueMoi<flutter_rust_bridge::for_generated::RustAutoOpaqueInner<PreparedSend>>
abstract class PreparedSend implements RustOpaqueInterface {
BigInt get amount;
@@ -44,12 +64,34 @@ abstract class Wallet implements RustOpaqueInterface {
Future<BigInt> balance();
Future<void> cancelMelt({required PreparedMelt melt});
Future<void> cancelSend({required PreparedSend send});
Future<void> checkAllMintQuotes();
Future<void> checkPendingTransactions();
/// Observe-only counterpart of `reclaim_proofs_by_ys`. Queries the mint
/// for the state of the target Ys but **does NOT** revert Unspent proofs
/// to the local Unspent state.
///
/// Use this for periodic reconciliation where we want to auto-settle
/// sends the receiver already claimed, without accidentally cancelling
/// sends the receiver has not claimed yet (which `reclaim_proofs_by_ys`
/// would do by calling `unreserve_proofs`).
///
/// Return semantics:
/// - `count` / `amount` always 0 (nothing is recovered).
/// - `pending_count` / `spent_count` reflect the mint's report.
/// - `spent_count` also includes target Ys that are no longer present
/// in local PendingSpent (already reconciled or externally cleaned).
/// The caller can infer "all resolved as spent" from
/// `spent_count == target_ys.len() && pending_count == 0`.
Future<ReclaimResult> checkProofsByYs({required List<String> ys});
Future<BigInt> confirmMelt({required PreparedMelt melt});
/// Create a NUT-18 payment request with Nostr transport.
///
/// Builds a PaymentRequest with the wallet's mint URL and unit,
@@ -67,8 +109,6 @@ abstract class Wallet implements RustOpaqueInterface {
Future<List<Transaction>> listTransactions({TransactionDirection? direction});
Future<BigInt> melt({required MeltQuote quote});
Future<MeltQuote> meltQuote({required String request});
Stream<MintQuote> mint({required BigInt amount, String? description});
@@ -97,16 +137,35 @@ abstract class Wallet implements RustOpaqueInterface {
BigInt? customAmount,
});
Future<PreparedMelt> prepareMelt({required MeltQuote quote});
Future<PreparedSend> prepareSend({required BigInt amount, SendOptions? opts});
Future<BigInt> receive({required Token token, ReceiveOptions? opts});
/// Check pending-spent proofs with the mint and revert unspent ones.
/// Returns the number of proofs recovered.
Future<BigInt> reclaimPendingProofs();
/// Returns per-state buckets (unspent reverted, still pending, spent).
Future<ReclaimResult> reclaimPendingProofs();
/// Reclaim proofs belonging to a specific send identified by its Y values.
/// Scoped variant of `reclaim_pending_proofs`: only proofs whose Y matches
/// the input list are checked with the mint and reverted if Unspent.
///
/// Empty returns:
/// - Input `ys` empty or all unparseable → `ReclaimResult::empty()`.
/// - None of the target Ys are in local pending anymore (a previous
/// reconciliation already processed them) → `spent_count` is set to
/// the number of targets, signalling to callers that the send is
/// definitively finished and any local record can be removed.
Future<ReclaimResult> reclaimProofsByYs({required List<String> ys});
Future<void> recoverIncompleteSagas();
/// Recalcula el balance desde la DB y lo empuja al stream.
/// Llamar después de manipulaciones directas al DB (ej: offline send
/// marking proofs as PendingSpent fuera de la API de CDK).
Future<void> refreshBalance();
Future<void> restore();
Future<SendResult> send({
@@ -182,7 +241,7 @@ class MintQuote {
final Token? token;
final String? error;
/// Deterministic transaction ID (set when state == Issued)
/// Deterministic transaction ID (set when proofs are available, typically on Issued)
final String? transactionId;
const MintQuote({
@@ -245,6 +304,49 @@ class ReceiveOptions {
preimages == other.preimages;
}
class ReclaimResult {
/// Proofs reverted from PendingSpent to Unspent. Also the number of
/// recovered proofs reflected in `amount`.
final BigInt count;
/// Total value of the recovered proofs.
final BigInt amount;
/// Proofs the mint reports as still Pending (receiver mid-swap). The
/// caller should keep the record for retry — a later reconciliation
/// will resolve them.
final BigInt pendingCount;
/// Proofs the mint reports as Spent (receiver already claimed). When
/// `count == 0 && pending_count == 0`, the send is definitively
/// finished and the caller can delete its local record.
final BigInt spentCount;
const ReclaimResult({
required this.count,
required this.amount,
required this.pendingCount,
required this.spentCount,
});
@override
int get hashCode =>
count.hashCode ^
amount.hashCode ^
pendingCount.hashCode ^
spentCount.hashCode;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is ReclaimResult &&
runtimeType == other.runtimeType &&
count == other.count &&
amount == other.amount &&
pendingCount == other.pendingCount &&
spentCount == other.spentCount;
}
class SendOptions {
final String? pubkey;
final bool? includeFee;
File diff suppressed because it is too large Load Diff
+123
View File
@@ -31,6 +31,10 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
get rust_arc_decrement_strong_count_NostrListenerHandlePtr => wire
._rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerNostrListenerHandlePtr;
CrossPlatformFinalizerArg
get rust_arc_decrement_strong_count_PreparedMeltPtr => wire
._rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerPreparedMeltPtr;
CrossPlatformFinalizerArg
get rust_arc_decrement_strong_count_PreparedSendPtr => wire
._rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerPreparedSendPtr;
@@ -62,6 +66,12 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
dynamic raw,
);
@protected
PreparedMelt
dco_decode_Auto_Owned_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerPreparedMelt(
dynamic raw,
);
@protected
PreparedSend
dco_decode_Auto_Owned_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerPreparedSend(
@@ -92,6 +102,12 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
dynamic raw,
);
@protected
PreparedMelt
dco_decode_Auto_RefMut_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerPreparedMelt(
dynamic raw,
);
@protected
PreparedSend
dco_decode_Auto_RefMut_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerPreparedSend(
@@ -122,6 +138,12 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
dynamic raw,
);
@protected
PreparedMelt
dco_decode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerPreparedMelt(
dynamic raw,
);
@protected
PreparedSend
dco_decode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerPreparedSend(
@@ -161,6 +183,12 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
dynamic raw,
);
@protected
PreparedMelt
dco_decode_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerPreparedMelt(
dynamic raw,
);
@protected
PreparedSend
dco_decode_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerPreparedSend(
@@ -372,6 +400,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected
ReceiveOptions dco_decode_receive_options(dynamic raw);
@protected
ReclaimResult dco_decode_reclaim_result(dynamic raw);
@protected
(String, String) dco_decode_record_string_string(dynamic raw);
@@ -426,6 +457,12 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
SseDeserializer deserializer,
);
@protected
PreparedMelt
sse_decode_Auto_Owned_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerPreparedMelt(
SseDeserializer deserializer,
);
@protected
PreparedSend
sse_decode_Auto_Owned_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerPreparedSend(
@@ -456,6 +493,12 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
SseDeserializer deserializer,
);
@protected
PreparedMelt
sse_decode_Auto_RefMut_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerPreparedMelt(
SseDeserializer deserializer,
);
@protected
PreparedSend
sse_decode_Auto_RefMut_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerPreparedSend(
@@ -486,6 +529,12 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
SseDeserializer deserializer,
);
@protected
PreparedMelt
sse_decode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerPreparedMelt(
SseDeserializer deserializer,
);
@protected
PreparedSend
sse_decode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerPreparedSend(
@@ -527,6 +576,12 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
SseDeserializer deserializer,
);
@protected
PreparedMelt
sse_decode_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerPreparedMelt(
SseDeserializer deserializer,
);
@protected
PreparedSend
sse_decode_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerPreparedSend(
@@ -776,6 +831,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected
ReceiveOptions sse_decode_receive_options(SseDeserializer deserializer);
@protected
ReclaimResult sse_decode_reclaim_result(SseDeserializer deserializer);
@protected
(String, String) sse_decode_record_string_string(
SseDeserializer deserializer,
@@ -839,6 +897,13 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
SseSerializer serializer,
);
@protected
void
sse_encode_Auto_Owned_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerPreparedMelt(
PreparedMelt self,
SseSerializer serializer,
);
@protected
void
sse_encode_Auto_Owned_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerPreparedSend(
@@ -874,6 +939,13 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
SseSerializer serializer,
);
@protected
void
sse_encode_Auto_RefMut_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerPreparedMelt(
PreparedMelt self,
SseSerializer serializer,
);
@protected
void
sse_encode_Auto_RefMut_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerPreparedSend(
@@ -909,6 +981,13 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
SseSerializer serializer,
);
@protected
void
sse_encode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerPreparedMelt(
PreparedMelt self,
SseSerializer serializer,
);
@protected
void
sse_encode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerPreparedSend(
@@ -957,6 +1036,13 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
SseSerializer serializer,
);
@protected
void
sse_encode_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerPreparedMelt(
PreparedMelt self,
SseSerializer serializer,
);
@protected
void
sse_encode_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerPreparedSend(
@@ -1270,6 +1356,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
SseSerializer serializer,
);
@protected
void sse_encode_reclaim_result(ReclaimResult self, SseSerializer serializer);
@protected
void sse_encode_record_string_string(
(String, String) self,
@@ -1404,6 +1493,40 @@ class RustLibWire implements BaseWire {
_rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerNostrListenerHandlePtr
.asFunction<void Function(ffi.Pointer<ffi.Void>)>();
void
rust_arc_increment_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerPreparedMelt(
ffi.Pointer<ffi.Void> ptr,
) {
return _rust_arc_increment_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerPreparedMelt(
ptr,
);
}
late final _rust_arc_increment_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerPreparedMeltPtr =
_lookup<ffi.NativeFunction<ffi.Void Function(ffi.Pointer<ffi.Void>)>>(
'frbgen_elcaju_rust_arc_increment_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerPreparedMelt',
);
late final _rust_arc_increment_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerPreparedMelt =
_rust_arc_increment_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerPreparedMeltPtr
.asFunction<void Function(ffi.Pointer<ffi.Void>)>();
void
rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerPreparedMelt(
ffi.Pointer<ffi.Void> ptr,
) {
return _rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerPreparedMelt(
ptr,
);
}
late final _rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerPreparedMeltPtr =
_lookup<ffi.NativeFunction<ffi.Void Function(ffi.Pointer<ffi.Void>)>>(
'frbgen_elcaju_rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerPreparedMelt',
);
late final _rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerPreparedMelt =
_rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerPreparedMeltPtr
.asFunction<void Function(ffi.Pointer<ffi.Void>)>();
void
rust_arc_increment_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerPreparedSend(
ffi.Pointer<ffi.Void> ptr,
+115
View File
@@ -33,6 +33,10 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
get rust_arc_decrement_strong_count_NostrListenerHandlePtr => wire
.rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerNostrListenerHandle;
CrossPlatformFinalizerArg
get rust_arc_decrement_strong_count_PreparedMeltPtr => wire
.rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerPreparedMelt;
CrossPlatformFinalizerArg
get rust_arc_decrement_strong_count_PreparedSendPtr => wire
.rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerPreparedSend;
@@ -64,6 +68,12 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
dynamic raw,
);
@protected
PreparedMelt
dco_decode_Auto_Owned_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerPreparedMelt(
dynamic raw,
);
@protected
PreparedSend
dco_decode_Auto_Owned_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerPreparedSend(
@@ -94,6 +104,12 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
dynamic raw,
);
@protected
PreparedMelt
dco_decode_Auto_RefMut_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerPreparedMelt(
dynamic raw,
);
@protected
PreparedSend
dco_decode_Auto_RefMut_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerPreparedSend(
@@ -124,6 +140,12 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
dynamic raw,
);
@protected
PreparedMelt
dco_decode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerPreparedMelt(
dynamic raw,
);
@protected
PreparedSend
dco_decode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerPreparedSend(
@@ -163,6 +185,12 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
dynamic raw,
);
@protected
PreparedMelt
dco_decode_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerPreparedMelt(
dynamic raw,
);
@protected
PreparedSend
dco_decode_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerPreparedSend(
@@ -374,6 +402,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected
ReceiveOptions dco_decode_receive_options(dynamic raw);
@protected
ReclaimResult dco_decode_reclaim_result(dynamic raw);
@protected
(String, String) dco_decode_record_string_string(dynamic raw);
@@ -428,6 +459,12 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
SseDeserializer deserializer,
);
@protected
PreparedMelt
sse_decode_Auto_Owned_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerPreparedMelt(
SseDeserializer deserializer,
);
@protected
PreparedSend
sse_decode_Auto_Owned_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerPreparedSend(
@@ -458,6 +495,12 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
SseDeserializer deserializer,
);
@protected
PreparedMelt
sse_decode_Auto_RefMut_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerPreparedMelt(
SseDeserializer deserializer,
);
@protected
PreparedSend
sse_decode_Auto_RefMut_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerPreparedSend(
@@ -488,6 +531,12 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
SseDeserializer deserializer,
);
@protected
PreparedMelt
sse_decode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerPreparedMelt(
SseDeserializer deserializer,
);
@protected
PreparedSend
sse_decode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerPreparedSend(
@@ -529,6 +578,12 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
SseDeserializer deserializer,
);
@protected
PreparedMelt
sse_decode_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerPreparedMelt(
SseDeserializer deserializer,
);
@protected
PreparedSend
sse_decode_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerPreparedSend(
@@ -778,6 +833,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected
ReceiveOptions sse_decode_receive_options(SseDeserializer deserializer);
@protected
ReclaimResult sse_decode_reclaim_result(SseDeserializer deserializer);
@protected
(String, String) sse_decode_record_string_string(
SseDeserializer deserializer,
@@ -841,6 +899,13 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
SseSerializer serializer,
);
@protected
void
sse_encode_Auto_Owned_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerPreparedMelt(
PreparedMelt self,
SseSerializer serializer,
);
@protected
void
sse_encode_Auto_Owned_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerPreparedSend(
@@ -876,6 +941,13 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
SseSerializer serializer,
);
@protected
void
sse_encode_Auto_RefMut_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerPreparedMelt(
PreparedMelt self,
SseSerializer serializer,
);
@protected
void
sse_encode_Auto_RefMut_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerPreparedSend(
@@ -911,6 +983,13 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
SseSerializer serializer,
);
@protected
void
sse_encode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerPreparedMelt(
PreparedMelt self,
SseSerializer serializer,
);
@protected
void
sse_encode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerPreparedSend(
@@ -959,6 +1038,13 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
SseSerializer serializer,
);
@protected
void
sse_encode_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerPreparedMelt(
PreparedMelt self,
SseSerializer serializer,
);
@protected
void
sse_encode_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerPreparedSend(
@@ -1272,6 +1358,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
SseSerializer serializer,
);
@protected
void sse_encode_reclaim_result(ReclaimResult self, SseSerializer serializer);
@protected
void sse_encode_record_string_string(
(String, String) self,
@@ -1361,6 +1450,22 @@ class RustLibWire implements BaseWire {
ptr,
);
void
rust_arc_increment_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerPreparedMelt(
int ptr,
) => wasmModule
.rust_arc_increment_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerPreparedMelt(
ptr,
);
void
rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerPreparedMelt(
int ptr,
) => wasmModule
.rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerPreparedMelt(
ptr,
);
void
rust_arc_increment_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerPreparedSend(
int ptr,
@@ -1452,6 +1557,16 @@ extension type RustLibWasmModule._(JSObject _) implements JSObject {
int ptr,
);
external void
rust_arc_increment_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerPreparedMelt(
int ptr,
);
external void
rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerPreparedMelt(
int ptr,
);
external void
rust_arc_increment_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerPreparedSend(
int ptr,
+15 -2
View File
@@ -58,6 +58,9 @@ class AnimatedActionButton extends StatefulWidget {
/// Si mostrar el icono (default: false)
final bool showIcon;
/// Si el icono va después del texto (default: false = antes del texto)
final bool iconTrailing;
/// Ancho del botón (default: expandir al padre)
final double? width;
@@ -73,6 +76,7 @@ class AnimatedActionButton extends StatefulWidget {
this.backgroundColor,
this.icon,
this.showIcon = false,
this.iconTrailing = false,
this.width,
this.height,
});
@@ -300,8 +304,8 @@ class _AnimatedActionButtonState extends State<AnimatedActionButton>
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: [
// Icono opcional
if (widget.showIcon && widget.icon != null) ...[
// Icono leading (antes del texto)
if (widget.showIcon && widget.icon != null && !widget.iconTrailing) ...[
Icon(
widget.icon,
color: Colors.white,
@@ -319,6 +323,15 @@ class _AnimatedActionButtonState extends State<AnimatedActionButton>
color: Colors.white,
),
),
// Icono trailing (después del texto)
if (widget.showIcon && widget.icon != null && widget.iconTrailing) ...[
const SizedBox(width: AppDimensions.paddingSmall),
Icon(
widget.icon,
color: Colors.white,
size: widget.type == ButtonType.navigation ? 28 : 20,
),
],
],
),
),
@@ -0,0 +1,80 @@
import 'package:flutter/material.dart';
import '../../core/constants/colors.dart';
import '../../core/constants/dimensions.dart';
/// Frame común para modales tipo bottom sheet.
///
/// Aplica SafeArea(top: false) para que los botones internos no queden
/// tapados por la barra de navegación del sistema (gestos o 3 botones).
///
/// Usá [respectKeyboard]: true cuando el modal contenga TextField, para
/// combinar el inset de la barra con el del teclado (viewInsets.bottom).
class BottomSheetContainer extends StatelessWidget {
final Widget child;
final EdgeInsetsGeometry padding;
final bool respectKeyboard;
const BottomSheetContainer({
super.key,
required this.child,
this.padding = const EdgeInsets.all(AppDimensions.paddingMedium),
this.respectKeyboard = false,
});
@override
Widget build(BuildContext context) {
final mq = MediaQuery.of(context);
// viewPadding.bottom es el inset crudo de la nav bar del sistema
// (no se consume aunque un SafeArea padre ya lo haya absorbido).
final navBarInset = mq.viewPadding.bottom;
// Combinar el padding base con el extra del navBar abajo.
final resolvedPadding = padding.resolve(Directionality.of(context));
final effectivePadding = EdgeInsets.only(
left: resolvedPadding.left,
top: resolvedPadding.top,
right: resolvedPadding.right,
bottom: resolvedPadding.bottom + navBarInset,
);
final frame = Container(
decoration: BoxDecoration(
color: AppColors.deepVoidPurple,
borderRadius: const BorderRadius.vertical(
top: Radius.circular(AppDimensions.radiusXLarge),
),
border: Border.all(
color: Colors.white.withValues(alpha: 0.1),
width: 1,
),
),
child: Padding(padding: effectivePadding, child: child),
);
if (respectKeyboard) {
return Padding(
padding: EdgeInsets.only(bottom: mq.viewInsets.bottom),
child: frame,
);
}
return frame;
}
}
/// Handle visual en la parte superior del modal (la barrita).
class BottomSheetHandle extends StatelessWidget {
const BottomSheetHandle({super.key});
@override
Widget build(BuildContext context) {
return Container(
margin: const EdgeInsets.only(bottom: 16),
width: 40,
height: 4,
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.3),
borderRadius: BorderRadius.circular(2),
),
);
}
}
+1 -1
View File
@@ -7,7 +7,7 @@ project(runner LANGUAGES CXX)
set(BINARY_NAME "elcaju")
# The unique GTK application identifier for this application. See:
# https://wiki.gnome.org/HowDoI/ChooseApplicationID
set(APPLICATION_ID "com.example.elcaju")
set(APPLICATION_ID "me.elcaju")
# Explicitly opt in to modern CMake behaviors to avoid warnings with recent
# versions of CMake.
+3 -3
View File
@@ -385,7 +385,7 @@
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.example.elcaju.RunnerTests;
PRODUCT_BUNDLE_IDENTIFIER = me.elcaju.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/elcaju.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/elcaju";
@@ -399,7 +399,7 @@
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.example.elcaju.RunnerTests;
PRODUCT_BUNDLE_IDENTIFIER = me.elcaju.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/elcaju.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/elcaju";
@@ -413,7 +413,7 @@
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.example.elcaju.RunnerTests;
PRODUCT_BUNDLE_IDENTIFIER = me.elcaju.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/elcaju.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/elcaju";
+2 -2
View File
@@ -8,7 +8,7 @@
PRODUCT_NAME = elcaju
// The application's bundle identifier
PRODUCT_BUNDLE_IDENTIFIER = com.example.elcaju
PRODUCT_BUNDLE_IDENTIFIER = me.elcaju
// The copyright displayed in application information
PRODUCT_COPYRIGHT = Copyright © 2026 com.example. All rights reserved.
PRODUCT_COPYRIGHT = Copyright © 2026 ElCaju. All rights reserved.
+1 -1
View File
@@ -1,7 +1,7 @@
name: elcaju
description: "ElCaju - Tu wallet de ecash privado"
publish_to: 'none'
version: 0.1.0+2
version: 0.2.0+3
environment:
sdk: ^3.11.0
+39 -15
View File
@@ -367,6 +367,17 @@ version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2dee39a0ee5b4095224a0cfc6bf4cc1baf0f9624b96b367e53b66d974e51d953"
[[package]]
name = "bitcoin-payment-instructions"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c300c948b2ff78c965ea3a613372352125448a22f1acf49e95e3878149824091"
dependencies = [
"bitcoin",
"lightning",
"lightning-invoice",
]
[[package]]
name = "bitcoin-private"
version = "0.1.0"
@@ -489,9 +500,9 @@ checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
[[package]]
name = "cashu"
version = "0.15.1"
version = "0.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d612261d61069314bb0e39adbd63d0fd3bd22084f332e85c98e615c15f24e5a"
checksum = "26b4ad04070fb67914feb2641e7b54001000f1f7f67a7c39a08f32edf01fd3ae"
dependencies = [
"bitcoin",
"cbor-diag",
@@ -507,6 +518,7 @@ dependencies = [
"strum_macros",
"thiserror 2.0.18",
"tracing",
"unicode-normalization",
"url",
"uuid",
"web-time",
@@ -555,14 +567,15 @@ dependencies = [
[[package]]
name = "cdk"
version = "0.15.1"
version = "0.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d8955437beea95ee6849c6eab59012760a7589e6eeff4d6e36ebf324c641388d"
checksum = "c6536eb1df7a497cb334edf6bb9eea1fa8e4a9b08c79fe45c40d43413ecb88c8"
dependencies = [
"anyhow",
"arc-swap",
"async-trait",
"bitcoin",
"bitcoin-payment-instructions",
"cbor-diag",
"cdk-common",
"cdk-signatory",
@@ -582,7 +595,6 @@ dependencies = [
"serde_with",
"thiserror 2.0.18",
"tokio",
"tokio-tungstenite",
"tokio-util",
"tracing",
"url",
@@ -593,9 +605,9 @@ dependencies = [
[[package]]
name = "cdk-common"
version = "0.15.1"
version = "0.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f5bf771d3780d2321211eb575e1f8341cec50f8a16c4f50b2787807b5090a38"
checksum = "a371d218ffe83dcabd202278251af53744b8fa3e846124ddcd34247c4e38bdc3"
dependencies = [
"anyhow",
"async-trait",
@@ -606,6 +618,7 @@ dependencies = [
"ciborium",
"futures",
"getrandom 0.2.17",
"jsonwebtoken",
"lightning",
"lightning-invoice",
"parking_lot",
@@ -626,23 +639,31 @@ dependencies = [
[[package]]
name = "cdk-http-client"
version = "0.15.1"
version = "0.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b58f47c4af4a4c862d145863211f5454c3ccfbacaaef9e4913276519a1ac498e"
checksum = "bf194792d45975b360911417d3713e4d3f0142e18b082e2a8c2c88a91630b43d"
dependencies = [
"futures",
"futures-channel",
"js-sys",
"regex",
"reqwest",
"serde",
"serde_json",
"thiserror 2.0.18",
"tokio-tungstenite",
"tracing",
"url",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
]
[[package]]
name = "cdk-signatory"
version = "0.15.1"
version = "0.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc1dca3b84999ac0e6fd15330d566aff6df6082a349134fd884deef8c9eeb570"
checksum = "4f40745c9402049bbba47ae52e1fc7552d4af8e99572fd73697448f51bbb3a02"
dependencies = [
"anyhow",
"async-trait",
@@ -662,9 +683,9 @@ dependencies = [
[[package]]
name = "cdk-sql-common"
version = "0.15.1"
version = "0.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6625a8248a43fbebb92cc57f1b09c453d5de94d62a512b62e9a62b25d0dd5506"
checksum = "36e920cffaa36f396d16727649b05d6dc8d42aecc4108fa955876515149bdb0e"
dependencies = [
"async-trait",
"bitcoin",
@@ -681,9 +702,9 @@ dependencies = [
[[package]]
name = "cdk-sqlite"
version = "0.15.1"
version = "0.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9334877fe3f9fb25f6819c0876af1b0690f47aa2b1e548a20ac904740cbd574c"
checksum = "c706b86b76d89bbbfad7e106328c14f1d99440ee5829840510e50922f48fdc51"
dependencies = [
"async-trait",
"bitcoin",
@@ -1054,6 +1075,9 @@ name = "dnssec-prover"
version = "0.6.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec4f825369fc7134da70ca4040fddc8e03b80a46d249ae38d9c1c39b7b4476bf"
dependencies = [
"bitcoin_hashes 0.14.1",
]
[[package]]
name = "dyn-clone"
+4 -4
View File
@@ -8,9 +8,9 @@ crate-type = ["cdylib", "staticlib"]
[dependencies]
# Cashu — acceso directo, sin intermediarios
cdk = { version = "0.15.1", default-features = false, features = ["wallet", "nostr"] }
cdk-common = { version = "0.15.1", default-features = false, features = ["nostr"] }
cdk-sqlite = { version = "0.15.1", default-features = false, features = ["wallet"] }
cdk = { version = "0.16.0", default-features = false, features = ["wallet", "nostr"] }
cdk-common = { version = "0.16.0", default-features = false, features = ["nostr"] }
cdk-sqlite = { version = "0.16.0", default-features = false, features = ["wallet"] }
# Bridge Flutter <-> Rust
flutter_rust_bridge = { version = "=2.11.1", default-features = false, features = [
@@ -31,7 +31,7 @@ bip39 = { version = "2.1", default-features = false, features = ["std"] }
# Entropy
getrandom = { version = "0.3", default-features = false, features = ["std"] }
# Nostr keys (same version as CDK 0.15.1 — CDK does not re-export Keys)
# Nostr keys (same version as CDK 0.16.0 — CDK does not re-export Keys)
nostr-sdk = { version = "0.44.1", default-features = false, features = ["nip04", "nip44", "nip59"] }
# Async runtime
+3 -12
View File
@@ -69,16 +69,7 @@ fn parse_payment_request_inner(encoded: String) -> Result<PaymentRequestInfo, Er
let pr = CdkPaymentRequest::from_str(&creq_str)
.map_err(|e| Error::Cdk(format!("Invalid payment request: {e}")))?;
let mints: Vec<String> = match &pr.mints {
Some(list) => {
let mut v = Vec::new();
for m in list {
v.push(format!("{}", m));
}
v
}
None => Vec::new(),
};
let mints: Vec<String> = pr.mints.iter().map(|m| m.to_string()).collect();
let mut transports = Vec::new();
for t in &pr.transports {
transports.push(TransportInfo {
@@ -320,7 +311,7 @@ impl Wallet {
let nostr_transport = Transport {
_type: TransportType::Nostr,
target: nprofile_bech32,
tags: Some(vec![vec!["n".to_string(), "17".to_string()]]),
tags: vec![vec!["n".to_string(), "17".to_string()]],
};
// Build the PaymentRequest with this wallet's mint and unit
@@ -336,7 +327,7 @@ impl Wallet {
amount: params.amount.map(Amount::from),
unit: Some(unit.clone()),
single_use: Some(true),
mints: Some(vec![mint_url.clone()]),
mints: vec![mint_url.clone()],
description: params.description,
transports: vec![nostr_transport],
nut10: None,
+296 -23
View File
@@ -155,7 +155,7 @@ impl Wallet {
// Compute the deterministic transaction ID from the token's proofs
// (SHA-256 of sorted Y values — same as CDK uses internally)
// Best-effort: send is already committed, don't fail on ID computation
let tx_id = match self.inner.get_mint_keysets().await {
let tx_id = match self.inner.get_mint_keysets(cdk::wallet::KeysetFilter::Active).await {
Ok(keysets) => match cdk_token.proofs(&keysets) {
Ok(proofs) => TransactionId::try_from(proofs)
.map(|id| id.to_string())
@@ -421,15 +421,52 @@ impl Wallet {
.into())
}
pub async fn melt(&self, quote: MeltQuote) -> Result<u64, Error> {
let melted = self
pub async fn prepare_melt(&self, quote: MeltQuote) -> Result<PreparedMelt, Error> {
let prepared = self
.inner
.prepare_melt(&quote.id, HashMap::new())
.await?
.confirm()
.await?;
Ok(PreparedMelt {
amount: prepared.amount().into(),
fee_reserve: prepared.quote().fee_reserve.into(),
swap_fee: prepared.swap_fee().into(),
input_fee: prepared.input_fee().into(),
operation_id: prepared.operation_id(),
cdk_quote: prepared.quote().clone(),
proofs: prepared.proofs().clone(),
proofs_to_swap: prepared.proofs_to_swap().clone(),
cdk_input_fee: prepared.input_fee(),
cdk_input_fee_without_swap: prepared.input_fee_without_swap(),
})
}
pub async fn confirm_melt(&self, melt: PreparedMelt) -> Result<u64, Error> {
let finalized = self
.inner
.confirm_prepared_melt(
melt.operation_id,
melt.cdk_quote,
melt.proofs,
melt.proofs_to_swap,
melt.cdk_input_fee,
melt.cdk_input_fee_without_swap,
HashMap::new(),
)
.await?;
self.update_balance_streams().await;
Ok(melted.total_amount().into())
Ok(finalized.total_amount().into())
}
pub async fn cancel_melt(&self, melt: PreparedMelt) -> Result<(), Error> {
self.inner
.cancel_prepared_melt(
melt.operation_id,
melt.proofs,
melt.proofs_to_swap,
)
.await?;
self.update_balance_streams().await;
Ok(())
}
// === Transactions ===
@@ -494,41 +531,173 @@ impl Wallet {
// === Reclaim orphaned proofs ===
/// Check pending-spent proofs with the mint and revert unspent ones.
/// Returns the number of proofs recovered.
pub async fn reclaim_pending_proofs(&self) -> Result<u64, Error> {
/// Returns per-state buckets (unspent reverted, still pending, spent).
pub async fn reclaim_pending_proofs(&self) -> Result<ReclaimResult, Error> {
let pending = self.inner.get_pending_spent_proofs().await?;
if pending.is_empty() {
return Ok(0);
return Ok(ReclaimResult::empty());
}
// Build a map of Y → amount for later lookup
let mut amount_by_y: HashMap<PublicKey, u64> = HashMap::new();
for proof in &pending {
if let Ok(y) = proof.y() {
amount_by_y.insert(y, u64::from(proof.amount));
}
}
// check_proofs_spent: queries mint AND removes spent proofs from local DB
let states = self.inner.check_proofs_spent(pending).await?;
let buckets = StateBuckets::from_states(states, &amount_by_y);
// Collect Y values of proofs the mint says are NOT spent
// Only reclaim proofs the mint explicitly reports as Unspent.
// Pending proofs (still being processed) must not be unreserved.
let unspent_ys: Vec<PublicKey> = states
.into_iter()
.filter(|s| s.state == ProofState::Unspent)
.map(|s| s.y)
.collect();
let count = unspent_ys.len() as u64;
if count > 0 {
if !buckets.unspent_ys.is_empty() {
// Revert from PendingSpent to Unspent
self.inner.unreserve_proofs(unspent_ys).await?;
self.inner.unreserve_proofs(buckets.unspent_ys.clone()).await?;
}
// Always refresh: check_proofs_spent may have removed spent proofs
self.update_balance_streams().await;
Ok(count)
Ok(buckets.into_result())
}
/// Reclaim proofs belonging to a specific send identified by its Y values.
/// Scoped variant of `reclaim_pending_proofs`: only proofs whose Y matches
/// the input list are checked with the mint and reverted if Unspent.
///
/// Empty returns:
/// - Input `ys` empty or all unparseable → `ReclaimResult::empty()`.
/// - None of the target Ys are in local pending anymore (a previous
/// reconciliation already processed them) → `spent_count` is set to
/// the number of targets, signalling to callers that the send is
/// definitively finished and any local record can be removed.
pub async fn reclaim_proofs_by_ys(&self, ys: Vec<String>) -> Result<ReclaimResult, Error> {
let target_ys: std::collections::HashSet<PublicKey> = ys
.iter()
.filter_map(|y| PublicKey::from_hex(y).ok())
.collect();
if target_ys.is_empty() {
return Ok(ReclaimResult::empty());
}
let total = target_ys.len() as u64;
let pending = self.inner.get_pending_spent_proofs().await?;
// Filter to only proofs with a Y in our target set.
let relevant: Vec<_> = pending
.into_iter()
.filter(|proof| proof.y().map(|y| target_ys.contains(&y)).unwrap_or(false))
.collect();
// Target Ys that aren't in local PendingSpent anymore are treated as
// spent — a previous `check_proofs_spent` call removed them from the
// local DB because the mint confirmed SPENT. This matches
// `check_proofs_by_ys` and keeps `ReclaimResult`'s bucket invariant
// (`unspent + pending + spent == total`) across both paths.
let resolved_out_of_pending = total.saturating_sub(relevant.len() as u64);
if relevant.is_empty() {
return Ok(ReclaimResult {
count: 0,
amount: 0,
pending_count: 0,
spent_count: total,
});
}
let mut amount_by_y: HashMap<PublicKey, u64> = HashMap::new();
for proof in &relevant {
if let Ok(y) = proof.y() {
amount_by_y.insert(y, u64::from(proof.amount));
}
}
let states = self.inner.check_proofs_spent(relevant).await?;
let buckets = StateBuckets::from_states(states, &amount_by_y);
if !buckets.unspent_ys.is_empty() {
self.inner.unreserve_proofs(buckets.unspent_ys.clone()).await?;
}
self.update_balance_streams().await;
let mut result = buckets.into_result();
result.spent_count += resolved_out_of_pending;
Ok(result)
}
/// Observe-only counterpart of `reclaim_proofs_by_ys`. Queries the mint
/// for the state of the target Ys but **does NOT** revert Unspent proofs
/// to the local Unspent state.
///
/// Use this for periodic reconciliation where we want to auto-settle
/// sends the receiver already claimed, without accidentally cancelling
/// sends the receiver has not claimed yet (which `reclaim_proofs_by_ys`
/// would do by calling `unreserve_proofs`).
///
/// Return semantics:
/// - `count` / `amount` always 0 (nothing is recovered).
/// - `pending_count` / `spent_count` reflect the mint's report.
/// - `spent_count` also includes target Ys that are no longer present
/// in local PendingSpent (already reconciled or externally cleaned).
/// The caller can infer "all resolved as spent" from
/// `spent_count == target_ys.len() && pending_count == 0`.
pub async fn check_proofs_by_ys(&self, ys: Vec<String>) -> Result<ReclaimResult, Error> {
let target_ys: std::collections::HashSet<PublicKey> = ys
.iter()
.filter_map(|y| PublicKey::from_hex(y).ok())
.collect();
if target_ys.is_empty() {
return Ok(ReclaimResult::empty());
}
let total = target_ys.len() as u64;
let pending = self.inner.get_pending_spent_proofs().await?;
let relevant: Vec<_> = pending
.into_iter()
.filter(|proof| proof.y().map(|y| target_ys.contains(&y)).unwrap_or(false))
.collect();
// Ys we expected to be in local PendingSpent but aren't anymore.
// Safe assumption: they were reconciled out earlier because the
// mint confirmed them spent (check_proofs_spent removed them).
let resolved_out_of_pending = total.saturating_sub(relevant.len() as u64);
if relevant.is_empty() {
return Ok(ReclaimResult {
count: 0,
amount: 0,
pending_count: 0,
spent_count: total,
});
}
let mut amount_by_y: HashMap<PublicKey, u64> = HashMap::new();
for proof in &relevant {
if let Ok(y) = proof.y() {
amount_by_y.insert(y, u64::from(proof.amount));
}
}
// check_proofs_spent queries the mint AND removes locally-spent
// proofs from the DB. That cleanup is fine — it doesn't cancel
// an active send, it just advances state once the mint confirms
// SPENT. The part we deliberately skip is `unreserve_proofs`.
let states = self.inner.check_proofs_spent(relevant).await?;
let buckets = StateBuckets::from_states(states, &amount_by_y);
self.update_balance_streams().await;
Ok(ReclaimResult {
count: 0,
amount: 0,
pending_count: buckets.pending_count,
spent_count: buckets.spent_count + resolved_out_of_pending,
})
}
// === Utility ===
pub async fn is_token_spent(&self, token: Token) -> Result<bool, Error> {
let token: CdkToken = token.try_into()?;
let mint_keysets = self.inner.get_mint_keysets().await?;
let mint_keysets = self.inner.get_mint_keysets(cdk::wallet::KeysetFilter::All).await?;
let proof_states = self
.inner
.check_proofs_spent(token.proofs(&mint_keysets)?)
@@ -552,6 +721,13 @@ impl Wallet {
CurrencyUnit::from_str(&self.unit).unwrap_or(CurrencyUnit::Custom(self.unit.clone()))
}
/// Recalcula el balance desde la DB y lo empuja al stream.
/// Llamar después de manipulaciones directas al DB (ej: offline send
/// marking proofs as PendingSpent fuera de la API de CDK).
pub async fn refresh_balance(&self) {
self.update_balance_streams().await;
}
pub(crate) async fn update_balance_streams(&self) {
let balance = self
.inner
@@ -677,6 +853,103 @@ impl<'a> From<CdkPreparedSend<'a>> for PreparedSend {
}
}
pub struct ReclaimResult {
/// Proofs reverted from PendingSpent to Unspent. Also the number of
/// recovered proofs reflected in `amount`.
pub count: u64,
/// Total value of the recovered proofs.
pub amount: u64,
/// Proofs the mint reports as still Pending (receiver mid-swap). The
/// caller should keep the record for retry — a later reconciliation
/// will resolve them.
pub pending_count: u64,
/// Proofs the mint reports as Spent (receiver already claimed). When
/// `count == 0 && pending_count == 0`, the send is definitively
/// finished and the caller can delete its local record.
pub spent_count: u64,
}
impl ReclaimResult {
#[frb(ignore)]
fn empty() -> Self {
Self {
count: 0,
amount: 0,
pending_count: 0,
spent_count: 0,
}
}
}
/// Internal helper: partition the response of `check_proofs_spent` into
/// the three buckets we care about (Unspent / Pending / Spent) and carry
/// the Ys/amount needed to build a `ReclaimResult`.
#[frb(ignore)]
struct StateBuckets {
unspent_ys: Vec<PublicKey>,
unspent_amount: u64,
pending_count: u64,
spent_count: u64,
}
impl StateBuckets {
fn from_states(
states: Vec<cdk::nuts::ProofState>,
amount_by_y: &HashMap<PublicKey, u64>,
) -> Self {
let mut unspent_ys = Vec::new();
let mut unspent_amount: u64 = 0;
let mut pending_count: u64 = 0;
let mut spent_count: u64 = 0;
for s in states {
match s.state {
ProofState::Unspent => {
if let Some(&a) = amount_by_y.get(&s.y) {
unspent_amount = unspent_amount.saturating_add(a);
}
unspent_ys.push(s.y);
}
ProofState::Pending => pending_count += 1,
ProofState::Spent => spent_count += 1,
// Reserved / other states: not expected for pending-spent
// queries; ignore so we don't miscount.
_ => {}
}
}
Self {
unspent_ys,
unspent_amount,
pending_count,
spent_count,
}
}
fn into_result(self) -> ReclaimResult {
ReclaimResult {
count: self.unspent_ys.len() as u64,
amount: self.unspent_amount,
pending_count: self.pending_count,
spent_count: self.spent_count,
}
}
}
pub struct PreparedMelt {
pub amount: u64,
pub fee_reserve: u64,
pub swap_fee: u64,
pub input_fee: u64,
operation_id: Uuid,
cdk_quote: CdkMeltQuote,
proofs: cdk::nuts::Proofs,
proofs_to_swap: cdk::nuts::Proofs,
cdk_input_fee: Amount,
cdk_input_fee_without_swap: Amount,
}
#[derive(Default)]
pub struct ReceiveOptions {
pub signing_keys: Option<Vec<String>>,
+953 -113
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -89,11 +89,11 @@ BEGIN
BEGIN
BLOCK "040904e4"
BEGIN
VALUE "CompanyName", "com.example" "\0"
VALUE "CompanyName", "ElCaju" "\0"
VALUE "FileDescription", "elcaju" "\0"
VALUE "FileVersion", VERSION_AS_STRING "\0"
VALUE "InternalName", "elcaju" "\0"
VALUE "LegalCopyright", "Copyright (C) 2026 com.example. All rights reserved." "\0"
VALUE "LegalCopyright", "Copyright (C) 2026 ElCaju. All rights reserved." "\0"
VALUE "OriginalFilename", "elcaju.exe" "\0"
VALUE "ProductName", "elcaju" "\0"
VALUE "ProductVersion", VERSION_AS_STRING "\0"