Compare commits

...
Author SHA1 Message Date
Forte11Cuba f9a70bf1ed ci: add universal APK and stable download URL
Release APK / build (push) Canceled after 0s
2026-02-25 05:27:34 -06:00
Forte11Cuba 22434245f2 bump version to v0.1.0 2026-02-25 04:48:05 -06:00
Forte11Cuba fb76bde5b7 fix: remove stale precompiled .so and ignore jniLibs 2026-02-25 04:46:44 -06:00
Forte11andGitHub 365047e73c Merge pull request #62 from Forte11Cuba/feature/p2pk-qr-scan
feat: add QR scan button for P2PK pubkey input (#59)
2026-02-23 16:31:48 -06:00
Forte11Cuba c374c042c7 fix: add user feedback for invalid QR and camera errors in P2PK scanner 2026-02-23 16:30:30 -06:00
Forte11Cuba 0f7e89a687 feat: add QR scan button for P2PK pubkey input (#59) 2026-02-23 16:21:29 -06:00
Forte11andGitHub 73582c4a69 Merge pull request #61 from Forte11Cuba/fix/cancel-send-on-failure
fix: release reserved proofs when confirmSend fails (#58)
2026-02-23 15:51:19 -06:00
Forte11Cuba 3e9fea34eb fix: guard cancelSend to avoid masking original send error 2026-02-23 15:48:46 -06:00
Forte11Cuba 348ef1d987 fix: release reserved proofs when confirmSend fails (#58) 2026-02-23 15:42:02 -06:00
Forte11andGitHub fc72bb33b2 Merge pull request #57 from Forte11Cuba/feature/enable-p2pk-send
feat: enable P2PK send with CDK 0.15 includeFee support
2026-02-23 14:29:52 -06:00
Forte11Cuba 7a9ce65e55 fix: guard against short pubkey in debug log and async paste disposal 2026-02-23 14:17:22 -06:00
Forte11Cuba 604dd65266 feat: enable P2PK send with CDK 0.15 includeFee support 2026-02-23 13:52:25 -06:00
Forte11andGitHub 42f59e877f Merge pull request #56 from Forte11Cuba/fix/pending-invoice-error-handling
fix: add try/catch to _loadPendingMintInvoice to prevent stuck loading state
2026-02-23 13:20:07 -06:00
Forte11Cuba 8b0e0dcd15 fix: log error in _loadPendingMintInvoice catch block 2026-02-23 13:18:28 -06:00
Forte11Cuba 0ac2a8b3ad fix: add try/catch to _loadPendingMintInvoice to prevent stuck loading state 2026-02-23 13:12:16 -06:00
Forte11andGitHub a97d7d45bc Merge pull request #55 from Forte11Cuba/fix/invoice-placeholder-flash
ui: suppress invoice placeholder flash while loading pending mint invoice
2026-02-21 01:38:33 -06:00
Forte11Cuba bf2c4771dd ui: suppress invoice placeholder flash while loading pending mint invoice 2026-02-21 01:30:53 -06:00
Forte11andGitHub 5821ea2be5 Merge pull request #54 from Forte11Cuba/ui/offline-send-select-all-label
ui: add label to select all checkbox in offline send screen
2026-02-21 01:25:01 -06:00
Forte11Cuba 36b900113f ui: add label to select all checkbox in offline send screen 2026-02-21 01:21:08 -06:00
Forte11Cuba 91078729aa ui: add label to select all checkbox in offline send screen 2026-02-21 00:53:53 -06:00
Forte11andGitHub 61293d991a Merge pull request #52 from Forte11Cuba/fix/transaction-detail-late-final-crash
fix: crash on Lightning incoming transaction detail without saved metadata
2026-02-21 00:44:13 -06:00
Forte11Cuba 4cb37a8de2 fix: crash on Lightning incoming transaction detail without saved metadata 2026-02-21 00:21:28 -06:00
Forte11andGitHub 1b5045b5ef Merge pull request #51 from Forte11Cuba/fix/normalize-mnemonic-whitespace
fix: normalize mnemonic whitespace before saving to secure storage
2026-02-21 00:18:28 -06:00
Forte11Cuba 7a1cb21d35 fix: normalize mnemonic whitespace and validate before persisting 2026-02-21 00:15:51 -06:00
Forte11Cuba b8649348c8 fix: normalize mnemonic whitespace before saving to secure storage 2026-02-21 00:06:27 -06:00
Forte11andGitHub d9de23b425 Merge pull request #46 from Forte11Cuba/fix/restore-wallet-nut13-scan
fix: trigger NUT-13 proof recovery scan on wallet restore
2026-02-17 01:56:38 -06:00
Forte11Cuba 28d95fdae0 fix: align Chinese mint term and center status text 2026-02-17 01:52:51 -06:00
Forte11Cuba 3b72367ff1 fix: trigger NUT-13 proof recovery scan on wallet restore 2026-02-17 01:39:15 -06:00
Forte11Cuba a8251c433c fix: split APK per ABI to reduce size (~40MB vs 125MB)
Release APK / build (push) Canceled after 0s
2026-02-14 21:02:26 -06:00
Forte11Cuba 812f28d3b9 Fix l10n imports for CI compatibility 2026-02-14 20:01:46 -06:00
Forte11Cuba 0465034a2b Add release workflow 2026-02-14 19:30:10 -06:00
Forte11andGitHub 9abb2c03e1 Merge pull request #44 from Forte11Cuba/fix/lightning-invoice-ux
fix: improve Lightning deposit UX and global confetti
2026-02-14 01:38:22 -06:00
Forte11Cuba f842561864 fix: improve Lightning deposit UX and global confetti 2026-02-14 01:36:52 -06:00
Forte11andGitHub 5fe1ec5c90 Merge pull request #43 from Forte11Cuba/fix/disable-p2pk-send-ui
fix: disable P2PK send UI pending CDK fix
2026-02-13 23:20:25 -06:00
Forte11Cuba 336f4316aa fix 2026-02-13 23:18:02 -06:00
Forte11Cuba 7f902f7f75 fix: disable P2PK send UI pending CDK fix 2026-02-13 23:08:31 -06:00
Forte11andGitHub bb07a8f2da Merge pull request #42 from Forte11Cuba/fix/p2pk-send-workaround
fix: workaround P2PK send bug (cdk-flutter#3)
2026-02-12 22:31:28 -06:00
56 changed files with 15021 additions and 221 deletions
+68
View File
@@ -0,0 +1,68 @@
name: Release APK
on:
push:
tags:
- "v*"
permissions:
contents: write
jobs:
build:
runs-on: ubuntu-latest
timeout-minutes: 60
steps:
- uses: actions/checkout@v4
- name: Setup Java 17
uses: actions/setup-java@v4
with:
distribution: 'zulu'
java-version: '17'
- name: Setup Flutter
uses: subosito/flutter-action@v2
with:
channel: stable
- name: Setup Rust toolchain
uses: dtolnay/rust-toolchain@stable
with:
targets: aarch64-linux-android,armv7-linux-androideabi,x86_64-linux-android
- name: Install Android NDK
run: echo "y" | $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager "ndk;27.0.12077973"
- name: Install dependencies
run: flutter pub get
- name: Generate localizations
run: flutter gen-l10n
- name: Build release APKs (split per ABI)
run: flutter build apk --release --split-per-abi
- name: Build universal APK
run: flutter build apk --release
- name: Rename APKs
run: |
cd build/app/outputs/flutter-apk
mv app-arm64-v8a-release.apk elcaju-${{ github.ref_name }}-arm64.apk
mv app-armeabi-v7a-release.apk elcaju-${{ github.ref_name }}-armeabi-v7a.apk
mv app-x86_64-release.apk elcaju-${{ github.ref_name }}-x86_64.apk
mv app-release.apk elcaju-${{ github.ref_name }}-universal.apk
cp elcaju-${{ github.ref_name }}-universal.apk elcaju-universal.apk
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
with:
files: |
build/app/outputs/flutter-apk/elcaju-${{ github.ref_name }}-arm64.apk
build/app/outputs/flutter-apk/elcaju-${{ github.ref_name }}-armeabi-v7a.apk
build/app/outputs/flutter-apk/elcaju-${{ github.ref_name }}-x86_64.apk
build/app/outputs/flutter-apk/elcaju-${{ github.ref_name }}-universal.apk
build/app/outputs/flutter-apk/elcaju-universal.apk
generate_release_notes: true
+3
View File
@@ -44,3 +44,6 @@ app.*.map.json
/android/app/debug
/android/app/profile
/android/app/release
# Rust/CargoKit compiled native libraries
android/app/src/main/jniLibs/
+1
View File
@@ -2,3 +2,4 @@ arb-dir: lib/l10n
template-arb-file: app_es.arb
output-localization-file: app_localizations.dart
output-class: L10n
synthetic-package: false
+108
View File
@@ -0,0 +1,108 @@
/// Utilidad para leer datos del SQLite de CDK.
/// Usado para:
/// - Debug de keyset counters
/// - Leer input_fee_ppk para cálculo de fees
/// - Contar proofs unspent para consolidación P2PK
library;
import 'package:flutter/foundation.dart';
import 'package:sqflite/sqflite.dart';
import 'package:path_provider/path_provider.dart';
class KeysetDebug {
static Database? _db;
/// Abre la DB de CDK en modo solo lectura.
static Future<Database> _getDb() async {
if (_db != null && _db!.isOpen) return _db!;
final dir = await getApplicationDocumentsDirectory();
final dbPath = '${dir.path}/elcaju_wallet.sqlite';
_db = await openDatabase(dbPath, readOnly: true);
return _db!;
}
/// Lee todos los keysets con sus counters y los imprime.
/// Retorna el counter del keyset activo (si hay uno).
static Future<void> logCounters(String label) async {
try {
final db = await _getDb();
final rows = await db.rawQuery(
'SELECT id, unit, active, counter FROM keyset ORDER BY active DESC, unit ASC',
);
debugPrint('[COUNTER DEBUG] ===== $label =====');
debugPrint('[COUNTER DEBUG] Total keysets: ${rows.length}');
for (final row in rows) {
final id = row['id'] as String?;
final unit = row['unit'] as String?;
final active = row['active'] as int?;
final counter = row['counter'] as int?;
final shortId = (id != null && id.length > 12) ? id.substring(0, 12) : id;
debugPrint(
'[COUNTER DEBUG] keyset=$shortId unit=$unit active=$active counter=$counter',
);
}
} catch (e) {
debugPrint('[COUNTER DEBUG] Error leyendo counters: $e');
}
}
/// Lee el input_fee_ppk del keyset activo para un mint y unidad.
///
/// IMPORTANTE: Lee directamente del schema interno de CDK (tabla `keyset`,
/// columna `input_fee_ppk`). Escrito para CDK 0.13.4 (cdk-flutter actual).
/// Si CDK cambia el schema (ej: en 0.14.x con rusqlite), este query puede
/// fallar. En caso de error retorna -1 (asume fees > 0) para bloquear P2PK
/// de forma segura — nunca retornar 0 en error porque permitiria P2PK en
/// mints con fees, causando perdida de fondos.
///
/// TODO: Revisar este codigo al actualizar cdk-flutter a CDK 0.14.x.
/// Idealmente cdk-flutter deberia exponer getActiveKeyset().inputFeePpk
/// via API publica en lugar de leer SQLite directamente.
static Future<int> getInputFeePpk(String mintUrl, String unit) async {
try {
final db = await _getDb();
final rows = await db.rawQuery(
'SELECT input_fee_ppk FROM keyset WHERE active=1 AND unit=? AND mint_url=?',
[unit, mintUrl],
);
if (rows.isEmpty) {
debugPrint('[KEYSET DEBUG] No active keyset found for $mintUrl/$unit');
return -1;
}
return (rows.first['input_fee_ppk'] as int?) ?? 0;
} catch (e) {
debugPrint('[KEYSET DEBUG] Error leyendo input_fee_ppk: $e');
// Fail-safe: asumir fees > 0 para bloquear P2PK ante schema desconocido
return -1;
}
}
/// Cuenta los proofs UNSPENT de un mint y unidad.
/// Retorna 0 si no se encuentra o hay error.
static Future<int> getUnspentProofCount(String mintUrl, String unit) async {
try {
final db = await _getDb();
final rows = await db.rawQuery(
'SELECT COUNT(*) as count FROM proof WHERE state=? AND mint_url=? AND unit=?',
['UNSPENT', mintUrl, unit],
);
if (rows.isEmpty) return 0;
return (rows.first['count'] as int?) ?? 0;
} catch (e) {
debugPrint('[KEYSET DEBUG] Error contando proofs: $e');
return 0;
}
}
/// Cierra la DB (llamar al final si es necesario).
static Future<void> close() async {
await _db?.close();
_db = null;
}
}
+4 -1
View File
@@ -52,6 +52,7 @@
}
},
"needWords": "(du brauchst 12 oder 24)",
"restoreScanningMint": "Mint wird nach vorhandenen Token durchsucht...",
"restoreError": "Wiederherstellungsfehler: {error}",
"@restoreError": {
"placeholders": {
@@ -401,6 +402,8 @@
"delete": "Löschen",
"offlineSend": "Offline senden",
"selectAll": "Alle",
"deselectAll": "Keine",
"selectNotesToSend": "Wähle die zu sendenden Notizen:",
"totalToSend": "Gesamt zu senden",
"notesSelected": "{count} Notizen ausgewählt",
@@ -501,7 +504,7 @@
"p2pkImportNsec": "nsec importieren",
"p2pkImport": "Importieren",
"p2pkEnterLabel": "Name für diesen Schlüssel",
"p2pkLockToKey": "An öffentlichen Schlüssel sperren",
"p2pkLockToKey": "Senden mit P2PK-Signatur",
"p2pkLockDescription": "Nur der Empfänger kann einlösen",
"p2pkReceiverPubkey": "npub1... oder hex (64/66 Zeichen)",
"p2pkInvalidPubkey": "Ungültiger öffentlicher Schlüssel",
+4 -1
View File
@@ -47,6 +47,7 @@
"seedPlaceholder": "word1 word2 word3 ...",
"wordCount": "{count} words",
"needWords": "(you need 12 or 24)",
"restoreScanningMint": "Scanning mint for existing tokens...",
"restoreError": "Restore error: {error}",
"homeTitle": "Home",
@@ -296,6 +297,8 @@
"delete": "Delete",
"offlineSend": "Offline Send",
"selectAll": "All",
"deselectAll": "None",
"selectNotesToSend": "Select the notes you want to send:",
"totalToSend": "Total to send",
"notesSelected": "{count} notes selected",
@@ -382,7 +385,7 @@
"p2pkImportNsec": "Import nsec",
"p2pkImport": "Import",
"p2pkEnterLabel": "Name for this key",
"p2pkLockToKey": "Lock to public key",
"p2pkLockToKey": "Send with P2PK signature",
"p2pkLockDescription": "Only the recipient can claim",
"p2pkReceiverPubkey": "npub1... or hex (64/66 chars)",
"p2pkInvalidPubkey": "Invalid public key",
+4 -1
View File
@@ -52,6 +52,7 @@
}
},
"needWords": "(necesitas 12 o 24)",
"restoreScanningMint": "Escaneando mint en busca de tokens...",
"restoreError": "Error al restaurar: {error}",
"@restoreError": {
"placeholders": {
@@ -401,6 +402,8 @@
"delete": "Eliminar",
"offlineSend": "Envío Offline",
"selectAll": "Todo",
"deselectAll": "Ninguno",
"selectNotesToSend": "Selecciona las notas que deseas enviar:",
"totalToSend": "Total a enviar",
"notesSelected": "{count} notas seleccionadas",
@@ -502,7 +505,7 @@
"p2pkImportNsec": "Importar nsec",
"p2pkImport": "Importar",
"p2pkEnterLabel": "Nombre para esta clave",
"p2pkLockToKey": "Bloquear a clave pública",
"p2pkLockToKey": "Envío con firma P2PK",
"p2pkLockDescription": "Solo el destinatario podrá reclamar",
"p2pkReceiverPubkey": "npub1... o hex (64/66 caracteres)",
"p2pkInvalidPubkey": "Clave pública inválida",
+4 -1
View File
@@ -52,6 +52,7 @@
}
},
"needWords": "(vous avez besoin de 12 ou 24)",
"restoreScanningMint": "Recherche de tokens sur le mint...",
"restoreError": "Erreur de restauration : {error}",
"@restoreError": {
"placeholders": {
@@ -401,6 +402,8 @@
"delete": "Supprimer",
"offlineSend": "Envoi hors ligne",
"selectAll": "Tout",
"deselectAll": "Aucun",
"selectNotesToSend": "Sélectionnez les notes à envoyer :",
"totalToSend": "Total à envoyer",
"notesSelected": "{count} notes sélectionnées",
@@ -501,7 +504,7 @@
"p2pkImportNsec": "Importer nsec",
"p2pkImport": "Importer",
"p2pkEnterLabel": "Nom pour cette clé",
"p2pkLockToKey": "Verrouiller à la clé publique",
"p2pkLockToKey": "Envoi avec signature P2PK",
"p2pkLockDescription": "Seul le destinataire peut réclamer",
"p2pkReceiverPubkey": "npub1... ou hex (64/66 caractères)",
"p2pkInvalidPubkey": "Clé publique invalide",
+4 -1
View File
@@ -52,6 +52,7 @@
}
},
"needWords": "(servono 12 o 24)",
"restoreScanningMint": "Scansione del mint per token esistenti...",
"restoreError": "Errore di ripristino: {error}",
"@restoreError": {
"placeholders": {
@@ -401,6 +402,8 @@
"delete": "Elimina",
"offlineSend": "Invio offline",
"selectAll": "Tutto",
"deselectAll": "Nessuno",
"selectNotesToSend": "Seleziona le note da inviare:",
"totalToSend": "Totale da inviare",
"notesSelected": "{count} note selezionate",
@@ -501,7 +504,7 @@
"p2pkImportNsec": "Importa nsec",
"p2pkImport": "Importa",
"p2pkEnterLabel": "Nome per questa chiave",
"p2pkLockToKey": "Blocca a chiave pubblica",
"p2pkLockToKey": "Invio con firma P2PK",
"p2pkLockDescription": "Solo il destinatario può riscattare",
"p2pkReceiverPubkey": "npub1... o hex (64/66 caratteri)",
"p2pkInvalidPubkey": "Chiave pubblica non valida",
+4 -1
View File
@@ -52,6 +52,7 @@
}
},
"needWords": "12語または24語が必要)",
"restoreScanningMint": "ミントで既存のトークンをスキャン中...",
"restoreError": "復元エラー:{error}",
"@restoreError": {
"placeholders": {
@@ -401,6 +402,8 @@
"delete": "削除",
"offlineSend": "オフライン送金",
"selectAll": "すべて",
"deselectAll": "なし",
"selectNotesToSend": "送信するノートを選択:",
"totalToSend": "送金合計",
"notesSelected": "{count}件のノートを選択",
@@ -501,7 +504,7 @@
"p2pkImportNsec": "nsecをインポート",
"p2pkImport": "インポート",
"p2pkEnterLabel": "この鍵の名前",
"p2pkLockToKey": "公開鍵にロック",
"p2pkLockToKey": "P2PK署名で送信",
"p2pkLockDescription": "受取人のみが請求可能",
"p2pkReceiverPubkey": "npub1... または hex64/66文字)",
"p2pkInvalidPubkey": "無効な公開鍵",
+4 -1
View File
@@ -52,6 +52,7 @@
}
},
"needWords": "(12개 또는 24개 필요)",
"restoreScanningMint": "민트에서 기존 토큰 검색 중...",
"restoreError": "복구 오류: {error}",
"@restoreError": {
"placeholders": {
@@ -401,6 +402,8 @@
"delete": "삭제",
"offlineSend": "오프라인 전송",
"selectAll": "전체",
"deselectAll": "없음",
"selectNotesToSend": "보낼 노트를 선택하세요:",
"totalToSend": "보낼 총액",
"notesSelected": "{count}개 노트 선택됨",
@@ -501,7 +504,7 @@
"p2pkImportNsec": "nsec 가져오기",
"p2pkImport": "가져오기",
"p2pkEnterLabel": "이 키의 이름",
"p2pkLockToKey": "공개 키로 잠금",
"p2pkLockToKey": "P2PK 서명으로 전송",
"p2pkLockDescription": "수신자만 청구 가능",
"p2pkReceiverPubkey": "npub1... 또는 hex (64/66자)",
"p2pkInvalidPubkey": "유효하지 않은 공개 키",
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+4 -1
View File
@@ -52,6 +52,7 @@
}
},
"needWords": "(você precisa de 12 ou 24)",
"restoreScanningMint": "Escaneando mint em busca de tokens...",
"restoreError": "Erro ao restaurar: {error}",
"@restoreError": {
"placeholders": {
@@ -401,6 +402,8 @@
"delete": "Excluir",
"offlineSend": "Envio Offline",
"selectAll": "Tudo",
"deselectAll": "Nenhum",
"selectNotesToSend": "Selecione as notas que deseja enviar:",
"totalToSend": "Total a enviar",
"notesSelected": "{count} notas selecionadas",
@@ -501,7 +504,7 @@
"p2pkImportNsec": "Importar nsec",
"p2pkImport": "Importar",
"p2pkEnterLabel": "Nome para esta chave",
"p2pkLockToKey": "Bloquear para chave pública",
"p2pkLockToKey": "Envio com assinatura P2PK",
"p2pkLockDescription": "Apenas o destinatário pode resgatar",
"p2pkReceiverPubkey": "npub1... ou hex (64/66 caracteres)",
"p2pkInvalidPubkey": "Chave pública inválida",
+4 -1
View File
@@ -52,6 +52,7 @@
}
},
"needWords": "(нужно 12 или 24)",
"restoreScanningMint": "Сканирование минта на наличие токенов...",
"restoreError": "Ошибка восстановления: {error}",
"@restoreError": {
"placeholders": {
@@ -401,6 +402,8 @@
"delete": "Удалить",
"offlineSend": "Офлайн отправка",
"selectAll": "Все",
"deselectAll": "Ничего",
"selectNotesToSend": "Выберите заметки для отправки:",
"totalToSend": "Итого к отправке",
"notesSelected": "{count} заметок выбрано",
@@ -501,7 +504,7 @@
"p2pkImportNsec": "Импортировать nsec",
"p2pkImport": "Импортировать",
"p2pkEnterLabel": "Имя для этого ключа",
"p2pkLockToKey": "Заблокировать на публичный ключ",
"p2pkLockToKey": "Отправка с подписью P2PK",
"p2pkLockDescription": "Только получатель может получить",
"p2pkReceiverPubkey": "npub1... или hex (64/66 символов)",
"p2pkInvalidPubkey": "Недействительный публичный ключ",
+4 -1
View File
@@ -52,6 +52,7 @@
}
},
"needWords": "(unahitaji 12 au 24)",
"restoreScanningMint": "Inatafuta tokeni zilizopo kwenye mint...",
"restoreError": "Hitilafu ya kurejesha: {error}",
"@restoreError": {
"placeholders": {
@@ -401,6 +402,8 @@
"delete": "Futa",
"offlineSend": "Tuma Nje ya Mtandao",
"selectAll": "Zote",
"deselectAll": "Hakuna",
"selectNotesToSend": "Chagua noti unazotaka kutuma:",
"totalToSend": "Jumla ya kutuma",
"notesSelected": "noti {count} zimechaguliwa",
@@ -501,7 +504,7 @@
"p2pkImportNsec": "Ingiza nsec",
"p2pkImport": "Ingiza",
"p2pkEnterLabel": "Jina la ufunguo huu",
"p2pkLockToKey": "Funga kwa ufunguo wa umma",
"p2pkLockToKey": "Tuma kwa saini ya P2PK",
"p2pkLockDescription": "Mpokeaji pekee anaweza kudai",
"p2pkReceiverPubkey": "npub1... au hex (herufi 64/66)",
"p2pkInvalidPubkey": "Ufunguo wa umma batili",
+4 -1
View File
@@ -52,6 +52,7 @@
}
},
"needWords": "(需要12或24个)",
"restoreScanningMint": "正在扫描铸造厂中的现有代币...",
"restoreError": "恢复错误:{error}",
"@restoreError": {
"placeholders": {
@@ -401,6 +402,8 @@
"delete": "删除",
"offlineSend": "离线发送",
"selectAll": "全部",
"deselectAll": "无",
"selectNotesToSend": "选择要发送的票据:",
"totalToSend": "发送总额",
"notesSelected": "已选择 {count} 张票据",
@@ -501,7 +504,7 @@
"p2pkImportNsec": "导入nsec",
"p2pkImport": "导入",
"p2pkEnterLabel": "此密钥的名称",
"p2pkLockToKey": "锁定到公钥",
"p2pkLockToKey": "P2PK签名发送",
"p2pkLockDescription": "只有接收者可以领取",
"p2pkReceiverPubkey": "npub1... 或 hex64/66字符)",
"p2pkInvalidPubkey": "无效的公钥",
+10 -1
View File
@@ -3,12 +3,13 @@ import 'package:flutter/services.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:provider/provider.dart';
import 'package:cdk_flutter/cdk_flutter.dart' hide WalletProvider;
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:elcaju/l10n/app_localizations.dart';
import 'core/theme/app_theme.dart';
import 'providers/wallet_provider.dart';
import 'providers/settings_provider.dart';
import 'providers/price_provider.dart';
import 'providers/p2pk_provider.dart';
import 'widgets/effects/cashu_confetti.dart';
import 'screens/1_splash/splash_screen.dart';
void main() async {
@@ -83,6 +84,14 @@ class ElCajuApp extends StatelessWidget {
],
locale: Locale(settingsProvider.locale),
builder: (context, child) {
final walletProvider = context.read<WalletProvider>();
return CashuConfetti(
controller: walletProvider.confettiController,
child: child ?? const SizedBox.shrink(),
);
},
home: const SplashScreen(),
);
}
+291 -36
View File
@@ -1,3 +1,4 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:flutter/foundation.dart';
@@ -10,6 +11,8 @@ import 'package:uuid/uuid.dart';
import '../data/transaction_meta_storage.dart';
import '../data/pending_token.dart';
import '../data/pending_token_storage.dart';
import '../core/utils/keyset_debug.dart';
import '../widgets/effects/cashu_confetti.dart';
/// Helper class para info de token parseado
class TokenInfo {
@@ -39,6 +42,9 @@ class WalletProvider extends ChangeNotifier {
/// Storage para tokens pendientes de reclamar (Receive Later)
final PendingTokenStorage _pendingTokenStorage = PendingTokenStorage();
/// Controller global de confetti para celebrar recepciones
final CashuConfettiController confettiController = CashuConfettiController();
/// Generador de UUIDs
static const _uuid = Uuid();
@@ -67,6 +73,7 @@ class WalletProvider extends ChangeNotifier {
static const _mintsKey = 'wallet_mints';
static const _activeMintKey = 'wallet_active_mint';
static const _activeUnitKey = 'wallet_active_unit';
static const _pendingMintInvoicesKey = 'pending_mint_invoices';
/// Mint de Cuba Bitcoin - siempre aparece primero en la lista
static const cubaBitcoinMint = 'https://mint.cubabitcoin.org';
@@ -846,16 +853,55 @@ class WalletProvider extends ChangeNotifier {
? ReceiveOptions(signingKeys: [p2pkPrivateKey])
: null;
// DEBUG: counters antes de receive
await KeysetDebug.logCounters('BEFORE receive ($unit)');
// DEBUG: transacciones ANTES del receive
await _debugLogTransactions(wallet, 'BEFORE receive');
final amount = await wallet.receive(token: token, opts: opts);
// DEBUG: counters después de receive
await KeysetDebug.logCounters('AFTER receive ($unit)');
// DEBUG: transacciones DESPUÉS del receive (antes de checkPending)
await _debugLogTransactions(wallet, 'AFTER receive (before checkPending)');
// Guardar metadata para la transacción recién creada
await _saveMetaForRecentReceive(wallet, encodedToken);
// Verificar transacciones pendientes para actualizar outgoing pending → settled
try {
await wallet.checkPendingTransactions();
} catch (e) {
debugPrint('Check pending after receive failed: $e');
}
// DEBUG: transacciones DESPUÉS de checkPending
await _debugLogTransactions(wallet, 'AFTER checkPending');
debugPrint('Token recibido: $amount $unit en $mintUrl');
confettiController.fire();
notifyListeners();
return amount;
}
/// DEBUG: Lista todas las transacciones de un wallet para diagnóstico.
Future<void> _debugLogTransactions(Wallet wallet, String label) async {
try {
final allTxs = await wallet.listTransactions();
debugPrint('[TX DEBUG] ===== $label =====');
debugPrint('[TX DEBUG] Total transacciones: ${allTxs.length}');
for (final tx in allTxs) {
final dir = tx.direction == TransactionDirection.incoming ? 'IN' : 'OUT';
final status = tx.status == TransactionStatus.pending ? 'PENDING' : 'SETTLED';
debugPrint('[TX DEBUG] $dir ${tx.amount} ${tx.unit} [$status] fee=${tx.fee} id=${tx.id.substring(0, 16)}...');
}
} catch (e) {
debugPrint('[TX DEBUG] Error listando transacciones: $e');
}
}
/// Guarda metadata para la transacción de receive más reciente.
Future<void> _saveMetaForRecentReceive(Wallet wallet, String tokenEncoded) async {
try {
@@ -921,11 +967,12 @@ class WalletProvider extends ChangeNotifier {
}
/// Prepara un envío P2PK (bloqueado a una clave pública).
/// includeFee: true asegura que se reserven proofs para cubrir el swap fee (CDK 0.15+).
Future<PreparedSend> prepareSendP2pk(BigInt amount, String pubkey) async {
final wallet = await getActiveWallet();
return await wallet.prepareSend(
amount: amount,
opts: SendOptions(pubkey: pubkey),
opts: SendOptions(pubkey: pubkey, includeFee: true),
);
}
@@ -982,39 +1029,42 @@ class WalletProvider extends ChangeNotifier {
}
/// Método de conveniencia: prepara y confirma en un solo paso.
/// Si confirmSend falla, libera proofs reservados con cancelSend.
Future<String> sendTokens(BigInt amount, String? memo) async {
final prepared = await prepareSend(amount);
return await confirmSend(prepared, memo);
}
/// Envía tokens P2PK.
/// Workaround CDK bug: limpia txs pending antes de enviar.
/// Ver: https://github.com/cashubtc/cdk-flutter/issues/3
Future<String> sendTokensP2pk(BigInt amount, String pubkey, String? memo) async {
// Workaround CDK bug: limpiar txs pending y proofs reservados antes de P2PK send
await _settlePendingTransactions();
final prepared = await prepareSendP2pk(amount, pubkey);
debugPrint('[SEND] prepareSend OK - fee=${prepared.fee}');
try {
return await confirmSend(prepared, memo);
final token = await confirmSend(prepared, memo);
debugPrint('[SEND] Send completed');
return token;
} catch (e) {
// Liberar proofs reservados si el envío falla
try {
await cancelSend(prepared);
} catch (_) {}
} catch (cancelErr) {
debugPrint('[SEND] cancelSend failed: $cancelErr');
}
rethrow;
}
}
/// Intenta liquidar transacciones pending sincronizando con el mint.
/// Workaround para bug de CDK donde P2PK sends pending bloquean envíos subsiguientes.
Future<void> _settlePendingTransactions() async {
/// 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 wallet = await getActiveWallet();
await wallet.checkPendingTransactions();
await wallet.reclaimReserved();
final token = await confirmSend(prepared, memo);
debugPrint('[P2PK] Send completed');
return token;
} catch (e) {
debugPrint('settlePendingTransactions failed (may be offline): $e');
try {
await cancelSend(prepared);
} catch (cancelErr) {
debugPrint('[P2PK] cancelSend failed: $cancelErr');
}
rethrow;
}
}
@@ -1035,34 +1085,161 @@ class WalletProvider extends ChangeNotifier {
// MINT (Depositar via Lightning)
// ============================================================
/// Suscripción activa al stream de mint (vive en el provider, no en la UI).
StreamSubscription<MintQuote>? _activeMintSubscription;
/// Controller del stream actual de mint (para cerrar al iniciar uno nuevo).
StreamController<MintQuote>? _activeMintController;
/// Inicia un depósito via Lightning.
/// Retorna Stream con estados: unpaid -> paid -> issued.
/// Guarda metadata type=lightning cuando se completa.
Stream<MintQuote> mintTokens(BigInt amount, String? description) {
/// La suscripción al CDK vive en el provider para que los side effects
/// (guardar metadata, confetti) ocurran aunque la UI se cierre.
Future<Stream<MintQuote>> mintTokens(BigInt amount, String? description) async {
final wallet = activeWallet;
if (wallet == null) {
throw Exception('No hay wallet activo');
}
final mintUrl = _activeMintUrl!;
final unit = _activeUnit;
String? invoiceBolt11;
// Wrapper del stream para capturar el invoice y guardar metadata
return wallet.mint(
// Cerrar controller anterior si existe (evitar leak)
if (_activeMintController != null && !_activeMintController!.isClosed) {
_activeMintController!.close();
}
// StreamController que la UI puede escuchar y cancelar libremente
final controller = StreamController<MintQuote>();
_activeMintController = controller;
// Cancelar suscripción anterior si existe (await evita race de callbacks)
await _activeMintSubscription?.cancel();
_activeMintSubscription = null;
// Suscribirse al stream del CDK desde el provider (persiste sin UI)
_activeMintSubscription = wallet.mint(
amount: amount,
description: description,
).map((quote) {
// Capturar el invoice cuando está en estado unpaid
if (quote.state == MintQuoteState.unpaid) {
invoiceBolt11 = quote.request;
).listen(
(quote) {
// Guardar invoice temprano en SharedPreferences
if (quote.state == MintQuoteState.unpaid) {
invoiceBolt11 = quote.request;
_savePendingMintInvoice(quote.id, quote.request, mintUrl, unit, amount);
}
// Cuando se completa, guardar metadata, confetti, limpiar pending
if (quote.state == MintQuoteState.issued && invoiceBolt11 != null) {
_saveMintMetadata(wallet, invoiceBolt11!);
_removePendingMintInvoice(quote.id);
}
// Reenviar a la UI (si sigue escuchando)
if (!controller.isClosed) {
controller.add(quote);
}
},
onError: (error) {
if (!controller.isClosed) {
controller.addError(error);
}
},
onDone: () {
_activeMintSubscription = null;
if (!controller.isClosed) {
controller.close();
}
},
);
return controller.stream;
}
/// Guarda un invoice de mint pendiente para recuperarlo después.
Future<void> _savePendingMintInvoice(
String quoteId, String invoice, String mintUrl, String unit, BigInt amount,
) async {
try {
final prefs = await SharedPreferences.getInstance();
final jsonStr = prefs.getString(_pendingMintInvoicesKey) ?? '{}';
final map = Map<String, dynamic>.from(jsonDecode(jsonStr));
map[quoteId] = {
'invoice': invoice,
'mintUrl': mintUrl,
'unit': unit,
'amount': amount.toString(),
'createdAt': DateTime.now().toIso8601String(),
};
await prefs.setString(_pendingMintInvoicesKey, jsonEncode(map));
debugPrint('Pending mint invoice guardado: $quoteId');
} catch (e) {
debugPrint('Error guardando pending mint invoice: $e');
}
}
/// Elimina un invoice de mint pendiente (ya fue procesado).
Future<void> _removePendingMintInvoice(String quoteId) async {
try {
final prefs = await SharedPreferences.getInstance();
final jsonStr = prefs.getString(_pendingMintInvoicesKey) ?? '{}';
final map = Map<String, dynamic>.from(jsonDecode(jsonStr));
map.remove(quoteId);
await prefs.setString(_pendingMintInvoicesKey, jsonEncode(map));
debugPrint('Pending mint invoice eliminado: $quoteId');
} catch (e) {
debugPrint('Error eliminando pending mint invoice: $e');
}
}
/// TTL para invoices de mint pendientes (24 horas).
static const _pendingMintInvoiceTtl = Duration(hours: 24);
/// Obtiene todos los invoices de mint pendientes, limpiando expirados.
Future<Map<String, dynamic>> _getPendingMintInvoices() async {
try {
final prefs = await SharedPreferences.getInstance();
final jsonStr = prefs.getString(_pendingMintInvoicesKey) ?? '{}';
final map = Map<String, dynamic>.from(jsonDecode(jsonStr));
// Filtrar expirados
final now = DateTime.now();
final expired = <String>[];
for (final entry in map.entries) {
final data = Map<String, dynamic>.from(entry.value);
final createdAt = DateTime.tryParse(data['createdAt'] ?? '');
if (createdAt == null || now.difference(createdAt) > _pendingMintInvoiceTtl) {
expired.add(entry.key);
}
}
// Cuando se completa, guardar metadata
if (quote.state == MintQuoteState.issued && invoiceBolt11 != null) {
_saveMintMetadata(wallet, invoiceBolt11!);
// Limpiar expirados si hay
if (expired.isNotEmpty) {
for (final key in expired) {
map.remove(key);
}
await prefs.setString(_pendingMintInvoicesKey, jsonEncode(map));
debugPrint('Pending mint invoices expirados eliminados: ${expired.length}');
}
return quote;
});
return map;
} catch (e) {
return {};
}
}
/// Busca un invoice pendiente que coincida con un mintUrl y unit.
/// Retorna el invoice string o null.
Future<String?> findPendingMintInvoice(String mintUrl, String unit) async {
final pending = await _getPendingMintInvoices();
for (final entry in pending.values) {
final data = Map<String, dynamic>.from(entry);
if (data['mintUrl'] == mintUrl && data['unit'] == unit) {
return data['invoice'] as String?;
}
}
return null;
}
/// Guarda metadata para una transacción de mint (Lightning deposit).
@@ -1083,6 +1260,8 @@ class WalletProvider extends ChangeNotifier {
),
);
debugPrint('Mint metadata guardada para tx ${recentTx.id}');
confettiController.fire();
notifyListeners();
}
} catch (e) {
debugPrint('Error guardando mint metadata: $e');
@@ -1180,8 +1359,20 @@ class WalletProvider extends ChangeNotifier {
/// Obtiene el tipo de una transacción (cashu o lightning).
/// Busca primero en metadata del CDK, luego en storage local.
/// Si no hay metadata y es incoming, asume Lightning (los receives Cashu
/// siempre guardan metadata inmediatamente).
/// Limitación conocida: transacciones anteriores al sistema de metadata
/// o donde el guardado falló silenciosamente serían clasificadas como Lightning.
TransactionType getTransactionType(Transaction tx) {
return _txMetaStorage.getType(tx.id, tx.metadata);
final type = _txMetaStorage.getType(tx.id, tx.metadata);
// Si el storage devuelve cashu por defecto pero no tiene metadata real,
// y la transacción es incoming → probablemente es Lightning
if (!_txMetaStorage.has(tx.id) &&
tx.metadata['type'] == null &&
tx.direction == TransactionDirection.incoming) {
return TransactionType.lightning;
}
return type;
}
/// Obtiene metadata adicional de una transacción.
@@ -1200,6 +1391,7 @@ class WalletProvider extends ChangeNotifier {
/// Verifica proofs pendientes en todos los wallets.
/// Llamar en background al iniciar la app.
/// También vincula transacciones incoming sin metadata con invoices pendientes.
Future<void> checkPendingTransactions() async {
for (final wallet in _wallets.values) {
try {
@@ -1209,6 +1401,59 @@ class WalletProvider extends ChangeNotifier {
debugPrint('Check pending failed: $e');
}
}
// Vincular transacciones incoming sin metadata con pending invoices
await _matchPendingMintInvoices();
}
/// Busca transacciones incoming sin metadata y las vincula con
/// invoices de mint pendientes guardados en SharedPreferences.
Future<void> _matchPendingMintInvoices() async {
try {
final pending = await _getPendingMintInvoices();
if (pending.isEmpty) return;
// Obtener todas las transacciones incoming
final allTxs = await getAllTransactions(
direction: TransactionDirection.incoming,
);
for (final tx in allTxs) {
// Solo procesar transacciones sin metadata
if (_txMetaStorage.has(tx.id)) continue;
// Buscar un pending invoice que coincida con mintUrl, unit y amount
String? matchedQuoteId;
String? matchedInvoice;
for (final entry in pending.entries) {
final data = Map<String, dynamic>.from(entry.value);
if (data['mintUrl'] == tx.mintUrl && data['unit'] == tx.unit && data['amount'] == tx.amount.toString()) {
matchedQuoteId = entry.key;
matchedInvoice = data['invoice'] as String?;
break;
}
}
if (matchedQuoteId != null && matchedInvoice != null) {
await _txMetaStorage.save(
tx.id,
TransactionMeta(
type: TransactionType.lightning,
invoice: matchedInvoice,
),
);
// Limpiar el pending invoice ya vinculado
await _removePendingMintInvoice(matchedQuoteId);
pending.remove(matchedQuoteId);
debugPrint('Matched pending mint invoice → tx ${tx.id}');
confettiController.fire();
notifyListeners();
}
}
} catch (e) {
debugPrint('Error matching pending mint invoices: $e');
}
}
/// Verifica si un token específico fue gastado.
@@ -1544,4 +1789,14 @@ class WalletProvider extends ChangeNotifier {
}
return cleaned;
}
@override
void dispose() {
_activeMintSubscription?.cancel();
if (_activeMintController != null && !_activeMintController!.isClosed) {
_activeMintController!.close();
}
confettiController.dispose();
super.dispose();
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:lucide_icons/lucide_icons.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:elcaju/l10n/app_localizations.dart';
import '../../core/constants/colors.dart';
import '../../core/utils/incoming_data_parser.dart';
import '../../widgets/scanner/qr_scanner_widget.dart';
+1 -1
View File
@@ -2,7 +2,7 @@ import 'dart:async';
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:elcaju/l10n/app_localizations.dart';
import '../../core/constants/colors.dart';
import '../../core/constants/dimensions.dart';
import '../../widgets/common/gradient_background.dart';
@@ -1,7 +1,7 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:lucide_icons/lucide_icons.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:elcaju/l10n/app_localizations.dart';
import '../../core/constants/colors.dart';
import '../../core/constants/dimensions.dart';
import '../../widgets/common/gradient_background.dart';
@@ -1,7 +1,7 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:lucide_icons/lucide_icons.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:elcaju/l10n/app_localizations.dart';
import '../../core/constants/colors.dart';
import '../../core/constants/dimensions.dart';
import '../../widgets/common/gradient_background.dart';
@@ -1,7 +1,7 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:lucide_icons/lucide_icons.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:elcaju/l10n/app_localizations.dart';
import '../../core/constants/colors.dart';
import '../../core/constants/dimensions.dart';
import '../../widgets/common/gradient_background.dart';
@@ -25,6 +25,7 @@ class _RestoreWalletScreenState extends State<RestoreWalletScreen> {
final FocusNode _focusNode = FocusNode();
bool _isRestoring = false;
String? _errorMessage;
String? _statusMessage;
int get _wordCount {
final text = _seedController.text.trim();
@@ -56,18 +57,19 @@ class _RestoreWalletScreenState extends State<RestoreWalletScreen> {
});
try {
final mnemonic = _seedController.text.trim().toLowerCase();
final mnemonic =
_seedController.text.trim().toLowerCase().replaceAll(RegExp(r'\s+'), ' ');
final walletProvider = context.read<WalletProvider>();
final settingsProvider = context.read<SettingsProvider>();
final p2pkProvider = context.read<P2PKProvider>();
// Guardar mnemonic de forma segura
await settingsProvider.saveMnemonic(mnemonic);
// Inicializar wallet (esto valida el mnemonic internamente)
// Si el mnemonic es inválido, cdk_flutter lanzará una excepción
// Inicializar wallet primero (valida el mnemonic internamente).
// Si es inválido, cdk_flutter lanzará una excepción antes de persistir.
await walletProvider.initialize(mnemonic);
// Solo guardar mnemonic si initialize() pasó sin error
await settingsProvider.saveMnemonic(mnemonic);
// Inicializar P2PK (derivar clave principal del mnemonic)
try {
await p2pkProvider.initialize(mnemonic);
@@ -75,6 +77,21 @@ class _RestoreWalletScreenState extends State<RestoreWalletScreen> {
debugPrint('[RestoreWalletScreen] Error initializing P2PK (non-fatal): $e');
}
// Escanear mint activo para recuperar tokens existentes (NUT-13)
final activeMint = walletProvider.activeMintUrl;
if (activeMint != null && mounted) {
final l10n = L10n.of(context)!;
setState(() {
_statusMessage = l10n.restoreScanningMint;
});
try {
await walletProvider.restoreFromMint(activeMint);
} catch (e) {
debugPrint('[RestoreWalletScreen] Error scanning mint (non-fatal): $e');
}
}
if (mounted) {
Navigator.pushAndRemoveUntil(
context,
@@ -83,9 +100,13 @@ class _RestoreWalletScreenState extends State<RestoreWalletScreen> {
);
}
} catch (e) {
// Revertir el guardado del mnemonic si falló la inicialización
final settingsProvider = context.read<SettingsProvider>();
await settingsProvider.deleteWallet();
// Limpiar cualquier estado parcial si algo falló
try {
final settingsProvider = context.read<SettingsProvider>();
await settingsProvider.deleteWallet();
} catch (cleanupError) {
debugPrint('Error during restore cleanup: $cleanupError');
}
final l10n = L10n.of(context)!;
setState(() {
@@ -260,6 +281,22 @@ class _RestoreWalletScreenState extends State<RestoreWalletScreen> {
? _restoreWallet
: null,
),
if (_isRestoring && _statusMessage != null) ...[
const SizedBox(height: AppDimensions.paddingMedium),
SizedBox(
width: double.infinity,
child: Text(
_statusMessage!,
textAlign: TextAlign.center,
style: TextStyle(
fontFamily: 'Inter',
fontSize: 14,
color: AppColors.textSecondary.withValues(alpha: 0.8),
),
),
),
],
],
),
),
+1 -1
View File
@@ -1,7 +1,7 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:lucide_icons/lucide_icons.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:elcaju/l10n/app_localizations.dart';
import '../../core/constants/colors.dart';
import '../../core/constants/dimensions.dart';
import '../../providers/settings_provider.dart';
+6 -18
View File
@@ -1,7 +1,7 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:lucide_icons/lucide_icons.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:elcaju/l10n/app_localizations.dart';
import '../../core/constants/colors.dart';
import '../../core/constants/dimensions.dart';
import '../../core/utils/formatters.dart';
@@ -9,7 +9,6 @@ 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/effects/cashu_confetti.dart';
import '../../providers/wallet_provider.dart';
import '../../providers/settings_provider.dart';
import '../4_receive/receive_screen.dart';
@@ -34,9 +33,6 @@ class _HomeScreenState extends State<HomeScreen> {
// Estado local
bool _isBalanceVisible = true;
// Controller para el efecto confeti
final CashuConfettiController _confettiController = CashuConfettiController();
@override
void initState() {
super.initState();
@@ -46,11 +42,8 @@ class _HomeScreenState extends State<HomeScreen> {
});
}
@override
void dispose() {
_confettiController.dispose();
super.dispose();
}
/// Dispara confetti global desde WalletProvider
void _fireConfetti() => context.read<WalletProvider>().confettiController.fire();
/// Verifica y reclama automáticamente tokens pendientes
Future<void> _checkPendingTokens() async {
@@ -64,9 +57,7 @@ class _HomeScreenState extends State<HomeScreen> {
final unit = (result['unit'] as String?) ?? walletProvider.activeUnit;
if (claimed > 0 && mounted) {
// Disparar confetti
_confettiController.fire();
// Confetti se dispara globalmente desde WalletProvider.receiveToken
// Mostrar snackbar
final l10n = L10n.of(context)!;
ScaffoldMessenger.of(context).showSnackBar(
@@ -84,9 +75,7 @@ class _HomeScreenState extends State<HomeScreen> {
@override
Widget build(BuildContext context) {
return CashuConfetti(
controller: _confettiController,
child: GradientBackground(
return GradientBackground(
child: Scaffold(
backgroundColor: Colors.transparent,
body: SafeArea(
@@ -122,7 +111,6 @@ class _HomeScreenState extends State<HomeScreen> {
),
),
),
),
);
}
@@ -161,7 +149,7 @@ class _HomeScreenState extends State<HomeScreen> {
// El Caju (derecha) - toca para confeti
GestureDetector(
onTap: () => _confettiController.fire(),
onTap: () => _fireConfetti(),
child: Image.asset(
'assets/img/elcajucubano.png',
width: 56,
+3 -11
View File
@@ -2,7 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import 'package:lucide_icons/lucide_icons.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:elcaju/l10n/app_localizations.dart';
import '../../core/constants/colors.dart';
import '../../core/constants/dimensions.dart';
import '../../core/utils/formatters.dart';
@@ -11,7 +11,6 @@ import '../../core/utils/nostr_utils.dart';
import '../../widgets/common/gradient_background.dart';
import '../../widgets/common/glass_card.dart';
import '../../widgets/common/primary_button.dart';
import '../../widgets/effects/cashu_confetti.dart';
import '../../providers/wallet_provider.dart';
import '../../providers/p2pk_provider.dart';
import '../10_scanner/scan_screen.dart';
@@ -29,8 +28,6 @@ class ReceiveScreen extends StatefulWidget {
class _ReceiveScreenState extends State<ReceiveScreen> {
final TextEditingController _tokenController = TextEditingController();
final CashuConfettiController _confettiController = CashuConfettiController();
// Estado del token
bool _isValidToken = false;
bool _isProcessing = false;
@@ -65,15 +62,12 @@ class _ReceiveScreenState extends State<ReceiveScreen> {
void dispose() {
_tokenController.dispose();
_manualKeyController.dispose();
_confettiController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return CashuConfetti(
controller: _confettiController,
child: GradientBackground(
return GradientBackground(
child: Scaffold(
backgroundColor: Colors.transparent,
appBar: AppBar(
@@ -96,7 +90,6 @@ class _ReceiveScreenState extends State<ReceiveScreen> {
child: _showSuccess ? _buildSuccessView() : _buildReceiveForm(),
),
),
),
);
}
@@ -1027,8 +1020,7 @@ class _ReceiveScreenState extends State<ReceiveScreen> {
_isProcessing = false;
});
// Disparar confetti
_confettiController.fire();
// Confetti se dispara globalmente desde WalletProvider.receiveToken
}
} catch (e) {
if (!mounted) return;
+13 -2
View File
@@ -1,6 +1,6 @@
import 'package:flutter/material.dart';
import 'package:lucide_icons/lucide_icons.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:elcaju/l10n/app_localizations.dart';
import '../../core/constants/colors.dart';
import '../../core/constants/dimensions.dart';
import '../../core/models/proof.dart';
@@ -132,12 +132,23 @@ class _OfflineSendScreenState extends State<OfflineSendScreen> {
actions: [
// Botón seleccionar/deseleccionar todos
if (_availableProofs.isNotEmpty)
IconButton(
TextButton.icon(
icon: Icon(
_selectedIds.length == _availableProofs.length
? LucideIcons.checkSquare
: LucideIcons.square,
color: AppColors.primaryAction,
size: 20,
),
label: Text(
_selectedIds.length == _availableProofs.length
? L10n.of(context)!.deselectAll
: L10n.of(context)!.selectAll,
style: const TextStyle(
color: AppColors.primaryAction,
fontFamily: 'Inter',
fontSize: 13,
),
),
onPressed: _selectedIds.length == _availableProofs.length
? _clearSelection
+110 -86
View File
@@ -3,7 +3,7 @@ import 'package:flutter/services.dart';
import 'package:http/http.dart' as http;
import 'package:provider/provider.dart';
import 'package:lucide_icons/lucide_icons.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:elcaju/l10n/app_localizations.dart';
import '../../core/constants/colors.dart';
import '../../core/constants/dimensions.dart';
import '../../core/utils/formatters.dart';
@@ -13,6 +13,7 @@ import '../../widgets/common/primary_button.dart';
import '../../widgets/common/numpad_widget.dart';
import '../../providers/wallet_provider.dart';
import '../../core/utils/nostr_utils.dart';
import '../../widgets/scanner/qr_scanner_widget.dart';
import 'share_token_screen.dart';
import 'offline_send_screen.dart';
@@ -277,9 +278,8 @@ class _SendScreenState extends State<SendScreen> {
final l10n = L10n.of(context)!;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Toggle P2PK
GlassCard(
padding: const EdgeInsets.symmetric(
horizontal: AppDimensions.paddingMedium,
@@ -319,88 +319,64 @@ class _SendScreenState extends State<SendScreen> {
),
Switch(
value: _useP2PK,
onChanged: (value) {
setState(() {
_useP2PK = value;
if (!value) {
_pubkeyController.clear();
_pubkeyError = null;
}
});
},
onChanged: (value) => setState(() => _useP2PK = value),
activeColor: AppColors.primaryAction,
),
],
),
),
// Campo pubkey (visible solo si P2PK está activo)
// Campo de pubkey cuando P2PK está activo
if (_useP2PK) ...[
const SizedBox(height: AppDimensions.paddingSmall),
GlassCard(
padding: const EdgeInsets.all(AppDimensions.paddingSmall),
child: TextField(
controller: _pubkeyController,
style: const TextStyle(
fontFamily: 'Inter',
fontSize: 14,
color: Colors.white,
),
decoration: InputDecoration(
hintText: l10n.p2pkReceiverPubkey,
hintStyle: TextStyle(
fontFamily: 'Inter',
fontSize: 14,
color: Colors.white.withValues(alpha: 0.3),
),
border: InputBorder.none,
errorText: _pubkeyError,
errorStyle: const TextStyle(
fontFamily: 'Inter',
fontSize: 12,
color: AppColors.error,
),
prefixIcon: Icon(
LucideIcons.key,
color: AppColors.textSecondary.withValues(alpha: 0.5),
size: 18,
),
suffixIcon: IconButton(
icon: Icon(
LucideIcons.clipboard,
color: AppColors.textSecondary.withValues(alpha: 0.5),
size: 18,
),
onPressed: _pastePubkey,
),
),
onChanged: _validatePubkey,
),
),
const SizedBox(height: AppDimensions.paddingSmall),
// Advertencia experimental
Row(
children: [
Icon(
LucideIcons.alertTriangle,
size: 14,
color: AppColors.warning.withValues(alpha: 0.8),
),
const SizedBox(width: 6),
Expanded(
child: Text(
l10n.p2pkExperimental,
style: TextStyle(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextField(
controller: _pubkeyController,
onChanged: _validatePubkey,
maxLines: 1,
style: const TextStyle(
fontFamily: 'Inter',
fontSize: 11,
color: AppColors.warning.withValues(alpha: 0.8),
fontSize: 14,
color: Colors.white,
),
decoration: InputDecoration(
hintText: l10n.p2pkReceiverPubkey,
hintStyle: TextStyle(
fontFamily: 'Inter',
fontSize: 14,
color: Colors.white.withValues(alpha: 0.3),
),
border: InputBorder.none,
suffixIcon: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: const Icon(LucideIcons.clipboard, size: 20),
color: AppColors.textSecondary,
onPressed: _pastePubkey,
),
IconButton(
icon: const Icon(LucideIcons.scanLine, size: 20),
color: AppColors.textSecondary,
tooltip: l10n.scanQrCode,
onPressed: _scanPubkey,
),
],
),
errorText: _pubkeyError,
errorStyle: TextStyle(
fontFamily: 'Inter',
fontSize: 12,
color: AppColors.error,
),
),
),
),
],
],
),
),
],
],
@@ -421,12 +397,71 @@ class _SendScreenState extends State<SendScreen> {
Future<void> _pastePubkey() async {
final clipboardData = await Clipboard.getData(Clipboard.kTextPlain);
if (clipboardData?.text != null) {
_pubkeyController.text = clipboardData!.text!;
_validatePubkey(clipboardData.text!);
if (!mounted) return;
final text = clipboardData?.text?.trim();
if (text != null && text.isNotEmpty) {
_pubkeyController.text = text;
_validatePubkey(text);
}
}
Future<void> _scanPubkey() async {
final result = await Navigator.push<String>(
context,
MaterialPageRoute(
builder: (context) => Scaffold(
backgroundColor: Colors.black,
appBar: AppBar(
backgroundColor: Colors.transparent,
elevation: 0,
leading: IconButton(
icon: const Icon(LucideIcons.x, color: Colors.white),
onPressed: () => Navigator.pop(context),
),
title: Text(
L10n.of(context)!.scanQrCode,
style: const TextStyle(
fontFamily: 'Inter',
fontWeight: FontWeight.w600,
color: Colors.white,
),
),
centerTitle: true,
),
body: QrScannerWidget(
onDetect: (data) {
final trimmed = data.trim();
if (NostrUtils.isValidP2PKPubkey(trimmed)) {
Navigator.pop(context, trimmed);
} else {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(L10n.of(context)!.p2pkInvalidPubkey),
duration: const Duration(seconds: 2),
),
);
}
},
onError: (error) {
debugPrint('[P2PK Scan] $error');
Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(L10n.of(context)!.cameraPermissionDenied),
backgroundColor: AppColors.error,
),
);
},
),
),
),
);
if (!mounted || result == null) return;
_pubkeyController.text = result;
_validatePubkey(result);
}
Widget _buildErrorMessage() {
return Container(
margin: const EdgeInsets.only(bottom: AppDimensions.paddingMedium),
@@ -590,17 +625,6 @@ class _SendScreenState extends State<SendScreen> {
// P2PK: bloquear a clave pública si está habilitado
if (_useP2PK && _pubkeyController.text.isNotEmpty) {
// Verificar si hay txs P2PK pending (workaround CDK bug)
final hasPending = await walletProvider.hasPendingOutgoingTransactions();
if (hasPending) {
if (!mounted) return;
setState(() {
_errorMessage = l10n.p2pkPendingSendWarning;
_isProcessing = false;
});
return;
}
// Usar normalizeToCompressedHex para obtener formato SEC1 (66 chars)
final pubkeyHex = NostrUtils.normalizeToCompressedHex(_pubkeyController.text);
if (pubkeyHex == null) {
+1 -1
View File
@@ -5,7 +5,7 @@ import 'package:qr_flutter/qr_flutter.dart';
import 'package:lucide_icons/lucide_icons.dart';
import 'package:share_plus/share_plus.dart';
import 'package:cdk_flutter/cdk_flutter.dart' as cdk;
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:elcaju/l10n/app_localizations.dart';
import '../../core/constants/colors.dart';
import '../../core/constants/dimensions.dart';
import '../../core/utils/formatters.dart';
+13 -25
View File
@@ -5,13 +5,12 @@ import 'package:provider/provider.dart';
import 'package:qr_flutter/qr_flutter.dart';
import 'package:lucide_icons/lucide_icons.dart';
import 'package:cdk_flutter/cdk_flutter.dart' hide WalletProvider;
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:elcaju/l10n/app_localizations.dart';
import '../../core/constants/colors.dart';
import '../../core/constants/dimensions.dart';
import '../../core/utils/formatters.dart';
import '../../widgets/common/gradient_background.dart';
import '../../widgets/common/glass_card.dart';
import '../../widgets/effects/cashu_confetti.dart';
import '../../providers/wallet_provider.dart';
/// Estados del proceso de mint
@@ -39,7 +38,6 @@ class _InvoiceScreenState extends State<InvoiceScreen> {
String? _invoice;
String? _errorMessage;
StreamSubscription<MintQuote>? _mintSubscription;
final CashuConfettiController _confettiController = CashuConfettiController();
@override
void initState() {
@@ -50,15 +48,14 @@ class _InvoiceScreenState extends State<InvoiceScreen> {
@override
void dispose() {
_mintSubscription?.cancel();
_confettiController.dispose();
super.dispose();
}
void _startMintProcess() {
Future<void> _startMintProcess() async {
final walletProvider = context.read<WalletProvider>();
try {
final mintStream = walletProvider.mintTokens(
final mintStream = await walletProvider.mintTokens(
widget.amount,
widget.description,
);
@@ -107,8 +104,7 @@ class _InvoiceScreenState extends State<InvoiceScreen> {
}
void _onMintCompleted() {
// Disparar confetti inmediatamente
_confettiController.fire();
// Confetti se dispara globalmente desde WalletProvider._saveMintMetadata
// Esperar a que termine el confetti antes de navegar
Future.delayed(const Duration(seconds: 3), () {
@@ -133,25 +129,19 @@ class _InvoiceScreenState extends State<InvoiceScreen> {
@override
Widget build(BuildContext context) {
return CashuConfetti(
controller: _confettiController,
child: GradientBackground(
child: PopScope(
canPop: _status == MintStatus.issued || _status == MintStatus.error,
child: Scaffold(
return GradientBackground(
child: Scaffold(
backgroundColor: Colors.transparent,
appBar: AppBar(
backgroundColor: Colors.transparent,
elevation: 0,
leading: (_status == MintStatus.issued || _status == MintStatus.error)
? IconButton(
icon: const Icon(
LucideIcons.arrowLeft,
color: Colors.white,
),
onPressed: () => Navigator.pop(context),
)
: null,
leading: IconButton(
icon: const Icon(
LucideIcons.arrowLeft,
color: Colors.white,
),
onPressed: () => Navigator.pop(context),
),
title: Text(
L10n.of(context)!.payInvoiceTitle,
style: const TextStyle(
@@ -181,9 +171,7 @@ class _InvoiceScreenState extends State<InvoiceScreen> {
child: _buildContent(),
),
),
),
),
),
);
}
+1 -1
View File
@@ -1,7 +1,7 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:lucide_icons/lucide_icons.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:elcaju/l10n/app_localizations.dart';
import '../../core/constants/colors.dart';
import '../../core/constants/dimensions.dart';
import '../../core/utils/formatters.dart';
+1 -1
View File
@@ -1,7 +1,7 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:lucide_icons/lucide_icons.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:elcaju/l10n/app_localizations.dart';
import '../../core/constants/colors.dart';
import '../../core/constants/dimensions.dart';
import '../../core/utils/formatters.dart';
+1 -1
View File
@@ -3,7 +3,7 @@ import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import 'package:lucide_icons/lucide_icons.dart';
import 'package:cdk_flutter/cdk_flutter.dart' hide WalletProvider;
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:elcaju/l10n/app_localizations.dart';
import '../../core/constants/colors.dart';
import '../../core/constants/dimensions.dart';
import '../../core/utils/formatters.dart';
+1 -1
View File
@@ -1,7 +1,7 @@
import 'package:flutter/material.dart';
import 'package:lucide_icons/lucide_icons.dart';
import 'package:provider/provider.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:elcaju/l10n/app_localizations.dart';
import '../../core/constants/colors.dart';
import '../../core/constants/dimensions.dart';
import '../../providers/settings_provider.dart';
@@ -2,7 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:lucide_icons/lucide_icons.dart';
import 'package:cdk_flutter/cdk_flutter.dart' show MintInfo, ContactInfo;
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:elcaju/l10n/app_localizations.dart';
import '../../core/constants/colors.dart';
import '../../core/utils/formatters.dart';
import '../../widgets/common/gradient_background.dart';
+1 -1
View File
@@ -2,7 +2,7 @@ import 'package:flutter/material.dart';
import 'package:lucide_icons/lucide_icons.dart';
import 'package:provider/provider.dart';
import 'package:cdk_flutter/cdk_flutter.dart' show MintInfo;
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:elcaju/l10n/app_localizations.dart';
import '../../core/constants/colors.dart';
import '../../core/constants/dimensions.dart';
import '../../core/utils/formatters.dart';
+1 -1
View File
@@ -2,7 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:lucide_icons/lucide_icons.dart';
import 'package:provider/provider.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:elcaju/l10n/app_localizations.dart';
import 'package:qr_flutter/qr_flutter.dart';
import '../../core/constants/colors.dart';
import '../../core/constants/dimensions.dart';
+1 -1
View File
@@ -2,7 +2,7 @@ import 'package:flutter/material.dart';
import 'package:lucide_icons/lucide_icons.dart';
import 'package:provider/provider.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:elcaju/l10n/app_localizations.dart';
import '../../core/constants/colors.dart';
import '../../core/constants/dimensions.dart';
import '../../core/utils/formatters.dart';
+30 -4
View File
@@ -7,7 +7,7 @@ import 'package:qr_flutter/qr_flutter.dart';
import 'package:intl/intl.dart';
import 'package:cdk_flutter/cdk_flutter.dart' as cdk;
import 'package:cdk_flutter/cdk_flutter.dart' show Transaction, TransactionDirection, TransactionStatus;
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:elcaju/l10n/app_localizations.dart';
import '../../core/constants/colors.dart';
import '../../core/constants/dimensions.dart';
import '../../core/utils/formatters.dart';
@@ -691,8 +691,9 @@ class _TransactionDetailScreenState extends State<_TransactionDetailScreen> {
// Datos de la transacción
late final bool _isIncoming;
late final bool _isLightning;
late final String? _tokenOrInvoice;
String? _tokenOrInvoice;
late final bool _shouldShowQR;
bool _isLoadingPendingInvoice = false;
@override
void initState() {
@@ -704,6 +705,11 @@ class _TransactionDetailScreenState extends State<_TransactionDetailScreen> {
final meta = widget.walletProvider.getTransactionMeta(widget.transaction.id);
_tokenOrInvoice = meta?.token ?? meta?.invoice;
// Si es Lightning incoming sin invoice, buscar en pending mint invoices
if (_tokenOrInvoice == null && _isLightning && _isIncoming) {
_loadPendingMintInvoice();
}
// Mostrar QR para todo EXCEPTO Lightning saliente
_shouldShowQR = !_isLightning || _isIncoming;
@@ -713,6 +719,26 @@ class _TransactionDetailScreenState extends State<_TransactionDetailScreen> {
}
}
/// Busca el invoice en pending mint invoices si no hay metadata.
Future<void> _loadPendingMintInvoice() async {
setState(() => _isLoadingPendingInvoice = true);
String? invoice;
try {
invoice = await widget.walletProvider.findPendingMintInvoice(
widget.transaction.mintUrl,
widget.transaction.unit,
);
} catch (e, st) {
debugPrint('findPendingMintInvoice failed: $e\n$st');
}
if (mounted) {
setState(() {
_isLoadingPendingInvoice = false;
if (invoice != null) _tokenOrInvoice = invoice;
});
}
}
@override
void dispose() {
_animationTimer?.cancel();
@@ -851,8 +877,8 @@ class _TransactionDetailScreenState extends State<_TransactionDetailScreen> {
const SizedBox(height: 32),
],
// Mensaje si no hay token/invoice
if (_tokenOrInvoice == null) ...[
// Mensaje si no hay token/invoice (ocultar mientras carga)
if (_tokenOrInvoice == null && !_isLoadingPendingInvoice) ...[
Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
+1 -1
View File
@@ -1,7 +1,7 @@
import 'package:flutter/material.dart';
import 'package:mobile_scanner/mobile_scanner.dart';
import 'package:lucide_icons/lucide_icons.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:elcaju/l10n/app_localizations.dart';
import 'package:cdk_flutter/cdk_flutter.dart';
import '../../core/constants/colors.dart';
import '../../core/utils/incoming_data_parser.dart';
+1 -1
View File
@@ -86,7 +86,7 @@ packages:
description:
path: "."
ref: HEAD
resolved-ref: "3e0c77c22c807459d5d355e270ef4b5ad6a490c1"
resolved-ref: "7fb8580530d963c9ff09f23e55a1e56bccd8676b"
url: "https://github.com/cashubtc/cdk_flutter.git"
source: git
version: "0.1.0"
+1 -1
View File
@@ -1,7 +1,7 @@
name: elcaju
description: "ElCaju - Tu wallet de ecash privado"
publish_to: 'none'
version: 0.0.1+1
version: 0.1.0+2
environment:
sdk: ^3.6.0