Compare commits

...
Author SHA1 Message Date
Forte11Cuba 07c0a7be89 fix: remove panic paths in TokenDecoder lock handling 2026-03-28 03:45:56 -06:00
Forte11Cuba 1f629d45b3 fix: remove panics in generate_mnemonic and token parsing 2026-03-28 03:30:56 -06:00
Forte11Cuba fa5fd7d98e feat: implement Phase 1 Rust API core (keys, token, mint_info, wallet) 2026-03-28 03:14:40 -06:00
Forte11andGitHub e4f60010f7 Merge pull request #72 from Forte11Cuba/feat/rust-internal-bridge
feat: scaffold internal Rust bridge (elcaju_core)
2026-03-28 02:33:58 -06:00
Forte11Cuba 5d54cb957c fix: correct babash typo from sed double-replacement 2026-03-28 02:33:33 -06:00
Forte11Cuba b930f3c201 fix: use bash consistently across build scripts and podspecs 2026-03-28 02:27:54 -06:00
Forte11Cuba 830c42da93 fix: bash consistency in build_pod.sh 2026-03-28 01:33:37 -06:00
Forte11Cuba e28c68a0ae fix: use bash shebang in build_pod.sh (uses bash-only features) 2026-03-28 01:28:41 -06:00
Forte11Cuba f59090cec8 fix: apply CodeRabbit review feedback 2026-03-28 01:21:12 -06:00
Forte11Cuba 2a092fc9f8 feat: scaffold internal Rust bridge (elcaju_core) 2026-03-28 00:52:48 -06:00
Forte11andGitHub 5fa8583cdf Merge pull request #68 from Forte11Cuba/fix/startup-recovery
fix: add startup recovery for lost proofs and pending operations
2026-03-20 11:37:32 -06:00
Forte11Cuba 9ab78e0ab8 fix: add startup recovery for lost proofs and pending operations 2026-03-20 11:32:16 -06:00
Forte11andGitHub d13b5e8782 Merge pull request #66 from Forte11Cuba/feature/nfc-support
feat: NFC support for Cashu token read/write
2026-03-13 17:34:47 -06:00
Forte11Cuba 76931f7b1d fix: parse URI fragment as key-value pairs instead of substring 2026-03-13 14:09:56 -06:00
Forte11Cuba 115843ce51 fix: guard against infinite loop on zero-data IsoDep response 2026-03-13 12:43:35 -06:00
Forte11Cuba 713f13a46d fix: address CodeRabbit review — await NFC sessions, log errors, validate tokens 2026-03-13 12:30:46 -06:00
Forte11Cuba 560d6fc683 feat: phone-to-phone NFC via HCE with proprietary AID 2026-03-13 12:05:36 -06:00
Forte11Cuba c0f8b166ea feat: NFC support for Cashu token read/write 2026-03-12 10:53:50 -06:00
Forte11andGitHub 6b68367c0d Merge pull request #64 from Forte11Cuba/feature/peanut-emoji-encoding
feat: add peanut emoji encoding for Cashu tokens
2026-03-12 08:33:05 -06:00
Forte11Cuba d80ed4caa7 fix: handle malformed peanut tokens with explicit error message 2026-03-12 08:20:52 -06:00
Forte11Cuba a627ed39f2 fix: guard clipboard callback with mounted check in ReceiveScreen 2026-03-12 08:06:24 -06:00
Forte11Cuba 5d00213d95 fix: peanut decode trimLeft consistency and auto-decode on receive 2026-03-12 07:30:52 -06:00
Forte11Cuba 2e3bb5dcc3 feat: add peanut emoji encoding for Cashu tokens 2026-03-12 06:54:50 -06:00
Forte11andGitHub 749d44650d Merge pull request #63 from Forte11Cuba/cleanup/css-duplicates-and-i18n
fix: auto version from pubspec, remove Cuba Bitcoin badge, LaChispa tappable link
2026-02-26 03:09:30 -06:00
Forte11Cuba 3ff2d6ef84 fix: defensive error handling for version load and LaChispa link 2026-02-26 03:04:58 -06:00
Forte11Cuba 18c72ebf1d fix: auto version from pubspec, remove Cuba Bitcoin badge, LaChispa tappable link 2026-02-26 02:50:40 -06:00
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
Forte11Cuba cb33924a75 fix 2026-02-12 04:52:37 -06:00
Forte11Cuba 390af394b9 fix: workaround P2PK send bug (cdk-flutter#3) 2026-02-12 04:44:49 -06:00
Forte11andGitHub 64430191f4 Merge pull request #41 from Forte11Cuba/feature/p2pk-receive-multi-unit
feat: Add P2PK (Pay to Public Key) support
2026-02-12 03:18:56 -06:00
Forte11Cuba 0952bda73b fix(p2pk): show error border on nsec input in all focus states 2026-02-12 03:17:46 -06:00
Forte11Cuba 7da8181022 fix(p2pk): handle hexToNpub exception and unsafe substring in key card 2026-02-12 03:11:09 -06:00
Forte11Cuba 13de1456c8 feat: Add P2PK (Pay to Public Key) support 2026-02-12 02:35:06 -06:00
Forte11andGitHub b1777f32a2 Merge pull request #40 from Forte11Cuba/feature/p2pk-send
feat(p2pk): implement P2PK token locking (send)
2026-02-11 21:30:43 -06:00
Forte11Cuba d9144e74d1 fix: address CodeRabbit review feedback (round 3) 2026-02-11 16:19:21 -06:00
Forte11Cuba bfca3da6e5 fix(p2pk): address CodeRabbit review feedback (round 2) 2026-02-11 16:10:12 -06:00
Forte11Cuba 235dc29b08 feat(p2pk): implement P2PK token locking (send) 2026-02-11 15:42:17 -06:00
Forte11Cuba 3840929d5e feat(p2pk): implement P2PK token locking (send) 2026-02-11 15:11:11 -06:00
Forte11andGitHub 705916d42c Merge pull request #38 from Forte11Cuba/feature/language-screen
feat(ui): add dedicated language selection screen
2026-02-11 04:15:08 -06:00
Forte11Cuba 6189af6200 feat(ui): add dedicated language selection screen 2026-02-11 03:59:18 -06:00
Forte11andGitHub c4e35e93fe Merge pull request #37 from Forte11Cuba/feature/i18n-swahili
feat(i18n): add Swahili (Kiswahili) translation
2026-02-11 03:44:51 -06:00
Forte11Cuba 369c634217 feat(i18n): add Swahili (Kiswahili) translation 2026-02-11 03:43:56 -06:00
Forte11andGitHub 69ae41b201 Merge pull request #36 from Forte11Cuba/feature/i18n-japanese
feat(i18n): add Japanese (日本語) translation
2026-02-11 03:37:50 -06:00
Forte11Cuba bacdff8a32 feat(i18n): add Japanese (日本語) translation 2026-02-11 03:36:46 -06:00
Forte11andGitHub 35d3338030 Merge pull request #35 from Forte11Cuba/feature/i18n-chinese
feat(i18n): add Chinese (中文) translation
2026-02-11 03:31:03 -06:00
Forte11Cuba b2c365b8f1 feat(i18n): add Chinese (中文) translation 2026-02-11 03:28:56 -06:00
Forte11andGitHub 339da91e20 Merge pull request #34 from Forte11Cuba/feature/i18n-korean
feat(i18n): add Korean (한국어) translation
2026-02-11 03:17:55 -06:00
Forte11Cuba 13a442696e feat(i18n): add Korean (한국어) translation 2026-02-11 03:17:04 -06:00
Forte11andGitHub 5b255e3b88 Merge pull request #33 from Forte11Cuba/feature/i18n-italian
feat(i18n): add Italian translation
2026-02-11 03:11:02 -06:00
129 changed files with 44436 additions and 503 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
+4
View File
@@ -44,3 +44,7 @@ app.*.map.json
/android/app/debug
/android/app/profile
/android/app/release
# Rust/CargoKit compiled native libraries
android/app/src/main/jniLibs/
rust/target/
+1 -1
View File
@@ -21,7 +21,7 @@ android {
defaultConfig {
applicationId "me.elcaju"
minSdk flutter.minSdkVersion
minSdkVersion flutter.minSdkVersion // Required for FlutterSecureStorage (EncryptedSharedPreferences)
targetSdk flutter.targetSdkVersion
versionCode flutter.versionCode
versionName flutter.versionName
+16
View File
@@ -7,6 +7,9 @@
<!-- Cámara opcional: la app funciona sin ella (pegando desde portapapeles) -->
<uses-feature android:name="android.hardware.camera" android:required="false"/>
<uses-feature android:name="android.hardware.camera.autofocus" android:required="false"/>
<!-- NFC para compartir tokens Cashu -->
<uses-permission android:name="android.permission.NFC"/>
<uses-feature android:name="android.hardware.nfc" android:required="false"/>
<application
android:label="ElCaju"
@@ -34,6 +37,19 @@
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<!-- HCE service: phone emulates NFC tag for phone-to-phone transfers -->
<service
android:name=".NfcHceService"
android:exported="true"
android:permission="android.permission.BIND_NFC_SERVICE">
<intent-filter>
<action android:name="android.nfc.cardemulation.action.HOST_APDU_SERVICE" />
</intent-filter>
<meta-data
android:name="android.nfc.cardemulation.host_apdu_service"
android:resource="@xml/hce_nfc" />
</service>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data
@@ -1,5 +1,60 @@
package me.elcaju
import android.content.ComponentName
import android.nfc.NfcAdapter
import android.nfc.cardemulation.CardEmulation
import android.util.Log
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel
class MainActivity : FlutterActivity()
class MainActivity : FlutterActivity() {
private val CHANNEL = "me.elcaju/nfc_hce"
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL).setMethodCallHandler { call, result ->
when (call.method) {
"setPayload" -> {
val payload = call.argument<ByteArray>("payload")
NfcHceService.ndefPayload = payload
// Force Android to route the NDEF AID to our service
// instead of manufacturer services (Xiaomi Mi Share, etc.)
try {
val adapter = NfcAdapter.getDefaultAdapter(this)
if (adapter != null) {
val cardEmulation = CardEmulation.getInstance(adapter)
cardEmulation.setPreferredService(
this,
ComponentName(this, NfcHceService::class.java)
)
}
} catch (e: Exception) {
Log.w("MainActivity", "setPreferredService failed (non-critical)", e)
}
result.success(true)
}
"clearPayload" -> {
NfcHceService.ndefPayload = null
// Release preferred service routing
try {
val adapter = NfcAdapter.getDefaultAdapter(this)
if (adapter != null) {
val cardEmulation = CardEmulation.getInstance(adapter)
cardEmulation.unsetPreferredService(this)
}
} catch (e: Exception) {
Log.w("MainActivity", "unsetPreferredService failed (non-critical)", e)
}
result.success(true)
}
else -> result.notImplemented()
}
}
}
}
@@ -0,0 +1,140 @@
package me.elcaju
import android.nfc.cardemulation.HostApduService
import android.os.Bundle
import android.util.Log
/**
* Host Card Emulation service that makes the phone act as an NFC Forum Type 4 tag.
* Another phone in reader mode can tap and read the NDEF message we serve.
*
* The payload is set from Flutter via MainActivity's MethodChannel.
*/
class NfcHceService : HostApduService() {
companion object {
private const val TAG = "NfcHceService"
// Shared payload - set by Flutter via MethodChannel
@Volatile
var ndefPayload: ByteArray? = null
private val OK = byteArrayOf(0x90.toByte(), 0x00.toByte())
private val NOT_FOUND = byteArrayOf(0x6A.toByte(), 0x82.toByte())
// Capability Container (CC) file - fixed structure
// MLe=0xFF (255), MLc=0xFF (255), max NDEF=0x70FF (28671)
// Compatible with Numo
private val CC_FILE = byteArrayOf(
0x00, 0x0F, // CC length (15)
0x20, // Mapping version 2.0
0x00, 0xFF.toByte(), // MLe: max read 255 bytes
0x00, 0xFF.toByte(), // MLc: max write 255 bytes
0x04, 0x06, // NDEF File Control TLV
0xE1.toByte(), 0x04, // NDEF file ID
0x70, 0xFF.toByte(), // Max NDEF size: 28671 bytes
0x00, // Read access: open
0xFF.toByte() // Write access: denied
)
}
private var selectedFile: String = "none"
private var ndefFileCache: ByteArray? = null
override fun processCommandApdu(commandApdu: ByteArray, extras: Bundle?): ByteArray {
if (commandApdu.size < 4) return NOT_FOUND
val ins = commandApdu[1]
// SELECT command
if (ins == 0xA4.toByte()) {
return handleSelect(commandApdu)
}
// READ BINARY command
if (ins == 0xB0.toByte()) {
return handleRead(commandApdu)
}
return NOT_FOUND
}
private fun handleSelect(apdu: ByteArray): ByteArray {
// Select ElCaju proprietary AID (F04543414A5500)
// Avoids conflicts with Xiaomi Mi Share / Samsung Beam
if (apdu.size >= 12 && apdu[5] == 0xF0.toByte() && apdu[6] == 0x45.toByte()) {
selectedFile = "app"
Log.d(TAG, "Selected ElCaju Application (proprietary AID)")
return OK
}
// Select NDEF Application (AID: D2760000850101)
if (apdu.size >= 12 && apdu[5] == 0xD2.toByte() && apdu[6] == 0x76.toByte()) {
selectedFile = "app"
Log.d(TAG, "Selected NDEF Application")
return OK
}
// Select by file ID
if (apdu.size >= 7) {
val fileId = ((apdu[5].toInt() and 0xFF) shl 8) or (apdu[6].toInt() and 0xFF)
when (fileId) {
0xE103 -> {
selectedFile = "cc"
Log.d(TAG, "Selected CC file")
return OK
}
0xE104 -> {
selectedFile = "ndef"
// Cache the NDEF file when selected
val payload = ndefPayload
if (payload != null) {
val file = ByteArray(2 + payload.size)
file[0] = (payload.size shr 8).toByte()
file[1] = (payload.size and 0xFF).toByte()
System.arraycopy(payload, 0, file, 2, payload.size)
ndefFileCache = file
Log.d(TAG, "Selected NDEF file (${payload.size} bytes payload)")
} else {
ndefFileCache = null
Log.w(TAG, "Selected NDEF file but no payload set")
}
return OK
}
}
}
return NOT_FOUND
}
private fun handleRead(apdu: ByteArray): ByteArray {
if (apdu.size < 5) return NOT_FOUND
val offset = ((apdu[2].toInt() and 0xFF) shl 8) or (apdu[3].toInt() and 0xFF)
// Le=0x00 means 256 bytes in short APDU encoding
var length = apdu[4].toInt() and 0xFF
if (length == 0) length = 256
val data = when (selectedFile) {
"cc" -> CC_FILE
"ndef" -> ndefFileCache ?: return NOT_FOUND
else -> return NOT_FOUND
}
if (offset >= data.size) {
Log.w(TAG, "Read offset $offset beyond data size ${data.size}")
return NOT_FOUND
}
val end = minOf(offset + length, data.size)
val response = data.copyOfRange(offset, end)
Log.d(TAG, "Read $selectedFile: offset=$offset len=$length returned=${response.size} bytes")
return response + OK
}
override fun onDeactivated(reason: Int) {
Log.d(TAG, "Deactivated (reason=$reason)")
selectedFile = "none"
ndefFileCache = null
}
}
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="hce_description">ElCaju NFC</string>
</resources>
+12
View File
@@ -0,0 +1,12 @@
<host-apdu-service xmlns:android="http://schemas.android.com/apk/res/android"
android:description="@string/hce_description"
android:requireDeviceUnlock="false">
<aid-group
android:category="other"
android:description="@string/hce_description">
<!-- ElCaju proprietary AID (avoids Xiaomi/Samsung NFC service conflicts) -->
<aid-filter android:name="F04543414A5500" />
<!-- NDEF Tag Application AID (NFC Forum Type 4) -->
<aid-filter android:name="D2760000850101" />
</aid-group>
</host-apdu-service>
+3
View File
@@ -0,0 +1,3 @@
rust_input: crate::api
rust_root: rust/
dart_output: lib/src/rust
+483
View File
@@ -0,0 +1,483 @@
import 'dart:io' show Platform;
import 'dart:typed_data';
import 'package:flutter/services.dart';
import 'package:nfc_manager/nfc_manager.dart';
import 'package:nfc_manager/nfc_manager_android.dart';
import 'package:ndef_record/ndef_record.dart';
/// NFC availability state for UI rendering.
enum NfcState {
/// Device does not have NFC hardware.
unsupported,
/// Device has NFC but it is disabled in settings.
disabled,
/// NFC is supported and enabled.
enabled,
}
/// Service for reading and writing Cashu tokens via NFC.
///
/// Writes tokens as NDEF Text Records for maximum compatibility
/// with cashu.me and Numo. Reads both Text and URI records.
class NfcService {
static const _hceChannel = MethodChannel('me.elcaju/nfc_hce');
// ─── HCE (phone-to-phone) ───
/// Start emulating an NFC tag with the given text payload.
/// The phone will appear as an NFC tag to other devices.
static Future<void> startEmulating(String text) async {
final ndefMessage = _buildNdefTextMessage(text);
await _hceChannel.invokeMethod('setPayload', {'payload': ndefMessage});
}
/// Stop emulating an NFC tag.
static Future<void> stopEmulating() async {
await _hceChannel.invokeMethod('clearPayload');
}
/// Build an NDEF message with a Text Record.
/// Uses Short Record (SR) for payloads ≤ 255 bytes,
/// Long Record for > 255 bytes (compatible with Numo).
static Uint8List _buildNdefTextMessage(String text) {
final textBytes = Uint8List.fromList(text.codeUnits);
final languageCode = Uint8List.fromList('en'.codeUnits);
// NDEF Text Record payload: [status byte][lang code][text]
final recordPayload = Uint8List(1 + languageCode.length + textBytes.length);
recordPayload[0] = languageCode.length;
recordPayload.setRange(1, 1 + languageCode.length, languageCode);
recordPayload.setRange(1 + languageCode.length, recordPayload.length, textBytes);
final payloadLength = recordPayload.length;
final isShortRecord = payloadLength <= 255;
if (isShortRecord) {
// Short Record: flags(1) + typeLen(1) + payloadLen(1) + type(1) + payload
final record = Uint8List(4 + payloadLength);
record[0] = 0xD1; // MB|ME|SR|TNF=well-known
record[1] = 1; // type length
record[2] = payloadLength;
record[3] = 0x54; // 'T'
record.setRange(4, 4 + payloadLength, recordPayload);
return record;
} else {
// Long Record: flags(1) + typeLen(1) + payloadLen(4) + type(1) + payload
final record = Uint8List(7 + payloadLength);
record[0] = 0xC1; // MB|ME|TNF=well-known (no SR flag)
record[1] = 1; // type length
record[2] = (payloadLength >> 24) & 0xFF;
record[3] = (payloadLength >> 16) & 0xFF;
record[4] = (payloadLength >> 8) & 0xFF;
record[5] = payloadLength & 0xFF;
record[6] = 0x54; // 'T'
record.setRange(7, 7 + payloadLength, recordPayload);
return record;
}
}
// ─── Tag read/write ───
/// Check NFC state on the device.
static Future<NfcState> checkState() async {
if (!Platform.isAndroid) return NfcState.unsupported;
try {
final availability = await NfcManager.instance.checkAvailability();
return switch (availability) {
NfcAvailability.enabled => NfcState.enabled,
NfcAvailability.disabled => NfcState.disabled,
NfcAvailability.unsupported => NfcState.unsupported,
};
} catch (_) {
return NfcState.unsupported;
}
}
/// Write a Cashu token to an NFC tag as NDEF Text Record.
static Future<void> startWrite({
required String token,
required void Function() onSuccess,
required void Function(String error) onError,
}) async {
try {
await NfcManager.instance.startSession(
pollingOptions: {NfcPollingOption.iso14443},
onDiscovered: (NfcTag tag) async {
final ndef = NdefAndroid.from(tag);
if (ndef == null || !ndef.isWritable) {
onError('Tag is not writable');
await NfcManager.instance.stopSession();
return;
}
// Build NDEF Text Record: [status byte][language code][text]
final textBytes = Uint8List.fromList(token.codeUnits);
final languageCode = Uint8List.fromList('en'.codeUnits);
final payload = Uint8List(1 + languageCode.length + textBytes.length);
payload[0] = languageCode.length; // status byte (UTF-8, no length)
payload.setRange(1, 1 + languageCode.length, languageCode);
payload.setRange(1 + languageCode.length, payload.length, textBytes);
// Check size
if (payload.length + 7 > ndef.maxSize) {
onError('Token too large for this NFC tag '
'(${payload.length + 7}B > ${ndef.maxSize}B)');
await NfcManager.instance.stopSession();
return;
}
try {
final message = NdefMessage(records: [
NdefRecord(
typeNameFormat: TypeNameFormat.wellKnown,
type: Uint8List.fromList([0x54]), // 'T' = Text Record
identifier: Uint8List(0),
payload: payload,
),
]);
await ndef.writeNdefMessage(message);
onSuccess();
await NfcManager.instance.stopSession();
} catch (e) {
onError(e.toString());
await NfcManager.instance.stopSession();
}
},
);
} catch (e) {
onError(e.toString());
}
}
/// Stop any active NFC session.
static Future<void> stopWrite() async {
await NfcManager.instance.stopSession();
}
/// Start reading NFC tags for Cashu tokens.
/// Tries IsoDep APDU first (phone-to-phone HCE, bypasses manufacturer
/// NFC services), falls back to NDEF (physical tags).
static Future<void> startRead({
required void Function(String token) onTokenRead,
required void Function(String error) onError,
}) async {
try {
await NfcManager.instance.startSession(
pollingOptions: {NfcPollingOption.iso14443},
onDiscovered: (NfcTag tag) async {
try {
final diagnostics = <String>[];
// 1. Try IsoDep first (HCE phone-to-phone)
final isoDep = IsoDepAndroid.from(tag);
if (isoDep != null) {
final (token, isoInfo) = await _readViaIsoDep(isoDep);
if (token != null) {
onTokenRead(token);
await NfcManager.instance.stopSession();
return;
}
diagnostics.add('IsoDep: $isoInfo');
} else {
diagnostics.add('IsoDep: not available');
}
// 2. Fallback: NDEF (physical tags)
final ndef = NdefAndroid.from(tag);
if (ndef != null) {
final message = ndef.cachedNdefMessage ?? await ndef.getNdefMessage();
if (message != null) {
final token = _extractToken(message);
if (token != null) {
onTokenRead(token);
await NfcManager.instance.stopSession();
return;
}
diagnostics.add('NDEF: ${message.records.length} records, no Cashu token');
} else {
diagnostics.add('NDEF: no message');
}
} else {
diagnostics.add('NDEF: not available');
}
onError('No Cashu token found [${diagnostics.join('; ')}]');
await NfcManager.instance.stopSession();
} catch (e) {
onError(e.toString());
await NfcManager.instance.stopSession();
}
},
);
} catch (e) {
onError(e.toString());
}
}
/// Read NDEF from HCE via IsoDep APDU commands (like Numo).
/// Bypasses manufacturer NFC services (Xiaomi, Samsung, etc).
/// Returns (token, diagnosticInfo) tuple.
static Future<(String?, String)> _readViaIsoDep(IsoDepAndroid isoDep) async {
String _hex(Uint8List bytes) =>
bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join(' ');
try {
// Set longer timeout (5s) to avoid TagLostException
await isoDep.setTimeout(5000);
// Try ElCaju proprietary AID first (avoids Xiaomi/Samsung conflicts)
final selectElCaju = await isoDep.transceive(Uint8List.fromList([
0x00, 0xA4, 0x04, 0x00, 0x07,
0xF0, 0x45, 0x43, 0x41, 0x4A, 0x55, 0x00,
0x00,
]));
final bool elCajuSelected = _isOk(selectElCaju);
// Fallback: standard NDEF Application AID
if (!elCajuSelected) {
final selectApp = await isoDep.transceive(Uint8List.fromList([
0x00, 0xA4, 0x04, 0x00, 0x07,
0xD2, 0x76, 0x00, 0x00, 0x85, 0x01, 0x01,
0x00,
]));
if (!_isOk(selectApp)) {
return (null, 'SELECT APP failed (both AIDs): elcaju=${_hex(selectElCaju)}, ndef=${_hex(selectApp)}');
}
}
// SELECT NDEF file (E104)
final selectNdef = await isoDep.transceive(Uint8List.fromList([
0x00, 0xA4, 0x00, 0x0C, 0x02,
0xE1, 0x04,
]));
if (!_isOk(selectNdef)) {
return (null, 'SELECT NDEF failed: ${_hex(selectNdef)}');
}
// READ NLEN (first 2 bytes)
final nlenResponse = await isoDep.transceive(Uint8List.fromList([
0x00, 0xB0, 0x00, 0x00, 0x02,
]));
if (nlenResponse.length < 4 || !_isOk(nlenResponse)) {
return (null, 'READ NLEN failed: ${_hex(nlenResponse)}');
}
final ndefLen = (nlenResponse[0] << 8) | nlenResponse[1];
if (ndefLen == 0) {
return (null, 'NLEN=0 (no payload set on emitter)');
}
// READ NDEF message in chunks (max 255 bytes per read)
final ndefBytes = BytesBuilder();
var offset = 2; // skip NLEN
var remaining = ndefLen;
while (remaining > 0) {
final chunkSize = remaining > 255 ? 255 : remaining;
final readCmd = Uint8List.fromList([
0x00, 0xB0,
(offset >> 8) & 0xFF,
offset & 0xFF,
chunkSize,
]);
final chunk = await isoDep.transceive(readCmd);
if (chunk.length < 2 || !_isOk(chunk)) {
return (null, 'READ chunk at offset=$offset failed: ${_hex(chunk)}');
}
// Response = data + SW1 SW2 (last 2 bytes are status)
final bytesRead = chunk.length - 2;
if (bytesRead == 0) {
return (null, 'READ chunk at offset=$offset returned no data');
}
ndefBytes.add(chunk.sublist(0, bytesRead));
offset += bytesRead;
remaining -= bytesRead;
}
// Parse NDEF message from raw bytes
final raw = ndefBytes.toBytes();
if (raw.isEmpty) {
return (null, 'read ${ndefLen}B but empty after parsing');
}
final token = _parseNdefAndExtractToken(Uint8List.fromList(raw));
if (token != null) {
return (token, 'OK');
}
return (null, 'NDEF parsed (${raw.length}B) but no Cashu token, hex: ${_hex(Uint8List.fromList(raw.length > 40 ? raw.sublist(0, 40) : raw))}...');
} catch (e) {
final msg = e.toString();
if (msg.contains('TagLostException')) {
return (null, 'connection lost (hold phones together longer)');
}
return (null, 'error: $msg');
}
}
/// Check if APDU response ends with 90 00 (OK).
static bool _isOk(Uint8List response) {
if (response.length < 2) return false;
return response[response.length - 2] == 0x90 &&
response[response.length - 1] == 0x00;
}
/// Parse raw NDEF bytes and extract Cashu token.
static String? _parseNdefAndExtractToken(Uint8List raw) {
if (raw.isEmpty) return null;
var pos = 0;
while (pos < raw.length) {
if (pos + 3 > raw.length) break;
final flags = raw[pos];
final tnf = flags & 0x07;
final isShortRecord = (flags & 0x10) != 0;
final hasIdLength = (flags & 0x08) != 0;
pos++;
final typeLength = raw[pos]; pos++;
int payloadLength;
if (isShortRecord) {
payloadLength = raw[pos]; pos++;
} else {
if (pos + 4 > raw.length) break;
payloadLength = (raw[pos] << 24) | (raw[pos+1] << 16) |
(raw[pos+2] << 8) | raw[pos+3];
pos += 4;
}
int idLength = 0;
if (hasIdLength) {
idLength = raw[pos]; pos++;
}
// Type
if (pos + typeLength > raw.length) break;
final type = raw.sublist(pos, pos + typeLength); pos += typeLength;
// ID (skip)
pos += idLength;
// Payload
if (pos + payloadLength > raw.length) break;
final payload = raw.sublist(pos, pos + payloadLength);
pos += payloadLength;
// Check: Text Record (TNF=0x01, type='T')
if (tnf == 0x01 && typeLength == 1 && type[0] == 0x54) {
if (payload.isNotEmpty) {
final langLen = payload[0] & 0x3F;
if (payload.length > 1 + langLen) {
final text = String.fromCharCodes(payload.sublist(1 + langLen));
if (_isCashuToken(text)) return text;
}
}
}
// Check: URI Record (TNF=0x01, type='U')
if (tnf == 0x01 && typeLength == 1 && type[0] == 0x55) {
if (payload.isNotEmpty) {
const prefixes = ['', 'http://www.', 'https://www.', 'http://', 'https://'];
final prefixIdx = payload[0];
final prefix = prefixIdx < prefixes.length ? prefixes[prefixIdx] : '';
final rest = String.fromCharCodes(payload.sublist(1));
final uri = '$prefix$rest';
final token = _extractTokenFromUri(uri);
if (token != null) return token;
}
}
// If ME (Message End) flag set, stop
if ((flags & 0x40) != 0) break;
}
return null;
}
/// Stop any active NFC read session.
static Future<void> stopRead() async {
await NfcManager.instance.stopSession();
}
/// Extract a Cashu token from an NDEF message.
static String? _extractToken(NdefMessage message) {
for (final record in message.records) {
// Text Record
if (record.typeNameFormat == TypeNameFormat.wellKnown &&
record.type.length == 1 &&
record.type[0] == 0x54) {
final text = _decodeTextRecord(record);
if (text != null && _isCashuToken(text)) {
return text;
}
}
// URI Record
if (record.typeNameFormat == TypeNameFormat.wellKnown &&
record.type.length == 1 &&
record.type[0] == 0x55) {
final uri = _decodeUriRecord(record);
if (uri != null) {
final token = _extractTokenFromUri(uri);
if (token != null) return token;
}
}
}
return null;
}
/// Decode NDEF Text Record payload.
static String? _decodeTextRecord(NdefRecord record) {
if (record.payload.isEmpty) return null;
final languageCodeLength = record.payload[0] & 0x3F;
if (record.payload.length <= 1 + languageCodeLength) return null;
return String.fromCharCodes(
record.payload.sublist(1 + languageCodeLength));
}
/// Decode NDEF URI Record payload.
static String? _decodeUriRecord(NdefRecord record) {
if (record.payload.isEmpty) return null;
const prefixes = [
'', // 0x00
'http://www.', // 0x01
'https://www.', // 0x02
'http://', // 0x03
'https://', // 0x04
];
final prefixIndex = record.payload[0];
final prefix = prefixIndex < prefixes.length ? prefixes[prefixIndex] : '';
final rest = String.fromCharCodes(record.payload.sublist(1));
return '$prefix$rest';
}
/// Extract token from a URL like https://wallet.com/#token=cashuA...
static String? _extractTokenFromUri(String uri) {
try {
final parsed = Uri.parse(uri);
final fragmentParams = Uri.splitQueryString(parsed.fragment);
final fragmentToken = fragmentParams['token'];
if (fragmentToken != null && _isCashuToken(fragmentToken)) {
return fragmentToken;
}
final tokenParam = parsed.queryParameters['token'];
if (tokenParam != null && _isCashuToken(tokenParam)) {
return tokenParam;
}
} catch (_) {}
return null;
}
/// Check if a string looks like a Cashu token.
static bool _isCashuToken(String text) {
final lower = text.toLowerCase().trim();
return lower.startsWith('cashua') ||
lower.startsWith('cashub') ||
lower.startsWith('creqa');
}
}
+12 -1
View File
@@ -1,5 +1,7 @@
// Parser para detectar el tipo de dato entrante (QR, clipboard, etc.)
// Soporta: tokens Cashu (A/B), invoices Lightning, URLs de mint, payment requests
// Soporta: tokens Cashu (A/B), invoices Lightning, URLs de mint, payment requests, peanut emoji
import 'peanut_codec.dart';
/// Modo de escaneo
enum ScanMode {
@@ -57,6 +59,15 @@ class IncomingDataParser {
/// Detecta el tipo de dato y extrae información relevante
static ParsedData parse(String data) {
final trimmed = data.trim();
// Peanut-encoded token (🥜 + variation selectors)
if (PeanutCodec.isPeanut(trimmed)) {
final decoded = PeanutCodec.decode(trimmed);
if (decoded != null) {
return parse(decoded);
}
}
final lower = trimmed.toLowerCase();
// Token Cashu (cashuA... o cashuB...)
+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;
}
}
+197
View File
@@ -0,0 +1,197 @@
/// Utilidades para conversión de claves Nostr (npub/nsec/hex)
///
/// Formatos soportados:
/// - npub1... (clave pública Nostr en bech32)
/// - nsec1... (clave privada Nostr en bech32)
/// - hex (64 caracteres hexadecimales)
/// - nostr:npub1... (URI de Nostr)
library;
import 'dart:typed_data';
import 'package:bech32/bech32.dart';
class NostrUtils {
static const _npubHrp = 'npub';
static const _nsecHrp = 'nsec';
// ============ HEX -> BECH32 ============
/// Convierte hex a npub
/// Acepta 64 chars (x-only) o 66 chars (SEC1 comprimido con prefijo 02/03)
static String hexToNpub(String hex) {
// Si es SEC1 comprimido (66 chars con prefijo 02 o 03), quitar prefijo
if (RegExp(r'^0[23][0-9a-fA-F]{64}$').hasMatch(hex)) {
hex = hex.substring(2);
} else if (!RegExp(r'^[0-9a-fA-F]{64}$').hasMatch(hex)) {
throw ArgumentError('Expected 64 or 66-char hex string, got ${hex.length} chars');
}
final bytes = _hexToBytes(hex);
final converted = _convertBits(bytes, 8, 5, true);
final bech32Data = Bech32(_npubHrp, converted);
return const Bech32Codec().encode(bech32Data);
}
/// Convierte hex (64 chars) a nsec
static String hexToNsec(String hex) {
if (!RegExp(r'^[0-9a-fA-F]{64}$').hasMatch(hex)) {
throw ArgumentError('Expected 64-char hex string, got ${hex.length} chars');
}
final bytes = _hexToBytes(hex);
final converted = _convertBits(bytes, 8, 5, true);
final bech32Data = Bech32(_nsecHrp, converted);
return const Bech32Codec().encode(bech32Data);
}
// ============ BECH32 -> HEX ============
/// Convierte npub a hex
static String? npubToHex(String npub) {
try {
final decoded = const Bech32Codec().decode(npub);
if (decoded.hrp != _npubHrp) return null;
final bytes = _convertBits(decoded.data, 5, 8, false);
return _bytesToHex(Uint8List.fromList(bytes));
} catch (e) {
return null;
}
}
/// Convierte nsec a hex
static String? nsecToHex(String nsec) {
try {
final decoded = const Bech32Codec().decode(nsec);
if (decoded.hrp != _nsecHrp) return null;
final bytes = _convertBits(decoded.data, 5, 8, false);
return _bytesToHex(Uint8List.fromList(bytes));
} catch (e) {
return null;
}
}
// ============ NORMALIZACIÓN ============
/// Normaliza input a hex (acepta npub, hex, nostr:npub)
/// Retorna null si el input no es válido
static String? normalizeToHex(String input) {
input = input.trim();
// Remover prefijo nostr: si existe
if (input.startsWith('nostr:')) {
input = input.substring(6);
}
// Si es npub → convertir a hex
if (input.startsWith('npub1')) {
return npubToHex(input);
}
// Si es hex válido (64 caracteres) → usar directo
if (RegExp(r'^[0-9a-fA-F]{64}$').hasMatch(input)) {
return input.toLowerCase();
}
return null;
}
// ============ P2PK (SEC1 COMPRESSED) ============
/// Normaliza input a clave pública comprimida SEC1 (66 hex chars)
/// Cashu P2PK usa claves comprimidas de 33 bytes (prefijo 02 o 03)
/// Acepta: npub, hex 64 chars (x-only), hex 66 chars (compressed)
static String? normalizeToCompressedHex(String input) {
input = input.trim();
// Remover prefijo nostr: si existe
if (input.startsWith('nostr:')) {
input = input.substring(6);
}
// Si ya es hex comprimido (66 chars con prefijo 02 o 03)
if (RegExp(r'^0[23][0-9a-fA-F]{64}$').hasMatch(input)) {
return input.toLowerCase();
}
// Si es npub → convertir a hex x-only → añadir prefijo 02
if (input.startsWith('npub1')) {
final xOnlyHex = npubToHex(input);
if (xOnlyHex != null) {
return '02$xOnlyHex';
}
return null;
}
// Si es hex x-only (64 chars) → añadir prefijo 02
if (RegExp(r'^[0-9a-fA-F]{64}$').hasMatch(input)) {
return '02${input.toLowerCase()}';
}
return null;
}
/// Valida si un input es válido para P2PK
static bool isValidP2PKPubkey(String input) {
return normalizeToCompressedHex(input) != null;
}
// ============ VALIDACIÓN ============
/// Valida si un input es una pubkey válida (npub o hex)
static bool isValidPubkey(String input) {
final hex = normalizeToHex(input);
return hex != null && hex.length == 64;
}
/// Valida si es un nsec válido
static bool isValidNsec(String input) {
if (input.startsWith('nostr:')) input = input.substring(6);
return input.startsWith('nsec1') && nsecToHex(input) != null;
}
/// Valida si es un npub válido
static bool isValidNpub(String input) {
if (input.startsWith('nostr:')) input = input.substring(6);
return input.startsWith('npub1') && npubToHex(input) != null;
}
// ============ HELPERS PRIVADOS ============
static Uint8List _hexToBytes(String hex) {
final result = Uint8List(hex.length ~/ 2);
for (var i = 0; i < hex.length; i += 2) {
result[i ~/ 2] = int.parse(hex.substring(i, i + 2), radix: 16);
}
return result;
}
static String _bytesToHex(Uint8List bytes) {
return bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join();
}
/// Convierte bits entre bases (5 <-> 8) para bech32
static List<int> _convertBits(
List<int> data,
int fromBits,
int toBits,
bool pad,
) {
var acc = 0;
var bits = 0;
final result = <int>[];
final maxv = (1 << toBits) - 1;
for (final value in data) {
acc = (acc << fromBits) | value;
bits += fromBits;
while (bits >= toBits) {
bits -= toBits;
result.add((acc >> bits) & maxv);
}
}
if (pad && bits > 0) {
result.add((acc << (toBits - bits)) & maxv);
}
return result;
}
}
+146
View File
@@ -0,0 +1,146 @@
/// Utilidades para detección de P2PK en tokens Cashu
///
/// Soporta:
/// - Tokens V3 (cashuA - base64 JSON)
/// - Tokens V4 (cashuB - CBOR)
library;
import 'dart:convert';
import 'dart:typed_data';
import 'package:cbor/cbor.dart';
class P2PKUtils {
/// Extrae la pubkey bloqueada de un token P2PK (si existe)
/// Retorna null si el token no es P2PK o no se puede parsear
static String? extractLockedPubkey(String encodedToken) {
try {
String token = encodedToken.trim();
// Remover prefijo cashu: si existe
if (token.startsWith('cashu:')) {
token = token.substring(6);
}
if (token.startsWith('cashuA')) {
return _extractFromV3(token);
} else if (token.startsWith('cashuB')) {
return _extractFromV4(token);
}
return null;
} catch (e) {
return null;
}
}
/// Verifica si un token está bloqueado con P2PK
static bool isP2PKLocked(String encodedToken) {
return extractLockedPubkey(encodedToken) != null;
}
// ============ V3 TOKEN (cashuA - base64 JSON) ============
static String? _extractFromV3(String token) {
try {
final base64Part = token.substring(6);
final jsonStr = utf8.decode(base64.decode(base64Part));
final data = jsonDecode(jsonStr) as Map<String, dynamic>;
// Estructura: { token: [{ proofs: [...] }] }
final tokenList = data['token'] as List?;
if (tokenList == null || tokenList.isEmpty) return null;
final proofs = tokenList[0]['proofs'] as List?;
if (proofs == null || proofs.isEmpty) return null;
final secret = proofs[0]['secret'];
return _parseP2PKSecret(secret);
} catch (e) {
return null;
}
}
// ============ V4 TOKEN (cashuB - CBOR) ============
static String? _extractFromV4(String token) {
try {
final cborPart = token.substring(6);
// V4 usa base64url
final bytes = base64Url.decode(_padBase64(cborPart));
final decoded = cbor.decode(bytes);
if (decoded is! CborMap) return null;
// Estructura CBOR: { t: [{ p: [...] }] } o { p: [...] }
final tokenData = decoded.toObject() as Map<dynamic, dynamic>;
List<dynamic>? proofs;
if (tokenData.containsKey('t')) {
final t = tokenData['t'] as List?;
if (t != null && t.isNotEmpty) {
final firstToken = t[0];
if (firstToken is Map) {
proofs = firstToken['p'] as List?;
}
}
} else if (tokenData.containsKey('p')) {
proofs = tokenData['p'] as List?;
}
if (proofs == null || proofs.isEmpty) return null;
final firstProof = proofs[0];
if (firstProof is! Map) return null;
final secret = firstProof['s'];
return _parseP2PKSecret(secret);
} catch (e) {
return null;
}
}
// ============ PARSER DE SECRET P2PK (NUT-10) ============
/// Parsea el secret de un proof para extraer la pubkey P2PK
/// Estructura P2PK: ["P2PK", { "nonce": "...", "data": "<pubkey>", "tags": [...] }]
static String? _parseP2PKSecret(dynamic secret) {
if (secret == null) return null;
String secretStr;
if (secret is String) {
secretStr = secret;
} else if (secret is List<int>) {
// Uint8List también es List<int>, así que este branch cubre ambos
secretStr = utf8.decode(secret);
} else {
return null;
}
try {
final parsed = jsonDecode(secretStr);
if (parsed is List && parsed.length >= 2 && parsed[0] == 'P2PK') {
final data = parsed[1];
if (data is Map && data.containsKey('data')) {
final pubkey = data['data'];
// Validar que sea hex de 64 chars (x-only) o 66 chars (SEC1 comprimido)
if (pubkey is String &&
(RegExp(r'^[0-9a-fA-F]{64}$').hasMatch(pubkey) ||
RegExp(r'^0[23][0-9a-fA-F]{64}$').hasMatch(pubkey))) {
return pubkey.toLowerCase();
}
}
}
} catch (e) {
// No es P2PK o formato inválido
}
return null;
}
/// Agrega padding a base64url si es necesario
static String _padBase64(String input) {
final remainder = input.length % 4;
if (remainder == 0) return input;
return input + '=' * (4 - remainder);
}
}
+62
View File
@@ -0,0 +1,62 @@
/// Peanut encoding/decoding for Cashu tokens.
///
/// Encodes Cashu tokens into invisible Unicode Variation Selectors
/// appended to a 🥜 emoji, compatible with cashu.me's implementation.
///
/// Each character of the base64 token is mapped to a Variation Selector:
/// - charCode 0-15 → VS1-VS16 (U+FE00 to U+FE0F)
/// - charCode 16-255 → VS17-VS256 (U+E0100 to U+E01EF)
class PeanutCodec {
static const String _peanutEmoji = '🥜';
/// Encodes a Cashu token string into peanut format (🥜 + variation selectors).
static String encode(String token) {
final buffer = StringBuffer(_peanutEmoji);
for (int i = 0; i < token.length; i++) {
final byte = token.codeUnitAt(i);
if (byte < 16) {
// VS1-VS16: U+FE00 to U+FE0F
buffer.writeCharCode(0xFE00 + byte);
} else if (byte < 256) {
// VS17-VS256: U+E0100 to U+E01EF
buffer.writeCharCode(0xE0100 + (byte - 16));
}
}
return buffer.toString();
}
/// Decodes a peanut-encoded string back to the original Cashu token.
/// Returns null if the input is not valid peanut format.
static String? decode(String peanut) {
final trimmed = peanut.trimLeft();
if (!trimmed.startsWith(_peanutEmoji)) return null;
final buffer = StringBuffer();
final runes = trimmed.runes.toList();
// Skip the first rune (🥜 emoji)
for (int i = 1; i < runes.length; i++) {
final codePoint = runes[i];
if (codePoint >= 0xFE00 && codePoint <= 0xFE0F) {
// VS1-VS16 → byte 0-15
buffer.writeCharCode(codePoint - 0xFE00);
} else if (codePoint >= 0xE0100 && codePoint <= 0xE01EF) {
// VS17-VS256 → byte 16-255
buffer.writeCharCode(codePoint - 0xE0100 + 16);
}
// Skip any other characters (whitespace, etc.)
}
final result = buffer.toString();
return result.isNotEmpty ? result : null;
}
/// Returns true if the string starts with the 🥜 emoji.
static bool isPeanut(String data) {
return data.trimLeft().startsWith(_peanutEmoji);
}
}
+55 -2
View File
@@ -52,6 +52,7 @@
}
},
"needWords": "(du brauchst 12 oder 24)",
"restoreScanningMint": "Mint wird nach vorhandenen Token durchsucht...",
"restoreError": "Wiederherstellungsfehler: {error}",
"@restoreError": {
"placeholders": {
@@ -136,6 +137,20 @@
},
"keepTokenWarning": "Bewahre diesen Token auf, bis der Empfänger ihn einlöst. Wenn du ihn verlierst, verlierst du die Mittel.",
"tokenCopiedToClipboard": "Token in Zwischenablage kopiert",
"copyAsEmoji": "Als Emoji kopieren",
"emojiCopiedToClipboard": "Token als Emoji kopiert 🥜",
"peanutDecodeError": "Emoji-Token konnte nicht dekodiert werden. Möglicherweise beschädigt.",
"nfcWrite": "Auf NFC-Tag schreiben",
"nfcRead": "NFC-Tag lesen",
"nfcHoldNear": "Gerät an NFC-Tag halten...",
"nfcWriteSuccess": "Token auf NFC-Tag geschrieben",
"nfcWriteError": "NFC-Schreibfehler: {error}",
"@nfcWriteError": { "placeholders": { "error": { "type": "String" } } },
"nfcReadError": "NFC-Lesefehler: {error}",
"@nfcReadError": { "placeholders": { "error": { "type": "String" } } },
"nfcDisabled": "NFC ist deaktiviert. Aktiviere es in den Einstellungen.",
"nfcUnsupported": "Dieses Gerät unterstützt kein NFC",
"amountToDeposit": "Einzuzahlender Betrag:",
"descriptionOptional": "Beschreibung (optional):",
@@ -271,6 +286,10 @@
"russian": "Русский",
"german": "Deutsch",
"italian": "Italiano",
"korean": "한국어",
"chinese": "中文",
"japanese": "日本語",
"swahili": "Kiswahili",
"mnemonicNotFound": "Mnemonic nicht gefunden",
"createPin": "PIN erstellen",
@@ -291,7 +310,7 @@
}
},
"close": "Schließen",
"aboutDescription": "Eine Cashu Wallet mit kubanischer DNA für die ganze Welt. Bruder von La Chispa.",
"aboutDescription": "Eine Cashu Wallet mit kubanischer DNA für die ganze Welt. Bruder von LaChispa.",
"couldNotOpenLink": "Link konnte nicht geöffnet werden",
"deleteWalletQuestion": "Wallet löschen?",
@@ -397,6 +416,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",
@@ -481,5 +502,37 @@
"scanLightningInvoiceHint": "Scanne eine Lightning Rechnung (lnbc...)",
"addMintQuestion": "Diesen Mint hinzufügen?",
"cameraPermissionDenied": "Kamera-Berechtigung verweigert",
"paymentRequestNotSupported": "Zahlungsanfragen werden noch nicht unterstützt"
"paymentRequestNotSupported": "Zahlungsanfragen werden noch nicht unterstützt",
"p2pkTitle": "P2PK-Schlüssel",
"p2pkSettingsDescription": "Gesperrtes ecash empfangen",
"p2pkExperimental": "P2PK ist experimentell. Mit Vorsicht verwenden.",
"p2pkPendingSendWarning": "Du hast einen ausstehenden P2PK-Versand. Gehe zum Verlauf und aktualisiere, nachdem der Empfänger den Token eingelöst hat.",
"p2pkExperimentalShort": "Experimentell",
"p2pkPrimaryKey": "Primärschlüssel",
"p2pkDerived": "Abgeleitet",
"p2pkImported": "Importiert",
"p2pkImportedKeys": "Importierte Schlüssel",
"p2pkNoImportedKeys": "Keine importierten Schlüssel",
"p2pkShowQR": "QR zeigen",
"p2pkCopy": "Kopieren",
"p2pkImportNsec": "nsec importieren",
"p2pkImport": "Importieren",
"p2pkEnterLabel": "Name für diesen Schlüssel",
"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",
"p2pkInvalidPrivateKey": "Ungültiger privater Schlüssel",
"p2pkLockedToYou": "Für dich gesperrt",
"p2pkLockedToOther": "Für anderen Schlüssel gesperrt",
"p2pkCannotUnlock": "Du hast nicht den Schlüssel, um diesen Token zu entsperren",
"p2pkEnterPrivateKey": "Privaten Schlüssel eingeben (nsec)",
"p2pkDeleteTitle": "Schlüssel löschen",
"p2pkDeleteConfirm": "Diesen Schlüssel löschen? Du kannst keine Token mehr empfangen, die daran gesperrt sind.",
"p2pkRequiresConnection": "P2PK erfordert Verbindung zum Mint",
"p2pkErrorMaxKeysReached": "Maximale Anzahl importierter Schlüssel erreicht (10)",
"p2pkErrorInvalidNsec": "Ungültiger nsec",
"p2pkErrorKeyAlreadyExists": "Dieser Schlüssel existiert bereits",
"p2pkErrorKeyNotFound": "Schlüssel nicht gefunden",
"p2pkErrorCannotDeletePrimary": "Primärschlüssel kann nicht gelöscht werden"
}
+64 -2
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",
@@ -111,6 +112,28 @@
"tokenCashuAnimatedQr": "Cashu Token (animated QR - {fragments} UR fragments)",
"keepTokenWarning": "Keep this token until the recipient claims it. If you lose it, you will lose the funds.",
"tokenCopiedToClipboard": "Token copied to clipboard",
"copyAsEmoji": "Copy as emoji",
"emojiCopiedToClipboard": "Token copied as emoji 🥜",
"peanutDecodeError": "Could not decode emoji token. It may be corrupted.",
"nfcWrite": "Write to NFC tag",
"nfcRead": "Read NFC tag",
"nfcHoldNear": "Hold device near NFC tag...",
"nfcWriteSuccess": "Token written to NFC tag",
"nfcWriteError": "NFC write error: {error}",
"@nfcWriteError": {
"placeholders": {
"error": { "type": "String" }
}
},
"nfcReadError": "NFC read error: {error}",
"@nfcReadError": {
"placeholders": {
"error": { "type": "String" }
}
},
"nfcDisabled": "NFC is disabled. Enable it in Settings.",
"nfcUnsupported": "This device does not support NFC",
"amountToDeposit": "Amount to deposit:",
"descriptionOptional": "Description (optional):",
@@ -214,6 +237,10 @@
"russian": "Русский",
"german": "Deutsch",
"italian": "Italiano",
"korean": "한국어",
"chinese": "中文",
"japanese": "日本語",
"swahili": "Kiswahili",
"mnemonicNotFound": "Mnemonic not found",
"createPin": "Create PIN",
@@ -229,7 +256,7 @@
"selectLanguage": "Select language",
"languageChanged": "Language changed to {language}",
"close": "Close",
"aboutDescription": "A Cashu wallet with Cuban DNA for the entire world. Brother of La Chispa.",
"aboutDescription": "A Cashu wallet with Cuban DNA for the entire world. Brother of LaChispa.",
"couldNotOpenLink": "Could not open link",
"deleteWalletQuestion": "Delete wallet?",
@@ -292,6 +319,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",
@@ -361,5 +390,38 @@
"scanLightningInvoiceHint": "Scan a Lightning invoice (lnbc...)",
"addMintQuestion": "Add this mint?",
"cameraPermissionDenied": "Camera permission denied",
"paymentRequestNotSupported": "Payment requests are not yet supported"
"paymentRequestNotSupported": "Payment requests are not yet supported",
"p2pkTitle": "P2PK Keys",
"p2pkSettingsDescription": "Receive locked ecash",
"p2pkExperimental": "P2PK is experimental. Use with caution.",
"p2pkPendingSendWarning": "You have a pending P2PK send. Go to history and refresh after the recipient claims the token.",
"p2pkExperimentalShort": "Experimental",
"p2pkPrimaryKey": "Primary Key",
"p2pkDerived": "Derived",
"p2pkImported": "Imported",
"p2pkImportedKeys": "Imported Keys",
"p2pkNoImportedKeys": "No imported keys",
"p2pkShowQR": "Show QR",
"p2pkCopy": "Copy",
"p2pkImportNsec": "Import nsec",
"p2pkImport": "Import",
"p2pkEnterLabel": "Name for this key",
"p2pkLockToKey": "Send with P2PK signature",
"p2pkLockDescription": "Only the recipient can claim",
"p2pkReceiverPubkey": "npub1... or hex (64/66 chars)",
"p2pkInvalidPubkey": "Invalid public key",
"p2pkInvalidPrivateKey": "Invalid private key",
"p2pkLockedToYou": "Locked to you",
"p2pkLockedToOther": "Locked to another key",
"p2pkCannotUnlock": "You don't have the key to unlock this token",
"p2pkEnterPrivateKey": "Enter private key (nsec)",
"p2pkDeleteTitle": "Delete key",
"p2pkDeleteConfirm": "Delete this key? You won't be able to receive tokens locked to it.",
"p2pkRequiresConnection": "P2PK requires connection to the mint",
"p2pkErrorMaxKeysReached": "Maximum imported keys reached (10)",
"p2pkErrorInvalidNsec": "Invalid nsec",
"p2pkErrorKeyAlreadyExists": "This key already exists",
"p2pkErrorKeyNotFound": "Key not found",
"p2pkErrorCannotDeletePrimary": "Cannot delete primary key"
}
+64 -2
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": {
@@ -136,6 +137,28 @@
},
"keepTokenWarning": "Guarda este token hasta que el receptor lo reclame. Si lo pierdes, perderás los fondos.",
"tokenCopiedToClipboard": "Token copiado al portapapeles",
"copyAsEmoji": "Copiar como emoji",
"emojiCopiedToClipboard": "Token copiado como emoji 🥜",
"peanutDecodeError": "No se pudo decodificar el token emoji. Puede estar corrupto.",
"nfcWrite": "Escribir en tag NFC",
"nfcRead": "Leer tag NFC",
"nfcHoldNear": "Acerca el dispositivo al tag NFC...",
"nfcWriteSuccess": "Token escrito en tag NFC",
"nfcWriteError": "Error NFC al escribir: {error}",
"@nfcWriteError": {
"placeholders": {
"error": { "type": "String" }
}
},
"nfcReadError": "Error NFC al leer: {error}",
"@nfcReadError": {
"placeholders": {
"error": { "type": "String" }
}
},
"nfcDisabled": "NFC está desactivado. Actívalo en Ajustes.",
"nfcUnsupported": "Este dispositivo no soporta NFC",
"amountToDeposit": "Monto a depositar:",
"descriptionOptional": "Descripción (opcional):",
@@ -271,6 +294,10 @@
"russian": "Русский",
"german": "Deutsch",
"italian": "Italiano",
"korean": "한국어",
"chinese": "中文",
"japanese": "日本語",
"swahili": "Kiswahili",
"mnemonicNotFound": "No se encontró el mnemonic",
"createPin": "Crear PIN",
@@ -291,7 +318,7 @@
}
},
"close": "Cerrar",
"aboutDescription": "Un wallet de Cashu con ADN cubano para el mundo entero. Hermano de La Chispa.",
"aboutDescription": "Un wallet de Cashu con ADN cubano para el mundo entero. Hermano de LaChispa.",
"couldNotOpenLink": "No se pudo abrir el enlace",
"deleteWalletQuestion": "¿Borrar wallet?",
@@ -397,6 +424,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",
@@ -481,5 +510,38 @@
"scanLightningInvoiceHint": "Escanea un invoice Lightning (lnbc...)",
"addMintQuestion": "¿Agregar este mint?",
"cameraPermissionDenied": "Permiso de cámara denegado",
"paymentRequestNotSupported": "Los payment requests aún no están soportados"
"paymentRequestNotSupported": "Los payment requests aún no están soportados",
"p2pkTitle": "Claves P2PK",
"p2pkSettingsDescription": "Recibir ecash bloqueado",
"p2pkExperimental": "P2PK es experimental. Úsala con precaución.",
"p2pkPendingSendWarning": "Tienes un envío P2PK pendiente. Ve al historial y presiona actualizar después de que el destinatario reclame el token.",
"p2pkExperimentalShort": "Experimental",
"p2pkPrimaryKey": "Clave Principal",
"p2pkDerived": "Derivada",
"p2pkImported": "Importada",
"p2pkImportedKeys": "Claves Importadas",
"p2pkNoImportedKeys": "No hay claves importadas",
"p2pkShowQR": "Mostrar QR",
"p2pkCopy": "Copiar",
"p2pkImportNsec": "Importar nsec",
"p2pkImport": "Importar",
"p2pkEnterLabel": "Nombre para esta clave",
"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",
"p2pkInvalidPrivateKey": "Clave privada inválida",
"p2pkLockedToYou": "Bloqueado para ti",
"p2pkLockedToOther": "Bloqueado para otra clave",
"p2pkCannotUnlock": "No tienes la clave para desbloquear este token",
"p2pkEnterPrivateKey": "Ingresa la clave privada (nsec)",
"p2pkDeleteTitle": "Eliminar clave",
"p2pkDeleteConfirm": "¿Eliminar esta clave? No podrás recibir tokens bloqueados a ella.",
"p2pkRequiresConnection": "P2PK requiere conexión al mint",
"p2pkErrorMaxKeysReached": "Máximo de claves importadas alcanzado (10)",
"p2pkErrorInvalidNsec": "nsec inválido",
"p2pkErrorKeyAlreadyExists": "Esta clave ya existe",
"p2pkErrorKeyNotFound": "Clave no encontrada",
"p2pkErrorCannotDeletePrimary": "No se puede eliminar la clave principal"
}
+55 -2
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": {
@@ -136,6 +137,20 @@
},
"keepTokenWarning": "Conservez ce token jusqu'à ce que le destinataire le réclame. Si vous le perdez, vous perdrez les fonds.",
"tokenCopiedToClipboard": "Token copié dans le presse-papiers",
"copyAsEmoji": "Copier en emoji",
"emojiCopiedToClipboard": "Token copié en emoji 🥜",
"peanutDecodeError": "Impossible de décoder le token emoji. Il est peut-être corrompu.",
"nfcWrite": "Écrire sur tag NFC",
"nfcRead": "Lire tag NFC",
"nfcHoldNear": "Approchez l'appareil du tag NFC...",
"nfcWriteSuccess": "Token écrit sur le tag NFC",
"nfcWriteError": "Erreur NFC écriture : {error}",
"@nfcWriteError": { "placeholders": { "error": { "type": "String" } } },
"nfcReadError": "Erreur NFC lecture : {error}",
"@nfcReadError": { "placeholders": { "error": { "type": "String" } } },
"nfcDisabled": "NFC est désactivé. Activez-le dans les Paramètres.",
"nfcUnsupported": "Cet appareil ne prend pas en charge le NFC",
"amountToDeposit": "Montant à déposer :",
"descriptionOptional": "Description (optionnelle) :",
@@ -271,6 +286,10 @@
"russian": "Русский",
"german": "Deutsch",
"italian": "Italiano",
"korean": "한국어",
"chinese": "中文",
"japanese": "日本語",
"swahili": "Kiswahili",
"mnemonicNotFound": "Mnémonique non trouvé",
"createPin": "Créer un PIN",
@@ -291,7 +310,7 @@
}
},
"close": "Fermer",
"aboutDescription": "Un portefeuille Cashu avec ADN cubain pour le monde entier. Frère de La Chispa.",
"aboutDescription": "Un portefeuille Cashu avec ADN cubain pour le monde entier. Frère de LaChispa.",
"couldNotOpenLink": "Impossible d'ouvrir le lien",
"deleteWalletQuestion": "Supprimer le portefeuille ?",
@@ -397,6 +416,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",
@@ -481,5 +502,37 @@
"scanLightningInvoiceHint": "Scannez une facture Lightning (lnbc...)",
"addMintQuestion": "Ajouter ce mint ?",
"cameraPermissionDenied": "Permission de la caméra refusée",
"paymentRequestNotSupported": "Les demandes de paiement ne sont pas encore prises en charge"
"paymentRequestNotSupported": "Les demandes de paiement ne sont pas encore prises en charge",
"p2pkTitle": "Clés P2PK",
"p2pkSettingsDescription": "Recevoir ecash verrouillé",
"p2pkExperimental": "P2PK est expérimental. Utiliser avec prudence.",
"p2pkPendingSendWarning": "Vous avez un envoi P2PK en attente. Allez dans l'historique et actualisez après que le destinataire a réclamé le jeton.",
"p2pkExperimentalShort": "Expérimental",
"p2pkPrimaryKey": "Clé Principale",
"p2pkDerived": "Dérivée",
"p2pkImported": "Importée",
"p2pkImportedKeys": "Clés Importées",
"p2pkNoImportedKeys": "Aucune clé importée",
"p2pkShowQR": "Afficher QR",
"p2pkCopy": "Copier",
"p2pkImportNsec": "Importer nsec",
"p2pkImport": "Importer",
"p2pkEnterLabel": "Nom pour cette clé",
"p2pkLockToKey": "Envoi avec signature P2PK",
"p2pkLockDescription": "Seul le destinataire peut réclamer",
"p2pkReceiverPubkey": "npub1... ou hex (64/66 caractères)",
"p2pkInvalidPubkey": "Clé publique invalide",
"p2pkInvalidPrivateKey": "Clé privée invalide",
"p2pkLockedToYou": "Verrouillé pour vous",
"p2pkLockedToOther": "Verrouillé pour une autre clé",
"p2pkCannotUnlock": "Vous n'avez pas la clé pour déverrouiller ce token",
"p2pkEnterPrivateKey": "Entrer la clé privée (nsec)",
"p2pkDeleteTitle": "Supprimer la clé",
"p2pkDeleteConfirm": "Supprimer cette clé ? Vous ne pourrez plus recevoir de tokens verrouillés dessus.",
"p2pkRequiresConnection": "P2PK nécessite une connexion au mint",
"p2pkErrorMaxKeysReached": "Nombre maximum de clés importées atteint (10)",
"p2pkErrorInvalidNsec": "nsec invalide",
"p2pkErrorKeyAlreadyExists": "Cette clé existe déjà",
"p2pkErrorKeyNotFound": "Clé non trouvée",
"p2pkErrorCannotDeletePrimary": "Impossible de supprimer la clé principale"
}
+55 -2
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": {
@@ -136,6 +137,20 @@
},
"keepTokenWarning": "Conserva questo token finché il destinatario non lo riscatta. Se lo perdi, perderai i fondi.",
"tokenCopiedToClipboard": "Token copiato negli appunti",
"copyAsEmoji": "Copia come emoji",
"emojiCopiedToClipboard": "Token copiato come emoji 🥜",
"peanutDecodeError": "Impossibile decodificare il token emoji. Potrebbe essere corrotto.",
"nfcWrite": "Scrivi su tag NFC",
"nfcRead": "Leggi tag NFC",
"nfcHoldNear": "Avvicina il dispositivo al tag NFC...",
"nfcWriteSuccess": "Token scritto sul tag NFC",
"nfcWriteError": "Errore NFC scrittura: {error}",
"@nfcWriteError": { "placeholders": { "error": { "type": "String" } } },
"nfcReadError": "Errore NFC lettura: {error}",
"@nfcReadError": { "placeholders": { "error": { "type": "String" } } },
"nfcDisabled": "NFC è disattivato. Attivalo nelle Impostazioni.",
"nfcUnsupported": "Questo dispositivo non supporta NFC",
"amountToDeposit": "Importo da depositare:",
"descriptionOptional": "Descrizione (opzionale):",
@@ -271,6 +286,10 @@
"russian": "Русский",
"german": "Deutsch",
"italian": "Italiano",
"korean": "한국어",
"chinese": "中文",
"japanese": "日本語",
"swahili": "Kiswahili",
"mnemonicNotFound": "Mnemonic non trovato",
"createPin": "Crea PIN",
@@ -291,7 +310,7 @@
}
},
"close": "Chiudi",
"aboutDescription": "Un portafoglio Cashu con DNA cubano per il mondo intero. Fratello di La Chispa.",
"aboutDescription": "Un portafoglio Cashu con DNA cubano per il mondo intero. Fratello di LaChispa.",
"couldNotOpenLink": "Impossibile aprire il link",
"deleteWalletQuestion": "Eliminare portafoglio?",
@@ -397,6 +416,8 @@
"delete": "Elimina",
"offlineSend": "Invio offline",
"selectAll": "Tutto",
"deselectAll": "Nessuno",
"selectNotesToSend": "Seleziona le note da inviare:",
"totalToSend": "Totale da inviare",
"notesSelected": "{count} note selezionate",
@@ -481,5 +502,37 @@
"scanLightningInvoiceHint": "Scansiona una fattura Lightning (lnbc...)",
"addMintQuestion": "Aggiungere questo mint?",
"cameraPermissionDenied": "Permesso fotocamera negato",
"paymentRequestNotSupported": "Le richieste di pagamento non sono ancora supportate"
"paymentRequestNotSupported": "Le richieste di pagamento non sono ancora supportate",
"p2pkTitle": "Chiavi P2PK",
"p2pkSettingsDescription": "Ricevi ecash bloccato",
"p2pkExperimental": "P2PK è sperimentale. Usare con cautela.",
"p2pkPendingSendWarning": "Hai un invio P2PK in sospeso. Vai alla cronologia e aggiorna dopo che il destinatario ha riscattato il token.",
"p2pkExperimentalShort": "Sperimentale",
"p2pkPrimaryKey": "Chiave Principale",
"p2pkDerived": "Derivata",
"p2pkImported": "Importata",
"p2pkImportedKeys": "Chiavi Importate",
"p2pkNoImportedKeys": "Nessuna chiave importata",
"p2pkShowQR": "Mostra QR",
"p2pkCopy": "Copia",
"p2pkImportNsec": "Importa nsec",
"p2pkImport": "Importa",
"p2pkEnterLabel": "Nome per questa chiave",
"p2pkLockToKey": "Invio con firma P2PK",
"p2pkLockDescription": "Solo il destinatario può riscattare",
"p2pkReceiverPubkey": "npub1... o hex (64/66 caratteri)",
"p2pkInvalidPubkey": "Chiave pubblica non valida",
"p2pkInvalidPrivateKey": "Chiave privata non valida",
"p2pkLockedToYou": "Bloccato per te",
"p2pkLockedToOther": "Bloccato per un'altra chiave",
"p2pkCannotUnlock": "Non hai la chiave per sbloccare questo token",
"p2pkEnterPrivateKey": "Inserisci chiave privata (nsec)",
"p2pkDeleteTitle": "Elimina chiave",
"p2pkDeleteConfirm": "Eliminare questa chiave? Non potrai ricevere token bloccati ad essa.",
"p2pkRequiresConnection": "P2PK richiede connessione al mint",
"p2pkErrorMaxKeysReached": "Numero massimo di chiavi importate raggiunto (10)",
"p2pkErrorInvalidNsec": "nsec non valido",
"p2pkErrorKeyAlreadyExists": "Questa chiave esiste già",
"p2pkErrorKeyNotFound": "Chiave non trovata",
"p2pkErrorCannotDeletePrimary": "Impossibile eliminare la chiave principale"
}
+538
View File
@@ -0,0 +1,538 @@
{
"@@locale": "ja",
"appName": "ElCaju",
"appTagline": "プライベートなecashウォレット",
"loadingMessage1": "コインを暗号化中...",
"loadingMessage2": "e-トークンを準備中...",
"loadingMessage3": "Mintに接続中...",
"loadingMessage4": "デフォルトでプライバシー保護。",
"loadingMessage5": "ブラインド署名中...",
"loadingMessage6": "Go full Calle...",
"loadingMessage7": "Cashu + Bitchat = プライバシー + 自由",
"aboutTagline": "国境のないプライバシー。",
"welcomeTitle": "ElCajuへようこそ",
"welcomeSubtitle": "世界のためのCashu。キューバ製。",
"createWallet": "新しいウォレットを作成",
"restoreWallet": "ウォレットを復元",
"createWalletTitle": "ウォレット作成",
"creatingWallet": "ウォレットを作成中...",
"generatingSeed": "シードフレーズを安全に生成中",
"createWalletDescription": "12語のシードフレーズが生成されます。\n安全な場所に保管してください。",
"generateWallet": "ウォレットを生成",
"walletCreated": "ウォレット作成完了!",
"walletCreatedDescription": "ウォレットの準備ができました。今すぐシードフレーズをバックアップすることをお勧めします。",
"backupWarning": "バックアップがないと、デバイスを紛失した場合に資金にアクセスできなくなります。",
"backupNow": "今すぐバックアップ",
"backupLater": "後でする",
"backupTitle": "バックアップ",
"seedPhraseTitle": "シードフレーズ",
"seedPhraseDescription": "この12語を順番に保存してください。ウォレットを復元する唯一の方法です。",
"revealSeedPhrase": "シードフレーズを表示",
"tapToReveal": "ボタンをタップして\nシードフレーズを表示",
"copyToClipboard": "クリップボードにコピー",
"seedCopied": "フレーズをクリップボードにコピーしました",
"neverShareSeed": "シードフレーズを誰にも共有しないでください。",
"confirmBackup": "シードフレーズを安全な場所に保存しました",
"continue_": "続ける",
"restoreTitle": "ウォレットを復元",
"enterSeedPhrase": "シードフレーズを入力",
"enterSeedDescription": "12語または24語をスペースで区切って入力してください。",
"seedPlaceholder": "単語1 単語2 単語3 ...",
"wordCount": "{count}語",
"@wordCount": {
"placeholders": {
"count": { "type": "int" }
}
},
"needWords": "12語または24語が必要)",
"restoreScanningMint": "ミントで既存のトークンをスキャン中...",
"restoreError": "復元エラー:{error}",
"@restoreError": {
"placeholders": {
"error": { "type": "String" }
}
},
"homeTitle": "ホーム",
"receive": "受取",
"send": "送金",
"sendAction": "送金 ↗",
"receiveAction": "↘ 受取",
"deposit": "入金",
"withdraw": "出金",
"lightning": "Lightning",
"cashu": "Cashu",
"ecash": "Ecash",
"history": "履歴",
"noTransactions": "取引履歴がありません",
"depositOrReceive": "satsを入金または受け取って開始",
"noMint": "Mintなし",
"balance": "残高",
"sats": "sats",
"pasteEcashToken": "ecashトークンを貼り付け",
"generateInvoiceToDeposit": "入金用インボイスを生成",
"createEcashToken": "ecashトークンを作成",
"payLightningInvoice": "Lightningインボイスを支払う",
"receiveCashu": "Cashuを受け取る",
"pasteTheCashuToken": "Cashuトークンを貼り付け:",
"pasteFromClipboard": "クリップボードから貼り付け",
"validToken": "有効なトークン",
"invalidToken": "無効または不正なトークン",
"amount": "金額:",
"mint": "Mint",
"claiming": "請求中...",
"claimTokens": "トークンを請求",
"tokensReceived": "トークンを受け取りました",
"backToHome": "ホームに戻る",
"tokenAlreadyClaimed": "このトークンは既に請求済みです",
"unknownMint": "不明なMintからのトークン",
"claimError": "請求エラー:{error}",
"@claimError": {
"placeholders": {
"error": { "type": "String" }
}
},
"sendCashu": "Cashuを送る",
"selectNotesManually": "ノートを手動で選択",
"amountToSend": "送金額:",
"available": "利用可能:",
"max": "(最大)",
"memoOptional": "メモ(任意):",
"memoPlaceholder": "この支払いは何のためですか?",
"creatingToken": "トークン作成中...",
"createToken": "トークンを作成",
"noActiveMint": "アクティブなMintがありません",
"offlineModeMessage": "接続なし。オフラインモード使用中...",
"confirmSend": "送金を確認",
"confirm": "確認",
"cancel": "キャンセル",
"insufficientBalance": "残高不足",
"tokenCreationError": "トークン作成エラー:{error}",
"@tokenCreationError": {
"placeholders": {
"error": { "type": "String" }
}
},
"tokenCreated": "トークン作成完了",
"copy": "コピー",
"share": "共有",
"tokenCashu": "Cashuトークン",
"tokenCashuAnimatedQr": "Cashuトークン(アニメーションQR - {fragments}個のURフラグメント)",
"@tokenCashuAnimatedQr": {
"placeholders": {
"fragments": { "type": "int" }
}
},
"keepTokenWarning": "受取人が請求するまでこのトークンを保管してください。紛失すると資金を失います。",
"tokenCopiedToClipboard": "トークンをクリップボードにコピーしました",
"copyAsEmoji": "絵文字としてコピー",
"emojiCopiedToClipboard": "トークンを絵文字としてコピーしました 🥜",
"peanutDecodeError": "絵文字トークンをデコードできませんでした。破損している可能性があります。",
"nfcWrite": "NFCタグに書き込む",
"nfcRead": "NFCタグを読み取る",
"nfcHoldNear": "デバイスをNFCタグに近づけてください...",
"nfcWriteSuccess": "トークンをNFCタグに書き込みました",
"nfcWriteError": "NFC書き込みエラー: {error}",
"@nfcWriteError": { "placeholders": { "error": { "type": "String" } } },
"nfcReadError": "NFC読み取りエラー: {error}",
"@nfcReadError": { "placeholders": { "error": { "type": "String" } } },
"nfcDisabled": "NFCが無効です。設定で有効にしてください。",
"nfcUnsupported": "このデバイスはNFCに対応していません",
"amountToDeposit": "入金額:",
"descriptionOptional": "説明(任意):",
"depositPlaceholder": "この入金は何のためですか?",
"generating": "生成中...",
"generateInvoice": "インボイスを生成",
"depositLightning": "Lightning入金",
"payInvoiceTitle": "インボイスを支払う",
"generatingInvoice": "インボイス生成中...",
"waitingForPayment": "支払い待ち...",
"paymentReceived": "支払いを受け取りました",
"tokensIssued": "トークンが発行されました!",
"error": "エラー",
"unknownError": "不明なエラー",
"back": "戻る",
"copyInvoice": "インボイスをコピー",
"description": "説明:",
"invoiceCopiedToClipboard": "インボイスをクリップボードにコピーしました",
"deposited": "{amount} {unit}を入金しました",
"@deposited": {
"placeholders": {
"amount": { "type": "String" },
"unit": { "type": "String" }
}
},
"pasteLightningInvoice": "Lightningインボイスを貼り付け:",
"gettingQuote": "見積もり取得中...",
"validInvoice": "有効なインボイス",
"invalidInvoice": "無効なインボイス",
"invalidInvoiceMalformed": "無効または不正なインボイス",
"feeReserved": "予約手数料:",
"total": "合計:",
"paying": "支払い中...",
"payInvoice": "インボイスを支払う",
"confirmPayment": "支払いを確認",
"pay": "支払う",
"fee": "手数料",
"invoiceExpired": "インボイス期限切れ",
"amountOutOfRange": "金額が許容範囲外です",
"resolvingType": "{type}を解決中...",
"@resolvingType": {
"placeholders": {
"type": { "type": "String" }
}
},
"invoiceAlreadyPaid": "インボイスは支払い済みです",
"paymentError": "支払いエラー:{error}",
"@paymentError": {
"placeholders": {
"error": { "type": "String" }
}
},
"sent": "{amount} {unit}を送金しました",
"@sent": {
"placeholders": {
"amount": { "type": "String" },
"unit": { "type": "String" }
}
},
"filterAll": "すべて",
"filterPending": "保留中",
"filterEcash": "Ecash",
"filterLightning": "Lightning",
"receiveTokensToStart": "Cashuトークンを受け取って開始",
"noPendingTransactions": "保留中の取引はありません",
"allTransactionsCompleted": "すべての取引が完了しました",
"noEcashTransactions": "Ecash取引はありません",
"sendOrReceiveTokens": "Cashuトークンを送受信",
"noLightningTransactions": "Lightning取引はありません",
"depositOrWithdrawLightning": "Lightningで入出金",
"pendingStatus": "保留中",
"receivedStatus": "受取済み",
"sentStatus": "送金済み",
"now": "たった今",
"agoMinutes": "{minutes}分前",
"@agoMinutes": {
"placeholders": {
"minutes": { "type": "int" }
}
},
"agoHours": "{hours}時間前",
"@agoHours": {
"placeholders": {
"hours": { "type": "int" }
}
},
"agoDays": "{days}日前",
"@agoDays": {
"placeholders": {
"days": { "type": "int" }
}
},
"lightningInvoice": "Lightningインボイス",
"receivedEcash": "Ecash受取",
"sentEcash": "Ecash送金",
"outgoingLightningPayment": "Lightning出金",
"invoiceNotAvailable": "インボイスは利用できません",
"tokenNotAvailable": "トークンは利用できません",
"unit": "単位",
"status": "ステータス",
"pending": "保留中",
"memo": "メモ",
"copyInvoiceButton": "インボイスをコピー",
"copyButton": "コピー",
"invoiceCopied": "インボイスをコピーしました",
"tokenCopied": "トークンをコピーしました",
"speed": "速度:",
"settings": "設定",
"walletSection": "ウォレット",
"backupSeedPhrase": "シードフレーズのバックアップ",
"viewRecoveryWords": "復元用単語を表示",
"connectedMints": "接続中のMint",
"manageCashuMints": "Cashu Mintを管理",
"pinAccess": "PINアクセス",
"pinEnabled": "有効",
"protectWithPin": "PINでアプリを保護",
"recoverTokens": "トークンを復元",
"scanMintsWithSeed": "シードフレーズでMintをスキャン",
"appearanceSection": "言語",
"language": "言語",
"informationSection": "情報",
"version": "バージョン",
"about": "このアプリについて",
"deleteWallet": "ウォレットを削除",
"spanish": "Español",
"english": "English",
"portuguese": "Português",
"french": "Français",
"russian": "Русский",
"german": "Deutsch",
"italian": "Italiano",
"korean": "한국어",
"chinese": "中文",
"japanese": "日本語",
"swahili": "Kiswahili",
"mnemonicNotFound": "ニーモニックが見つかりません",
"createPin": "PINを作成",
"enterPinDigits": "4桁のPINを入力",
"confirmPin": "PINを確認",
"enterPinAgain": "PINを再入力",
"pinMismatch": "PINが一致しません",
"pinActivated": "PINが有効になりました",
"pinDeactivated": "PINが無効になりました",
"verifyPin": "PINを確認",
"enterCurrentPin": "現在のPINを入力",
"incorrectPin": "PINが正しくありません",
"selectLanguage": "言語を選択",
"languageChanged": "言語を{language}に変更しました",
"@languageChanged": {
"placeholders": {
"language": { "type": "String" }
}
},
"close": "閉じる",
"aboutDescription": "キューバのDNAを持つ世界のためのCashuウォレット。LaChispaの兄弟。",
"couldNotOpenLink": "リンクを開けませんでした",
"deleteWalletQuestion": "ウォレットを削除しますか?",
"actionIrreversible": "この操作は取り消せません",
"deleteWalletWarning": "シードフレーズとトークンを含むすべてのデータが削除されます。バックアップがあることを確認してください。",
"typeDeleteToConfirm": "確認のため「DELETE」と入力:",
"deleteConfirmWord": "DELETE",
"deleteError": "削除エラー:{error}",
"@deleteError": {
"placeholders": {
"error": { "type": "String" }
}
},
"recoverTokensTitle": "トークンを復元",
"recoverTokensDescription": "シードフレーズに関連付けられたトークンを復元するためにMintをスキャン(NUT-13",
"useCurrentSeedPhrase": "現在のシードフレーズを使用",
"scanWithSavedWords": "保存された12語でMintをスキャン",
"useOtherSeedPhrase": "別のシードフレーズを使用",
"recoverFromOtherWords": "別の12語からトークンを復元",
"mintsToScan": "スキャンするMint",
"allMints": "すべてのMint{count}件)",
"@allMints": {
"placeholders": {
"count": { "type": "int" }
}
},
"specificMint": "特定のMint",
"enterMnemonicWords": "12語をスペースで区切って入力...",
"scanMints": "Mintをスキャン",
"selectMintToScan": "スキャンするMintを選択",
"mnemonicMustHaveWords": "ニーモニックは12語または24語である必要があります",
"noConnectedMintsToScan": "スキャンできる接続済みMintがありません",
"recoveredTokens": "{mints}件のMintから{tokens}を復元しました!",
"@recoveredTokens": {
"placeholders": {
"tokens": { "type": "String" },
"mints": { "type": "int" }
}
},
"scanCompleteNoTokens": "スキャン完了。新しいトークンは見つかりませんでした。",
"mintsWithError": "{count}件のMintでエラー)",
"@mintsWithError": {
"placeholders": {
"count": { "type": "int" }
}
},
"recoveredFromMint": "{mint}から{tokens}を復元しました!",
"@recoveredFromMint": {
"placeholders": {
"tokens": { "type": "String" },
"mint": { "type": "String" }
}
},
"noTokensFoundInMint": "{mint}でトークンが見つかりませんでした。",
"@noTokensFoundInMint": {
"placeholders": {
"mint": { "type": "String" }
}
},
"recoveredAndTransferred": "{amount} {unit}を復元してウォレットに転送しました!",
"@recoveredAndTransferred": {
"placeholders": {
"amount": { "type": "String" },
"unit": { "type": "String" }
}
},
"noTokensForMnemonic": "そのニーモニックに関連付けられたトークンは見つかりませんでした。",
"noConnectedMints": "接続済みMintがありません",
"addMintToStart": "Mintを追加して開始",
"addMint": "Mintを追加",
"mintDeleted": "Mintを削除しました",
"activeMintUpdated": "アクティブMintを更新しました",
"mintUrl": "Mint URL",
"mintUrlPlaceholder": "https://mint.example.com",
"urlMustStartWithHttps": "URLはhttps://で始まる必要があります",
"connectingToMint": "Mintに接続中...",
"mintAddedSuccessfully": "Mintを追加しました",
"couldNotConnectToMint": "Mintに接続できませんでした",
"add": "追加",
"success": "成功",
"loading": "読み込み中...",
"retry": "再試行",
"activeMint": "アクティブMint",
"mintMessage": "Mintメッセージ",
"url": "URL",
"currency": "通貨",
"unknown": "不明",
"useThisMint": "このMintを使用",
"copyMintUrl": "Mint URLをコピー",
"deleteMint": "Mintを削除",
"copied": "{label}をコピーしました",
"@copied": {
"placeholders": {
"label": { "type": "String" }
}
},
"deleteMintConfirmTitle": "Mintを削除",
"deleteMintConfirmMessage": "このMintに残高がある場合、失われます。よろしいですか?",
"delete": "削除",
"offlineSend": "オフライン送金",
"selectAll": "すべて",
"deselectAll": "なし",
"selectNotesToSend": "送信するノートを選択:",
"totalToSend": "送金合計",
"notesSelected": "{count}件のノートを選択",
"@notesSelected": {
"placeholders": {
"count": { "type": "int" }
}
},
"loadingProofsError": "プルーフ読み込みエラー:{error}",
"@loadingProofsError": {
"placeholders": {
"error": { "type": "String" }
}
},
"creatingTokenError": "トークン作成エラー:{error}",
"@creatingTokenError": {
"placeholders": {
"error": { "type": "String" }
}
},
"unknownState": "不明な状態",
"depositAmountTitle": "{amount} {unit}を入金",
"@depositAmountTitle": {
"placeholders": {
"amount": { "type": "String" },
"unit": { "type": "String" }
}
},
"receiveNow": "今すぐ受け取る",
"receiveLater": "後で受け取る",
"tokenSavedForLater": "トークンを保存しました。後で請求できます",
"noConnectionTokenSaved": "接続なし。トークンを保存しました。後で請求できます。",
"unknownMintOffline": "このトークンは不明なMintからのものです。インターネットに接続してMintを追加し、トークンを請求してください。",
"noConnectionTryLater": "Mintに接続できません。後でもう一度お試しください。",
"saveTokenError": "トークンの保存エラー。もう一度お試しください。",
"pendingTokenLimitReached": "保留中トークンの上限に達しました(最大50件)",
"filterToReceive": "受取予定",
"noPendingTokens": "保留中のトークンはありません",
"noPendingTokensHint": "トークンを保存して後で請求",
"pendingBadge": "保留中",
"expiresInDays": "{days, plural, =1{1日後に期限切れ} other{{days}日後に期限切れ}}",
"@expiresInDays": {
"placeholders": {
"days": { "type": "int" }
}
},
"retryCount": "{count}回リトライ",
"@retryCount": {
"placeholders": {
"count": { "type": "int" }
}
},
"claimNow": "今すぐ請求",
"pendingTokenClaimedSuccess": "{amount} {unit}を請求しました",
"@pendingTokenClaimedSuccess": {
"placeholders": {
"amount": { "type": "String" },
"unit": { "type": "String" }
}
},
"pendingTokensClaimed": "{count, plural, =1{1件のトークン({amount} {unit})を請求しました} other{{count}件のトークン({amount} {unit})を請求しました}}",
"@pendingTokensClaimed": {
"placeholders": {
"count": { "type": "int" },
"amount": { "type": "String" },
"unit": { "type": "String" }
}
},
"scan": "スキャン",
"scanQrCode": "QRコードをスキャン",
"scanCashuToken": "Cashuトークンをスキャン",
"scanLightningInvoice": "インボイスをスキャン",
"scanningAnimatedQr": "アニメーションQRをスキャン中...",
"pointCameraAtQr": "カメラをQRコードに向けてください",
"pointCameraAtCashuQr": "カメラをCashuトークンのQRに向けてください",
"pointCameraAtInvoiceQr": "カメラをインボイスのQRに向けてください",
"unrecognizedQrCode": "認識できないQRコード",
"scanCashuTokenHint": "Cashuトークンをスキャン(cashuA...またはcashuB...",
"scanLightningInvoiceHint": "Lightningインボイスをスキャン(lnbc...",
"addMintQuestion": "このMintを追加しますか?",
"cameraPermissionDenied": "カメラの許可が拒否されました",
"paymentRequestNotSupported": "支払いリクエストはまだサポートされていません",
"p2pkTitle": "P2PK鍵",
"p2pkSettingsDescription": "ロックされたecashを受け取る",
"p2pkExperimental": "P2PKは実験的機能です。注意してご使用ください。",
"p2pkPendingSendWarning": "保留中のP2PK送信があります。受取人がトークンを受け取った後、履歴で更新してください。",
"p2pkExperimentalShort": "実験的",
"p2pkPrimaryKey": "プライマリ鍵",
"p2pkDerived": "派生",
"p2pkImported": "インポート済み",
"p2pkImportedKeys": "インポートした鍵",
"p2pkNoImportedKeys": "インポートした鍵はありません",
"p2pkShowQR": "QRを表示",
"p2pkCopy": "コピー",
"p2pkImportNsec": "nsecをインポート",
"p2pkImport": "インポート",
"p2pkEnterLabel": "この鍵の名前",
"p2pkLockToKey": "P2PK署名で送信",
"p2pkLockDescription": "受取人のみが請求可能",
"p2pkReceiverPubkey": "npub1... または hex64/66文字)",
"p2pkInvalidPubkey": "無効な公開鍵",
"p2pkInvalidPrivateKey": "無効な秘密鍵",
"p2pkLockedToYou": "あなた宛にロック",
"p2pkLockedToOther": "別の鍵にロック",
"p2pkCannotUnlock": "このトークンをアンロックする鍵がありません",
"p2pkEnterPrivateKey": "秘密鍵を入力(nsec",
"p2pkDeleteTitle": "鍵を削除",
"p2pkDeleteConfirm": "この鍵を削除しますか?この鍵にロックされたトークンを受け取れなくなります。",
"p2pkRequiresConnection": "P2PKにはMintへの接続が必要です",
"p2pkErrorMaxKeysReached": "インポート可能な鍵の上限に達しました(10)",
"p2pkErrorInvalidNsec": "無効なnsec",
"p2pkErrorKeyAlreadyExists": "この鍵は既に存在します",
"p2pkErrorKeyNotFound": "鍵が見つかりません",
"p2pkErrorCannotDeletePrimary": "プライマリ鍵は削除できません"
}
+538
View File
@@ -0,0 +1,538 @@
{
"@@locale": "ko",
"appName": "ElCaju",
"appTagline": "당신의 프라이빗 ecash 지갑",
"loadingMessage1": "코인 암호화 중...",
"loadingMessage2": "e-토큰 준비 중...",
"loadingMessage3": "Mint에 연결 중...",
"loadingMessage4": "기본 프라이버시.",
"loadingMessage5": "블라인드 서명 토큰...",
"loadingMessage6": "Go full Calle...",
"loadingMessage7": "Cashu + Bitchat = 프라이버시 + 자유",
"aboutTagline": "국경 없는 프라이버시.",
"welcomeTitle": "ElCaju에 오신 것을 환영합니다",
"welcomeSubtitle": "세계를 위한 Cashu. 쿠바에서 제작.",
"createWallet": "새 지갑 만들기",
"restoreWallet": "지갑 복구",
"createWalletTitle": "지갑 만들기",
"creatingWallet": "지갑 생성 중...",
"generatingSeed": "시드 문구를 안전하게 생성 중",
"createWalletDescription": "12개의 단어로 된 시드 문구가 생성됩니다.\n안전한 곳에 보관하세요.",
"generateWallet": "지갑 생성",
"walletCreated": "지갑이 생성되었습니다!",
"walletCreatedDescription": "지갑이 준비되었습니다. 지금 시드 문구를 백업하는 것을 권장합니다.",
"backupWarning": "백업 없이는 기기를 분실하면 자금에 접근할 수 없습니다.",
"backupNow": "지금 백업",
"backupLater": "나중에 하기",
"backupTitle": "백업",
"seedPhraseTitle": "시드 문구",
"seedPhraseDescription": "이 12개의 단어를 순서대로 저장하세요. 지갑을 복구하는 유일한 방법입니다.",
"revealSeedPhrase": "시드 문구 보기",
"tapToReveal": "버튼을 탭하여\n시드 문구를 확인하세요",
"copyToClipboard": "클립보드에 복사",
"seedCopied": "문구가 클립보드에 복사되었습니다",
"neverShareSeed": "시드 문구를 절대 다른 사람과 공유하지 마세요.",
"confirmBackup": "시드 문구를 안전한 곳에 저장했습니다",
"continue_": "계속",
"restoreTitle": "지갑 복구",
"enterSeedPhrase": "시드 문구를 입력하세요",
"enterSeedDescription": "12개 또는 24개의 단어를 공백으로 구분하여 입력하세요.",
"seedPlaceholder": "단어1 단어2 단어3 ...",
"wordCount": "{count}개 단어",
"@wordCount": {
"placeholders": {
"count": { "type": "int" }
}
},
"needWords": "(12개 또는 24개 필요)",
"restoreScanningMint": "민트에서 기존 토큰 검색 중...",
"restoreError": "복구 오류: {error}",
"@restoreError": {
"placeholders": {
"error": { "type": "String" }
}
},
"homeTitle": "홈",
"receive": "받기",
"send": "보내기",
"sendAction": "보내기 ↗",
"receiveAction": "↘ 받기",
"deposit": "입금",
"withdraw": "출금",
"lightning": "Lightning",
"cashu": "Cashu",
"ecash": "Ecash",
"history": "거래 내역",
"noTransactions": "거래 내역이 없습니다",
"depositOrReceive": "시작하려면 sats를 입금하거나 받으세요",
"noMint": "Mint 없음",
"balance": "잔액",
"sats": "sats",
"pasteEcashToken": "ecash 토큰 붙여넣기",
"generateInvoiceToDeposit": "입금용 인보이스 생성",
"createEcashToken": "ecash 토큰 만들기",
"payLightningInvoice": "Lightning 인보이스 결제",
"receiveCashu": "Cashu 받기",
"pasteTheCashuToken": "Cashu 토큰을 붙여넣으세요:",
"pasteFromClipboard": "클립보드에서 붙여넣기",
"validToken": "유효한 토큰",
"invalidToken": "유효하지 않거나 손상된 토큰",
"amount": "금액:",
"mint": "Mint:",
"claiming": "청구 중...",
"claimTokens": "토큰 청구",
"tokensReceived": "토큰을 받았습니다",
"backToHome": "홈으로 돌아가기",
"tokenAlreadyClaimed": "이 토큰은 이미 청구되었습니다",
"unknownMint": "알 수 없는 mint의 토큰",
"claimError": "청구 오류: {error}",
"@claimError": {
"placeholders": {
"error": { "type": "String" }
}
},
"sendCashu": "Cashu 보내기",
"selectNotesManually": "수동으로 노트 선택",
"amountToSend": "보낼 금액:",
"available": "사용 가능:",
"max": "(최대)",
"memoOptional": "메모 (선택사항):",
"memoPlaceholder": "이 결제는 무엇을 위한 것인가요?",
"creatingToken": "토큰 생성 중...",
"createToken": "토큰 만들기",
"noActiveMint": "활성 mint 없음",
"offlineModeMessage": "연결 없음. 오프라인 모드...",
"confirmSend": "보내기 확인",
"confirm": "확인",
"cancel": "취소",
"insufficientBalance": "잔액 부족",
"tokenCreationError": "토큰 생성 오류: {error}",
"@tokenCreationError": {
"placeholders": {
"error": { "type": "String" }
}
},
"tokenCreated": "토큰이 생성되었습니다",
"copy": "복사",
"share": "공유",
"tokenCashu": "Cashu 토큰",
"tokenCashuAnimatedQr": "Cashu 토큰 (애니메이션 QR - {fragments}개 UR 조각)",
"@tokenCashuAnimatedQr": {
"placeholders": {
"fragments": { "type": "int" }
}
},
"keepTokenWarning": "수신자가 청구할 때까지 이 토큰을 보관하세요. 분실하면 자금을 잃게 됩니다.",
"tokenCopiedToClipboard": "토큰이 클립보드에 복사되었습니다",
"copyAsEmoji": "이모지로 복사",
"emojiCopiedToClipboard": "토큰이 이모지로 복사되었습니다 🥜",
"peanutDecodeError": "이모지 토큰을 디코딩할 수 없습니다. 손상되었을 수 있습니다.",
"nfcWrite": "NFC 태그에 쓰기",
"nfcRead": "NFC 태그 읽기",
"nfcHoldNear": "기기를 NFC 태그에 가까이 대세요...",
"nfcWriteSuccess": "토큰이 NFC 태그에 기록되었습니다",
"nfcWriteError": "NFC 쓰기 오류: {error}",
"@nfcWriteError": { "placeholders": { "error": { "type": "String" } } },
"nfcReadError": "NFC 읽기 오류: {error}",
"@nfcReadError": { "placeholders": { "error": { "type": "String" } } },
"nfcDisabled": "NFC가 비활성화되어 있습니다. 설정에서 활성화하세요.",
"nfcUnsupported": "이 기기는 NFC를 지원하지 않습니다",
"amountToDeposit": "입금할 금액:",
"descriptionOptional": "설명 (선택사항):",
"depositPlaceholder": "이 입금은 무엇을 위한 것인가요?",
"generating": "생성 중...",
"generateInvoice": "인보이스 생성",
"depositLightning": "Lightning 입금",
"payInvoiceTitle": "인보이스 결제",
"generatingInvoice": "인보이스 생성 중...",
"waitingForPayment": "결제 대기 중...",
"paymentReceived": "결제 완료",
"tokensIssued": "토큰이 발행되었습니다!",
"error": "오류",
"unknownError": "알 수 없는 오류",
"back": "뒤로",
"copyInvoice": "인보이스 복사",
"description": "설명:",
"invoiceCopiedToClipboard": "인보이스가 클립보드에 복사되었습니다",
"deposited": "{amount} {unit} 입금됨",
"@deposited": {
"placeholders": {
"amount": { "type": "String" },
"unit": { "type": "String" }
}
},
"pasteLightningInvoice": "Lightning 인보이스를 붙여넣으세요:",
"gettingQuote": "견적 조회 중...",
"validInvoice": "유효한 인보이스",
"invalidInvoice": "유효하지 않은 인보이스",
"invalidInvoiceMalformed": "유효하지 않거나 손상된 인보이스",
"feeReserved": "예약 수수료:",
"total": "총액:",
"paying": "결제 중...",
"payInvoice": "인보이스 결제",
"confirmPayment": "결제 확인",
"pay": "결제",
"fee": "수수료",
"invoiceExpired": "인보이스 만료됨",
"amountOutOfRange": "허용 범위를 벗어난 금액",
"resolvingType": "{type} 확인 중...",
"@resolvingType": {
"placeholders": {
"type": { "type": "String" }
}
},
"invoiceAlreadyPaid": "이미 결제된 인보이스",
"paymentError": "결제 오류: {error}",
"@paymentError": {
"placeholders": {
"error": { "type": "String" }
}
},
"sent": "{amount} {unit} 전송됨",
"@sent": {
"placeholders": {
"amount": { "type": "String" },
"unit": { "type": "String" }
}
},
"filterAll": "전체",
"filterPending": "대기 중",
"filterEcash": "Ecash",
"filterLightning": "Lightning",
"receiveTokensToStart": "시작하려면 Cashu 토큰을 받으세요",
"noPendingTransactions": "대기 중인 거래 없음",
"allTransactionsCompleted": "모든 거래가 완료되었습니다",
"noEcashTransactions": "Ecash 거래 없음",
"sendOrReceiveTokens": "Cashu 토큰을 보내거나 받으세요",
"noLightningTransactions": "Lightning 거래 없음",
"depositOrWithdrawLightning": "Lightning으로 입금 또는 출금",
"pendingStatus": "대기 중",
"receivedStatus": "받음",
"sentStatus": "보냄",
"now": "방금",
"agoMinutes": "{minutes}분 전",
"@agoMinutes": {
"placeholders": {
"minutes": { "type": "int" }
}
},
"agoHours": "{hours}시간 전",
"@agoHours": {
"placeholders": {
"hours": { "type": "int" }
}
},
"agoDays": "{days}일 전",
"@agoDays": {
"placeholders": {
"days": { "type": "int" }
}
},
"lightningInvoice": "Lightning 인보이스",
"receivedEcash": "Ecash 받음",
"sentEcash": "Ecash 보냄",
"outgoingLightningPayment": "Lightning 송금",
"invoiceNotAvailable": "인보이스를 사용할 수 없음",
"tokenNotAvailable": "토큰을 사용할 수 없음",
"unit": "단위",
"status": "상태",
"pending": "대기 중",
"memo": "메모",
"copyInvoiceButton": "인보이스 복사",
"copyButton": "복사",
"invoiceCopied": "인보이스 복사됨",
"tokenCopied": "토큰 복사됨",
"speed": "속도:",
"settings": "설정",
"walletSection": "지갑",
"backupSeedPhrase": "시드 문구 백업",
"viewRecoveryWords": "복구 단어 보기",
"connectedMints": "연결된 Mint",
"manageCashuMints": "Cashu mint 관리",
"pinAccess": "PIN 잠금",
"pinEnabled": "활성화됨",
"protectWithPin": "PIN으로 앱 보호",
"recoverTokens": "토큰 복구",
"scanMintsWithSeed": "시드 문구로 mint 스캔",
"appearanceSection": "언어",
"language": "언어",
"informationSection": "정보",
"version": "버전",
"about": "정보",
"deleteWallet": "지갑 삭제",
"spanish": "Español",
"english": "English",
"portuguese": "Português",
"french": "Français",
"russian": "Русский",
"german": "Deutsch",
"italian": "Italiano",
"korean": "한국어",
"chinese": "中文",
"japanese": "日本語",
"swahili": "Kiswahili",
"mnemonicNotFound": "니모닉을 찾을 수 없음",
"createPin": "PIN 만들기",
"enterPinDigits": "4자리 PIN을 입력하세요",
"confirmPin": "PIN 확인",
"enterPinAgain": "PIN을 다시 입력하세요",
"pinMismatch": "PIN이 일치하지 않습니다",
"pinActivated": "PIN 활성화됨",
"pinDeactivated": "PIN 비활성화됨",
"verifyPin": "PIN 확인",
"enterCurrentPin": "현재 PIN을 입력하세요",
"incorrectPin": "잘못된 PIN",
"selectLanguage": "언어 선택",
"languageChanged": "언어가 {language}(으)로 변경되었습니다",
"@languageChanged": {
"placeholders": {
"language": { "type": "String" }
}
},
"close": "닫기",
"aboutDescription": "전 세계를 위한 쿠바 DNA를 가진 Cashu 지갑. LaChispa의 형제.",
"couldNotOpenLink": "링크를 열 수 없음",
"deleteWalletQuestion": "지갑을 삭제하시겠습니까?",
"actionIrreversible": "이 작업은 되돌릴 수 없습니다",
"deleteWalletWarning": "시드 문구와 토큰을 포함한 모든 데이터가 삭제됩니다. 백업이 있는지 확인하세요.",
"typeDeleteToConfirm": "확인하려면 \"삭제\"를 입력하세요:",
"deleteConfirmWord": "삭제",
"deleteError": "삭제 오류: {error}",
"@deleteError": {
"placeholders": {
"error": { "type": "String" }
}
},
"recoverTokensTitle": "토큰 복구",
"recoverTokensDescription": "시드 문구와 연결된 토큰을 복구하기 위해 mint 스캔 (NUT-13)",
"useCurrentSeedPhrase": "현재 시드 문구 사용",
"scanWithSavedWords": "저장된 12개 단어로 mint 스캔",
"useOtherSeedPhrase": "다른 시드 문구 사용",
"recoverFromOtherWords": "다른 12개 단어에서 토큰 복구",
"mintsToScan": "스캔할 Mint:",
"allMints": "모든 mint ({count}개)",
"@allMints": {
"placeholders": {
"count": { "type": "int" }
}
},
"specificMint": "특정 mint",
"enterMnemonicWords": "공백으로 구분된 12개 단어를 입력하세요...",
"scanMints": "Mint 스캔",
"selectMintToScan": "스캔할 mint 선택",
"mnemonicMustHaveWords": "니모닉은 12개 또는 24개 단어여야 합니다",
"noConnectedMintsToScan": "스캔할 연결된 mint 없음",
"recoveredTokens": "{mints}개 mint에서 {tokens} 복구됨!",
"@recoveredTokens": {
"placeholders": {
"tokens": { "type": "String" },
"mints": { "type": "int" }
}
},
"scanCompleteNoTokens": "스캔 완료. 새 토큰을 찾지 못했습니다.",
"mintsWithError": "({count}개 mint 오류)",
"@mintsWithError": {
"placeholders": {
"count": { "type": "int" }
}
},
"recoveredFromMint": "{mint}에서 {tokens} 복구됨!",
"@recoveredFromMint": {
"placeholders": {
"tokens": { "type": "String" },
"mint": { "type": "String" }
}
},
"noTokensFoundInMint": "{mint}에서 토큰을 찾을 수 없음.",
"@noTokensFoundInMint": {
"placeholders": {
"mint": { "type": "String" }
}
},
"recoveredAndTransferred": "{amount} {unit}이(가) 지갑으로 복구 및 전송되었습니다!",
"@recoveredAndTransferred": {
"placeholders": {
"amount": { "type": "String" },
"unit": { "type": "String" }
}
},
"noTokensForMnemonic": "해당 니모닉과 연결된 토큰을 찾을 수 없음.",
"noConnectedMints": "연결된 mint 없음",
"addMintToStart": "시작하려면 mint를 추가하세요",
"addMint": "Mint 추가",
"mintDeleted": "Mint 삭제됨",
"activeMintUpdated": "활성 mint 업데이트됨",
"mintUrl": "Mint URL:",
"mintUrlPlaceholder": "https://mint.example.com",
"urlMustStartWithHttps": "URL은 https://로 시작해야 합니다",
"connectingToMint": "Mint에 연결 중...",
"mintAddedSuccessfully": "Mint가 성공적으로 추가되었습니다",
"couldNotConnectToMint": "Mint에 연결할 수 없음",
"add": "추가",
"success": "성공",
"loading": "로딩 중...",
"retry": "다시 시도",
"activeMint": "활성 Mint",
"mintMessage": "Mint 메시지",
"url": "URL",
"currency": "통화",
"unknown": "알 수 없음",
"useThisMint": "이 mint 사용",
"copyMintUrl": "Mint URL 복사",
"deleteMint": "Mint 삭제",
"copied": "{label} 복사됨",
"@copied": {
"placeholders": {
"label": { "type": "String" }
}
},
"deleteMintConfirmTitle": "Mint 삭제",
"deleteMintConfirmMessage": "이 mint에 잔액이 있으면 잃게 됩니다. 확실합니까?",
"delete": "삭제",
"offlineSend": "오프라인 전송",
"selectAll": "전체",
"deselectAll": "없음",
"selectNotesToSend": "보낼 노트를 선택하세요:",
"totalToSend": "보낼 총액",
"notesSelected": "{count}개 노트 선택됨",
"@notesSelected": {
"placeholders": {
"count": { "type": "int" }
}
},
"loadingProofsError": "증명 로드 오류: {error}",
"@loadingProofsError": {
"placeholders": {
"error": { "type": "String" }
}
},
"creatingTokenError": "토큰 생성 오류: {error}",
"@creatingTokenError": {
"placeholders": {
"error": { "type": "String" }
}
},
"unknownState": "알 수 없는 상태",
"depositAmountTitle": "{amount} {unit} 입금",
"@depositAmountTitle": {
"placeholders": {
"amount": { "type": "String" },
"unit": { "type": "String" }
}
},
"receiveNow": "지금 받기",
"receiveLater": "나중에 받기",
"tokenSavedForLater": "나중에 청구하기 위해 토큰이 저장되었습니다",
"noConnectionTokenSaved": "연결 없음. 나중에 청구하기 위해 토큰이 저장되었습니다.",
"unknownMintOffline": "이 토큰은 알 수 없는 mint의 것입니다. 인터넷에 연결하여 추가하고 토큰을 청구하세요.",
"noConnectionTryLater": "Mint에 연결할 수 없음. 나중에 다시 시도하세요.",
"saveTokenError": "토큰 저장 오류. 다시 시도해 주세요.",
"pendingTokenLimitReached": "대기 중 토큰 한도 도달 (최대 50개)",
"filterToReceive": "받을 항목",
"noPendingTokens": "대기 중인 토큰 없음",
"noPendingTokensHint": "나중에 청구할 토큰을 저장하세요",
"pendingBadge": "대기 중",
"expiresInDays": "{days, plural, =1{1일 후 만료} other{{days}일 후 만료}}",
"@expiresInDays": {
"placeholders": {
"days": { "type": "int" }
}
},
"retryCount": "{count}회 재시도",
"@retryCount": {
"placeholders": {
"count": { "type": "int" }
}
},
"claimNow": "지금 청구",
"pendingTokenClaimedSuccess": "{amount} {unit} 청구됨",
"@pendingTokenClaimedSuccess": {
"placeholders": {
"amount": { "type": "String" },
"unit": { "type": "String" }
}
},
"pendingTokensClaimed": "{count, plural, =1{1개 토큰 청구됨 ({amount} {unit})} other{{count}개 토큰 청구됨 ({amount} {unit})}}",
"@pendingTokensClaimed": {
"placeholders": {
"count": { "type": "int" },
"amount": { "type": "String" },
"unit": { "type": "String" }
}
},
"scan": "스캔",
"scanQrCode": "QR 스캔",
"scanCashuToken": "Cashu 토큰 스캔",
"scanLightningInvoice": "인보이스 스캔",
"scanningAnimatedQr": "애니메이션 QR 스캔 중...",
"pointCameraAtQr": "카메라를 QR 코드에 맞추세요",
"pointCameraAtCashuQr": "카메라를 Cashu 토큰 QR에 맞추세요",
"pointCameraAtInvoiceQr": "카메라를 인보이스 QR에 맞추세요",
"unrecognizedQrCode": "인식할 수 없는 QR 코드",
"scanCashuTokenHint": "Cashu 토큰 스캔 (cashuA... 또는 cashuB...)",
"scanLightningInvoiceHint": "Lightning 인보이스 스캔 (lnbc...)",
"addMintQuestion": "이 mint를 추가하시겠습니까?",
"cameraPermissionDenied": "카메라 권한이 거부되었습니다",
"paymentRequestNotSupported": "결제 요청은 아직 지원되지 않습니다",
"p2pkTitle": "P2PK 키",
"p2pkSettingsDescription": "잠긴 ecash 받기",
"p2pkExperimental": "P2PK는 실험적 기능입니다. 주의하여 사용하세요.",
"p2pkPendingSendWarning": "대기 중인 P2PK 전송이 있습니다. 수신자가 토큰을 수령한 후 기록에서 새로고침하세요.",
"p2pkExperimentalShort": "실험적",
"p2pkPrimaryKey": "기본 키",
"p2pkDerived": "파생됨",
"p2pkImported": "가져옴",
"p2pkImportedKeys": "가져온 키",
"p2pkNoImportedKeys": "가져온 키가 없습니다",
"p2pkShowQR": "QR 표시",
"p2pkCopy": "복사",
"p2pkImportNsec": "nsec 가져오기",
"p2pkImport": "가져오기",
"p2pkEnterLabel": "이 키의 이름",
"p2pkLockToKey": "P2PK 서명으로 전송",
"p2pkLockDescription": "수신자만 청구 가능",
"p2pkReceiverPubkey": "npub1... 또는 hex (64/66자)",
"p2pkInvalidPubkey": "유효하지 않은 공개 키",
"p2pkInvalidPrivateKey": "유효하지 않은 개인 키",
"p2pkLockedToYou": "당신에게 잠김",
"p2pkLockedToOther": "다른 키로 잠김",
"p2pkCannotUnlock": "이 토큰을 잠금 해제할 키가 없습니다",
"p2pkEnterPrivateKey": "개인 키 입력 (nsec)",
"p2pkDeleteTitle": "키 삭제",
"p2pkDeleteConfirm": "이 키를 삭제하시겠습니까? 이 키로 잠긴 토큰을 받을 수 없게 됩니다.",
"p2pkRequiresConnection": "P2PK는 mint 연결이 필요합니다",
"p2pkErrorMaxKeysReached": "가져온 키의 최대 개수에 도달했습니다 (10)",
"p2pkErrorInvalidNsec": "유효하지 않은 nsec",
"p2pkErrorKeyAlreadyExists": "이 키는 이미 존재합니다",
"p2pkErrorKeyNotFound": "키를 찾을 수 없습니다",
"p2pkErrorCannotDeletePrimary": "기본 키는 삭제할 수 없습니다"
}
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
+55 -2
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": {
@@ -136,6 +137,20 @@
},
"keepTokenWarning": "Guarde este token até que o destinatário o resgate. Se você perdê-lo, perderá os fundos.",
"tokenCopiedToClipboard": "Token copiado para área de transferência",
"copyAsEmoji": "Copiar como emoji",
"emojiCopiedToClipboard": "Token copiado como emoji 🥜",
"peanutDecodeError": "Não foi possível decodificar o token emoji. Pode estar corrompido.",
"nfcWrite": "Escrever em tag NFC",
"nfcRead": "Ler tag NFC",
"nfcHoldNear": "Aproxime o dispositivo da tag NFC...",
"nfcWriteSuccess": "Token escrito na tag NFC",
"nfcWriteError": "Erro NFC ao escrever: {error}",
"@nfcWriteError": { "placeholders": { "error": { "type": "String" } } },
"nfcReadError": "Erro NFC ao ler: {error}",
"@nfcReadError": { "placeholders": { "error": { "type": "String" } } },
"nfcDisabled": "NFC está desativado. Ative nas Configurações.",
"nfcUnsupported": "Este dispositivo não suporta NFC",
"amountToDeposit": "Valor a depositar:",
"descriptionOptional": "Descrição (opcional):",
@@ -271,6 +286,10 @@
"russian": "Русский",
"german": "Deutsch",
"italian": "Italiano",
"korean": "한국어",
"chinese": "中文",
"japanese": "日本語",
"swahili": "Kiswahili",
"mnemonicNotFound": "Mnemônico não encontrado",
"createPin": "Criar PIN",
@@ -291,7 +310,7 @@
}
},
"close": "Fechar",
"aboutDescription": "Uma wallet Cashu com DNA cubano para o mundo inteiro. Irmã de La Chispa.",
"aboutDescription": "Uma wallet Cashu com DNA cubano para o mundo inteiro. Irmã de LaChispa.",
"couldNotOpenLink": "Não foi possível abrir o link",
"deleteWalletQuestion": "Apagar wallet?",
@@ -397,6 +416,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",
@@ -481,5 +502,37 @@
"scanLightningInvoiceHint": "Escaneie um invoice Lightning (lnbc...)",
"addMintQuestion": "Adicionar este mint?",
"cameraPermissionDenied": "Permissão de câmera negada",
"paymentRequestNotSupported": "Solicitações de pagamento ainda não são suportadas"
"paymentRequestNotSupported": "Solicitações de pagamento ainda não são suportadas",
"p2pkTitle": "Chaves P2PK",
"p2pkSettingsDescription": "Receber ecash bloqueado",
"p2pkExperimental": "P2PK é experimental. Use com cautela.",
"p2pkPendingSendWarning": "Você tem um envio P2PK pendente. Vá ao histórico e atualize após o destinatário resgatar o token.",
"p2pkExperimentalShort": "Experimental",
"p2pkPrimaryKey": "Chave Principal",
"p2pkDerived": "Derivada",
"p2pkImported": "Importada",
"p2pkImportedKeys": "Chaves Importadas",
"p2pkNoImportedKeys": "Nenhuma chave importada",
"p2pkShowQR": "Mostrar QR",
"p2pkCopy": "Copiar",
"p2pkImportNsec": "Importar nsec",
"p2pkImport": "Importar",
"p2pkEnterLabel": "Nome para esta chave",
"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",
"p2pkInvalidPrivateKey": "Chave privada inválida",
"p2pkLockedToYou": "Bloqueado para você",
"p2pkLockedToOther": "Bloqueado para outra chave",
"p2pkCannotUnlock": "Você não tem a chave para desbloquear este token",
"p2pkEnterPrivateKey": "Digite a chave privada (nsec)",
"p2pkDeleteTitle": "Excluir chave",
"p2pkDeleteConfirm": "Excluir esta chave? Você não poderá receber tokens bloqueados para ela.",
"p2pkRequiresConnection": "P2PK requer conexão com o mint",
"p2pkErrorMaxKeysReached": "Número máximo de chaves importadas atingido (10)",
"p2pkErrorInvalidNsec": "nsec inválido",
"p2pkErrorKeyAlreadyExists": "Esta chave já existe",
"p2pkErrorKeyNotFound": "Chave não encontrada",
"p2pkErrorCannotDeletePrimary": "Não é possível excluir a chave principal"
}
+55 -2
View File
@@ -52,6 +52,7 @@
}
},
"needWords": "(нужно 12 или 24)",
"restoreScanningMint": "Сканирование минта на наличие токенов...",
"restoreError": "Ошибка восстановления: {error}",
"@restoreError": {
"placeholders": {
@@ -136,6 +137,20 @@
},
"keepTokenWarning": "Сохраните этот токен, пока получатель не заберёт его. Если потеряете — потеряете средства.",
"tokenCopiedToClipboard": "Токен скопирован в буфер обмена",
"copyAsEmoji": "Скопировать как эмодзи",
"emojiCopiedToClipboard": "Токен скопирован как эмодзи 🥜",
"peanutDecodeError": "Не удалось декодировать эмодзи-токен. Возможно, он повреждён.",
"nfcWrite": "Записать на NFC-метку",
"nfcRead": "Читать NFC-метку",
"nfcHoldNear": "Поднесите устройство к NFC-метке...",
"nfcWriteSuccess": "Токен записан на NFC-метку",
"nfcWriteError": "Ошибка записи NFC: {error}",
"@nfcWriteError": { "placeholders": { "error": { "type": "String" } } },
"nfcReadError": "Ошибка чтения NFC: {error}",
"@nfcReadError": { "placeholders": { "error": { "type": "String" } } },
"nfcDisabled": "NFC отключён. Включите в Настройках.",
"nfcUnsupported": "Это устройство не поддерживает NFC",
"amountToDeposit": "Сумма для пополнения:",
"descriptionOptional": "Описание (необязательно):",
@@ -271,6 +286,10 @@
"russian": "Русский",
"german": "Deutsch",
"italian": "Italiano",
"korean": "한국어",
"chinese": "中文",
"japanese": "日本語",
"swahili": "Kiswahili",
"mnemonicNotFound": "Мнемоника не найдена",
"createPin": "Создать PIN",
@@ -291,7 +310,7 @@
}
},
"close": "Закрыть",
"aboutDescription": "Cashu кошелёк с кубинской ДНК для всего мира. Брат La Chispa.",
"aboutDescription": "Cashu кошелёк с кубинской ДНК для всего мира. Брат LaChispa.",
"couldNotOpenLink": "Не удалось открыть ссылку",
"deleteWalletQuestion": "Удалить кошелёк?",
@@ -397,6 +416,8 @@
"delete": "Удалить",
"offlineSend": "Офлайн отправка",
"selectAll": "Все",
"deselectAll": "Ничего",
"selectNotesToSend": "Выберите заметки для отправки:",
"totalToSend": "Итого к отправке",
"notesSelected": "{count} заметок выбрано",
@@ -481,5 +502,37 @@
"scanLightningInvoiceHint": "Сканируйте Lightning счёт (lnbc...)",
"addMintQuestion": "Добавить этот mint?",
"cameraPermissionDenied": "Доступ к камере запрещён",
"paymentRequestNotSupported": "Запросы на оплату пока не поддерживаются"
"paymentRequestNotSupported": "Запросы на оплату пока не поддерживаются",
"p2pkTitle": "Ключи P2PK",
"p2pkSettingsDescription": "Получить заблокированный ecash",
"p2pkExperimental": "P2PK экспериментальный. Используйте с осторожностью.",
"p2pkPendingSendWarning": "У вас есть ожидающая отправка P2PK. Перейдите в историю и обновите после того, как получатель заберёт токен.",
"p2pkExperimentalShort": "Экспериментальный",
"p2pkPrimaryKey": "Основной ключ",
"p2pkDerived": "Производный",
"p2pkImported": "Импортированный",
"p2pkImportedKeys": "Импортированные ключи",
"p2pkNoImportedKeys": "Нет импортированных ключей",
"p2pkShowQR": "Показать QR",
"p2pkCopy": "Копировать",
"p2pkImportNsec": "Импортировать nsec",
"p2pkImport": "Импортировать",
"p2pkEnterLabel": "Имя для этого ключа",
"p2pkLockToKey": "Отправка с подписью P2PK",
"p2pkLockDescription": "Только получатель может получить",
"p2pkReceiverPubkey": "npub1... или hex (64/66 символов)",
"p2pkInvalidPubkey": "Недействительный публичный ключ",
"p2pkInvalidPrivateKey": "Недействительный приватный ключ",
"p2pkLockedToYou": "Заблокировано для вас",
"p2pkLockedToOther": "Заблокировано для другого ключа",
"p2pkCannotUnlock": "У вас нет ключа для разблокировки этого токена",
"p2pkEnterPrivateKey": "Введите приватный ключ (nsec)",
"p2pkDeleteTitle": "Удалить ключ",
"p2pkDeleteConfirm": "Удалить этот ключ? Вы не сможете получать токены заблокированные на него.",
"p2pkRequiresConnection": "P2PK требует подключения к mint",
"p2pkErrorMaxKeysReached": "Достигнуто максимальное количество импортированных ключей (10)",
"p2pkErrorInvalidNsec": "Недействительный nsec",
"p2pkErrorKeyAlreadyExists": "Этот ключ уже существует",
"p2pkErrorKeyNotFound": "Ключ не найден",
"p2pkErrorCannotDeletePrimary": "Невозможно удалить основной ключ"
}
+538
View File
@@ -0,0 +1,538 @@
{
"@@locale": "sw",
"appName": "ElCaju",
"appTagline": "Pochi yako ya faragha ya ecash",
"loadingMessage1": "Kusimba sarafu zako...",
"loadingMessage2": "Kuandaa e-tokeni zako...",
"loadingMessage3": "Kuunganisha na Mint...",
"loadingMessage4": "Faragha kwa chaguo-msingi.",
"loadingMessage5": "Kusaini tokeni kwa upofu...",
"loadingMessage6": "Go full Calle...",
"loadingMessage7": "Cashu + Bitchat = Faragha + Uhuru",
"aboutTagline": "Faragha bila mipaka.",
"welcomeTitle": "Karibu ElCaju",
"welcomeSubtitle": "Cashu kwa ulimwengu. Imetengenezwa Cuba.",
"createWallet": "Unda pochi mpya",
"restoreWallet": "Rejesha pochi",
"createWalletTitle": "Unda pochi",
"creatingWallet": "Kuunda pochi yako...",
"generatingSeed": "Kutengeneza maneno yako ya mbegu kwa usalama",
"createWalletDescription": "Maneno 12 ya mbegu yatatengenezwa.\nYahifadhi mahali salama.",
"generateWallet": "Tengeneza pochi",
"walletCreated": "Pochi imeundwa!",
"walletCreatedDescription": "Pochi yako iko tayari. Tunakushauri uhifadhi nakala ya maneno yako ya mbegu sasa.",
"backupWarning": "Bila nakala, utapoteza uwezo wa kufikia fedha zako ukipoteza kifaa.",
"backupNow": "Hifadhi nakala sasa",
"backupLater": "Fanya baadaye",
"backupTitle": "Nakala rudufu",
"seedPhraseTitle": "Maneno yako ya mbegu",
"seedPhraseDescription": "Hifadhi maneno haya 12 kwa mpangilio. Ndiyo njia pekee ya kurejesha pochi yako.",
"revealSeedPhrase": "Onyesha maneno ya mbegu",
"tapToReveal": "Bofya kitufe kuonyesha\nmaneno yako ya mbegu",
"copyToClipboard": "Nakili kwenye ubao wa kunakili",
"seedCopied": "Maneno yamenakiliwa kwenye ubao",
"neverShareSeed": "Usishiriki maneno yako ya mbegu na mtu yeyote.",
"confirmBackup": "Nimehifadhi maneno yangu ya mbegu mahali salama",
"continue_": "Endelea",
"restoreTitle": "Rejesha pochi",
"enterSeedPhrase": "Ingiza maneno yako ya mbegu",
"enterSeedDescription": "Andika maneno 12 au 24 yakitengwa na nafasi.",
"seedPlaceholder": "neno1 neno2 neno3 ...",
"wordCount": "maneno {count}",
"@wordCount": {
"placeholders": {
"count": { "type": "int" }
}
},
"needWords": "(unahitaji 12 au 24)",
"restoreScanningMint": "Inatafuta tokeni zilizopo kwenye mint...",
"restoreError": "Hitilafu ya kurejesha: {error}",
"@restoreError": {
"placeholders": {
"error": { "type": "String" }
}
},
"homeTitle": "Nyumbani",
"receive": "Pokea",
"send": "Tuma",
"sendAction": "Tuma ↗",
"receiveAction": "↘ Pokea",
"deposit": "Weka",
"withdraw": "Toa",
"lightning": "Lightning",
"cashu": "Cashu",
"ecash": "Ecash",
"history": "Historia",
"noTransactions": "Hakuna miamala bado",
"depositOrReceive": "Weka au pokea sats kuanza",
"noMint": "Hakuna mint",
"balance": "Salio",
"sats": "sats",
"pasteEcashToken": "Bandika tokeni ya ecash",
"generateInvoiceToDeposit": "Tengeneza ankara ya kuweka",
"createEcashToken": "Unda tokeni ya ecash",
"payLightningInvoice": "Lipa ankara ya Lightning",
"receiveCashu": "Pokea Cashu",
"pasteTheCashuToken": "Bandika tokeni ya Cashu:",
"pasteFromClipboard": "Bandika kutoka ubao",
"validToken": "Tokeni halali",
"invalidToken": "Tokeni batili au imeharibika",
"amount": "Kiasi:",
"mint": "Mint:",
"claiming": "Kudai...",
"claimTokens": "Dai tokeni",
"tokensReceived": "Tokeni zimepokelewa",
"backToHome": "Rudi nyumbani",
"tokenAlreadyClaimed": "Tokeni hii tayari imedaiwa",
"unknownMint": "Tokeni kutoka mint isiyojulikana",
"claimError": "Hitilafu ya kudai: {error}",
"@claimError": {
"placeholders": {
"error": { "type": "String" }
}
},
"sendCashu": "Tuma Cashu",
"selectNotesManually": "Chagua noti kwa mkono",
"amountToSend": "Kiasi cha kutuma:",
"available": "Inapatikana:",
"max": "(Upeo)",
"memoOptional": "Memo (si lazima):",
"memoPlaceholder": "Malipo haya ni ya nini?",
"creatingToken": "Kuunda tokeni...",
"createToken": "Unda tokeni",
"noActiveMint": "Hakuna mint inayofanya kazi",
"offlineModeMessage": "Hakuna muunganisho. Kutumia hali ya nje ya mtandao...",
"confirmSend": "Thibitisha kutuma",
"confirm": "Thibitisha",
"cancel": "Ghairi",
"insufficientBalance": "Salio halitoshi",
"tokenCreationError": "Hitilafu ya kuunda tokeni: {error}",
"@tokenCreationError": {
"placeholders": {
"error": { "type": "String" }
}
},
"tokenCreated": "Tokeni imeundwa",
"copy": "Nakili",
"share": "Shiriki",
"tokenCashu": "Tokeni ya Cashu",
"tokenCashuAnimatedQr": "Tokeni ya Cashu (QR inayosogea - vipande {fragments} vya UR)",
"@tokenCashuAnimatedQr": {
"placeholders": {
"fragments": { "type": "int" }
}
},
"keepTokenWarning": "Hifadhi tokeni hii hadi mpokeaji aidai. Ukiipoteza, utapoteza fedha.",
"tokenCopiedToClipboard": "Tokeni imenakiliwa kwenye ubao",
"copyAsEmoji": "Nakili kama emoji",
"emojiCopiedToClipboard": "Tokeni imenakiliwa kama emoji 🥜",
"peanutDecodeError": "Imeshindwa kusimbua tokeni ya emoji. Inaweza kuwa imeharibika.",
"nfcWrite": "Andika kwenye NFC tag",
"nfcRead": "Soma NFC tag",
"nfcHoldNear": "Karibia kifaa kwenye NFC tag...",
"nfcWriteSuccess": "Tokeni imeandikwa kwenye NFC tag",
"nfcWriteError": "Hitilafu ya NFC kuandika: {error}",
"@nfcWriteError": { "placeholders": { "error": { "type": "String" } } },
"nfcReadError": "Hitilafu ya NFC kusoma: {error}",
"@nfcReadError": { "placeholders": { "error": { "type": "String" } } },
"nfcDisabled": "NFC imezimwa. Iwashe katika Mipangilio.",
"nfcUnsupported": "Kifaa hiki hakitumii NFC",
"amountToDeposit": "Kiasi cha kuweka:",
"descriptionOptional": "Maelezo (si lazima):",
"depositPlaceholder": "Amana hii ni ya nini?",
"generating": "Kutengeneza...",
"generateInvoice": "Tengeneza ankara",
"depositLightning": "Weka Lightning",
"payInvoiceTitle": "Lipa ankara",
"generatingInvoice": "Kutengeneza ankara...",
"waitingForPayment": "Kusubiri malipo...",
"paymentReceived": "Malipo yamepokelewa",
"tokensIssued": "Tokeni zimetolewa!",
"error": "Hitilafu",
"unknownError": "Hitilafu isiyojulikana",
"back": "Rudi",
"copyInvoice": "Nakili ankara",
"description": "Maelezo:",
"invoiceCopiedToClipboard": "Ankara imenakiliwa kwenye ubao",
"deposited": "{amount} {unit} zimewekwa",
"@deposited": {
"placeholders": {
"amount": { "type": "String" },
"unit": { "type": "String" }
}
},
"pasteLightningInvoice": "Bandika ankara ya Lightning:",
"gettingQuote": "Kupata bei...",
"validInvoice": "Ankara halali",
"invalidInvoice": "Ankara batili",
"invalidInvoiceMalformed": "Ankara batili au imeharibika",
"feeReserved": "Ada iliyohifadhiwa:",
"total": "Jumla:",
"paying": "Kulipa...",
"payInvoice": "Lipa ankara",
"confirmPayment": "Thibitisha malipo",
"pay": "Lipa",
"fee": "ada",
"invoiceExpired": "Ankara imeisha muda",
"amountOutOfRange": "Kiasi nje ya masafa yanayoruhusiwa",
"resolvingType": "Kutatua {type}...",
"@resolvingType": {
"placeholders": {
"type": { "type": "String" }
}
},
"invoiceAlreadyPaid": "Ankara tayari imelipwa",
"paymentError": "Hitilafu ya malipo: {error}",
"@paymentError": {
"placeholders": {
"error": { "type": "String" }
}
},
"sent": "{amount} {unit} zimetumwa",
"@sent": {
"placeholders": {
"amount": { "type": "String" },
"unit": { "type": "String" }
}
},
"filterAll": "Zote",
"filterPending": "Zinazosubiri",
"filterEcash": "Ecash",
"filterLightning": "Lightning",
"receiveTokensToStart": "Pokea tokeni za Cashu kuanza",
"noPendingTransactions": "Hakuna miamala inayosubiri",
"allTransactionsCompleted": "Miamala yako yote imekamilika",
"noEcashTransactions": "Hakuna miamala ya Ecash",
"sendOrReceiveTokens": "Tuma au pokea tokeni za Cashu",
"noLightningTransactions": "Hakuna miamala ya Lightning",
"depositOrWithdrawLightning": "Weka au toa kupitia Lightning",
"pendingStatus": "Inasubiri",
"receivedStatus": "Imepokelewa",
"sentStatus": "Imetumwa",
"now": "Sasa",
"agoMinutes": "Dakika {minutes} zilizopita",
"@agoMinutes": {
"placeholders": {
"minutes": { "type": "int" }
}
},
"agoHours": "Saa {hours} zilizopita",
"@agoHours": {
"placeholders": {
"hours": { "type": "int" }
}
},
"agoDays": "Siku {days} zilizopita",
"@agoDays": {
"placeholders": {
"days": { "type": "int" }
}
},
"lightningInvoice": "Ankara ya Lightning",
"receivedEcash": "Ecash Iliyopokelewa",
"sentEcash": "Ecash Iliyotumwa",
"outgoingLightningPayment": "Malipo ya Lightning Yanayotoka",
"invoiceNotAvailable": "Ankara haipatikani",
"tokenNotAvailable": "Tokeni haipatikani",
"unit": "Kitengo",
"status": "Hali",
"pending": "Inasubiri",
"memo": "Memo",
"copyInvoiceButton": "NAKILI ANKARA",
"copyButton": "NAKILI",
"invoiceCopied": "Ankara imenakiliwa",
"tokenCopied": "Tokeni imenakiliwa",
"speed": "KASI:",
"settings": "Mipangilio",
"walletSection": "POCHI",
"backupSeedPhrase": "Nakala ya maneno ya mbegu",
"viewRecoveryWords": "Tazama maneno yako ya kurejesha",
"connectedMints": "Mint zilizounganishwa",
"manageCashuMints": "Simamia mint zako za Cashu",
"pinAccess": "Ufikiaji wa PIN",
"pinEnabled": "Imewashwa",
"protectWithPin": "Linda programu kwa PIN",
"recoverTokens": "Rejesha tokeni",
"scanMintsWithSeed": "Changanua mint na maneno ya mbegu",
"appearanceSection": "LUGHA",
"language": "Lugha",
"informationSection": "MAELEZO",
"version": "Toleo",
"about": "Kuhusu",
"deleteWallet": "Futa pochi",
"spanish": "Español",
"english": "English",
"portuguese": "Português",
"french": "Français",
"russian": "Русский",
"german": "Deutsch",
"italian": "Italiano",
"korean": "한국어",
"chinese": "中文",
"japanese": "日本語",
"swahili": "Kiswahili",
"mnemonicNotFound": "Mnemonic haijapatikana",
"createPin": "Unda PIN",
"enterPinDigits": "Ingiza PIN ya tarakimu 4",
"confirmPin": "Thibitisha PIN",
"enterPinAgain": "Ingiza PIN tena",
"pinMismatch": "PIN hazilingani",
"pinActivated": "PIN imewashwa",
"pinDeactivated": "PIN imezimwa",
"verifyPin": "Thibitisha PIN",
"enterCurrentPin": "Ingiza PIN yako ya sasa",
"incorrectPin": "PIN si sahihi",
"selectLanguage": "Chagua lugha",
"languageChanged": "Lugha imebadilishwa kuwa {language}",
"@languageChanged": {
"placeholders": {
"language": { "type": "String" }
}
},
"close": "Funga",
"aboutDescription": "Pochi ya Cashu yenye DNA ya Cuba kwa ulimwengu wote. Ndugu wa LaChispa.",
"couldNotOpenLink": "Haikuweza kufungua kiungo",
"deleteWalletQuestion": "Futa pochi?",
"actionIrreversible": "Kitendo hiki hakiwezi kutenduliwa",
"deleteWalletWarning": "Data zote zitafutwa ikiwa ni pamoja na maneno yako ya mbegu na tokeni. Hakikisha una nakala.",
"typeDeleteToConfirm": "Andika \"FUTA\" kuthibitisha:",
"deleteConfirmWord": "FUTA",
"deleteError": "Hitilafu ya kufuta: {error}",
"@deleteError": {
"placeholders": {
"error": { "type": "String" }
}
},
"recoverTokensTitle": "Rejesha tokeni",
"recoverTokensDescription": "Changanua mint kurejesha tokeni zinazohusiana na maneno yako ya mbegu (NUT-13)",
"useCurrentSeedPhrase": "Tumia maneno yangu ya mbegu ya sasa",
"scanWithSavedWords": "Changanua mint na maneno 12 yaliyohifadhiwa",
"useOtherSeedPhrase": "Tumia maneno mengine ya mbegu",
"recoverFromOtherWords": "Rejesha tokeni kutoka maneno mengine 12",
"mintsToScan": "Mint za kuchanganua:",
"allMints": "Mint zote ({count})",
"@allMints": {
"placeholders": {
"count": { "type": "int" }
}
},
"specificMint": "Mint maalum",
"enterMnemonicWords": "Ingiza maneno 12 yakitengwa na nafasi...",
"scanMints": "Changanua mint",
"selectMintToScan": "Chagua mint ya kuchanganua",
"mnemonicMustHaveWords": "Mnemonic lazima iwe na maneno 12 au 24",
"noConnectedMintsToScan": "Hakuna mint zilizounganishwa za kuchanganua",
"recoveredTokens": "Zimerejeshwa {tokens} kutoka mint {mints}!",
"@recoveredTokens": {
"placeholders": {
"tokens": { "type": "String" },
"mints": { "type": "int" }
}
},
"scanCompleteNoTokens": "Uchanganuzi umekamilika. Hakuna tokeni mpya zilizopatikana.",
"mintsWithError": "(mint {count} zenye hitilafu)",
"@mintsWithError": {
"placeholders": {
"count": { "type": "int" }
}
},
"recoveredFromMint": "Zimerejeshwa {tokens} kutoka {mint}!",
"@recoveredFromMint": {
"placeholders": {
"tokens": { "type": "String" },
"mint": { "type": "String" }
}
},
"noTokensFoundInMint": "Hakuna tokeni zilizopatikana katika {mint}.",
"@noTokensFoundInMint": {
"placeholders": {
"mint": { "type": "String" }
}
},
"recoveredAndTransferred": "Zimerejeshwa na kuhamishwa {amount} {unit} kwenye pochi yako!",
"@recoveredAndTransferred": {
"placeholders": {
"amount": { "type": "String" },
"unit": { "type": "String" }
}
},
"noTokensForMnemonic": "Hakuna tokeni zilizopatikana zinazohusiana na mnemonic hiyo.",
"noConnectedMints": "Hakuna mint zilizounganishwa",
"addMintToStart": "Ongeza mint kuanza",
"addMint": "Ongeza mint",
"mintDeleted": "Mint imefutwa",
"activeMintUpdated": "Mint inayofanya kazi imesasishwa",
"mintUrl": "URL ya mint:",
"mintUrlPlaceholder": "https://mint.example.com",
"urlMustStartWithHttps": "URL lazima ianze na https://",
"connectingToMint": "Kuunganisha na mint...",
"mintAddedSuccessfully": "Mint imeongezwa kwa mafanikio",
"couldNotConnectToMint": "Haikuweza kuunganisha na mint",
"add": "Ongeza",
"success": "Mafanikio",
"loading": "Inapakia...",
"retry": "Jaribu tena",
"activeMint": "Mint inayofanya kazi",
"mintMessage": "Ujumbe wa Mint",
"url": "URL",
"currency": "Sarafu",
"unknown": "Haijulikani",
"useThisMint": "Tumia mint hii",
"copyMintUrl": "Nakili URL ya mint",
"deleteMint": "Futa mint",
"copied": "{label} imenakiliwa",
"@copied": {
"placeholders": {
"label": { "type": "String" }
}
},
"deleteMintConfirmTitle": "Futa mint",
"deleteMintConfirmMessage": "Ukiwa na salio katika mint hii, itapotea. Una uhakika?",
"delete": "Futa",
"offlineSend": "Tuma Nje ya Mtandao",
"selectAll": "Zote",
"deselectAll": "Hakuna",
"selectNotesToSend": "Chagua noti unazotaka kutuma:",
"totalToSend": "Jumla ya kutuma",
"notesSelected": "noti {count} zimechaguliwa",
"@notesSelected": {
"placeholders": {
"count": { "type": "int" }
}
},
"loadingProofsError": "Hitilafu ya kupakia uthibitisho: {error}",
"@loadingProofsError": {
"placeholders": {
"error": { "type": "String" }
}
},
"creatingTokenError": "Hitilafu ya kuunda tokeni: {error}",
"@creatingTokenError": {
"placeholders": {
"error": { "type": "String" }
}
},
"unknownState": "Hali isiyojulikana",
"depositAmountTitle": "Weka {amount} {unit}",
"@depositAmountTitle": {
"placeholders": {
"amount": { "type": "String" },
"unit": { "type": "String" }
}
},
"receiveNow": "Pokea sasa",
"receiveLater": "Pokea baadaye",
"tokenSavedForLater": "Tokeni imehifadhiwa kudai baadaye",
"noConnectionTokenSaved": "Hakuna muunganisho. Tokeni imehifadhiwa kudai baadaye.",
"unknownMintOffline": "Tokeni hii ni kutoka mint isiyojulikana. Unganisha na mtandao kuongeza na kudai tokeni.",
"noConnectionTryLater": "Hakuna muunganisho na mint. Jaribu baadaye.",
"saveTokenError": "Hitilafu ya kuhifadhi tokeni. Jaribu tena.",
"pendingTokenLimitReached": "Kikomo cha tokeni zinazosubiri kimefikiwa (upeo 50)",
"filterToReceive": "Za kupokea",
"noPendingTokens": "Hakuna tokeni zinazosubiri",
"noPendingTokensHint": "Hifadhi tokeni kudai baadaye",
"pendingBadge": "INASUBIRI",
"expiresInDays": "{days, plural, =1{Inaisha siku 1} other{Inaisha siku {days}}}",
"@expiresInDays": {
"placeholders": {
"days": { "type": "int" }
}
},
"retryCount": "majaribio {count}",
"@retryCount": {
"placeholders": {
"count": { "type": "int" }
}
},
"claimNow": "Dai sasa",
"pendingTokenClaimedSuccess": "Zimedaiwa {amount} {unit}",
"@pendingTokenClaimedSuccess": {
"placeholders": {
"amount": { "type": "String" },
"unit": { "type": "String" }
}
},
"pendingTokensClaimed": "{count, plural, =1{Imedaiwa tokeni 1 ({amount} {unit})} other{Zimedaiwa tokeni {count} ({amount} {unit})}}",
"@pendingTokensClaimed": {
"placeholders": {
"count": { "type": "int" },
"amount": { "type": "String" },
"unit": { "type": "String" }
}
},
"scan": "Changanua",
"scanQrCode": "Changanua QR",
"scanCashuToken": "Changanua tokeni ya Cashu",
"scanLightningInvoice": "Changanua ankara",
"scanningAnimatedQr": "Kuchanganua QR inayosogea...",
"pointCameraAtQr": "Elekeza kamera kwenye msimbo wa QR",
"pointCameraAtCashuQr": "Elekeza kamera kwenye QR ya tokeni ya Cashu",
"pointCameraAtInvoiceQr": "Elekeza kamera kwenye QR ya ankara",
"unrecognizedQrCode": "Msimbo wa QR haujatambuliwa",
"scanCashuTokenHint": "Changanua tokeni ya Cashu (cashuA... au cashuB...)",
"scanLightningInvoiceHint": "Changanua ankara ya Lightning (lnbc...)",
"addMintQuestion": "Ongeza mint hii?",
"cameraPermissionDenied": "Ruhusa ya kamera imekataliwa",
"paymentRequestNotSupported": "Maombi ya malipo bado hayatumiki",
"p2pkTitle": "Funguo za P2PK",
"p2pkSettingsDescription": "Pokea ecash iliyofungwa",
"p2pkExperimental": "P2PK ni ya majaribio. Tumia kwa uangalifu.",
"p2pkPendingSendWarning": "Una usafirishaji wa P2PK unaosubiri. Nenda kwenye historia na usasishe baada ya mpokeaji kudai tokeni.",
"p2pkExperimentalShort": "Majaribio",
"p2pkPrimaryKey": "Ufunguo Mkuu",
"p2pkDerived": "Iliyotokana",
"p2pkImported": "Iliyoingizwa",
"p2pkImportedKeys": "Funguo Zilizoingizwa",
"p2pkNoImportedKeys": "Hakuna funguo zilizoingizwa",
"p2pkShowQR": "Onyesha QR",
"p2pkCopy": "Nakili",
"p2pkImportNsec": "Ingiza nsec",
"p2pkImport": "Ingiza",
"p2pkEnterLabel": "Jina la ufunguo huu",
"p2pkLockToKey": "Tuma kwa saini ya P2PK",
"p2pkLockDescription": "Mpokeaji pekee anaweza kudai",
"p2pkReceiverPubkey": "npub1... au hex (herufi 64/66)",
"p2pkInvalidPubkey": "Ufunguo wa umma batili",
"p2pkInvalidPrivateKey": "Ufunguo wa siri batili",
"p2pkLockedToYou": "Imefungwa kwako",
"p2pkLockedToOther": "Imefungwa kwa ufunguo mwingine",
"p2pkCannotUnlock": "Huna ufunguo wa kufungua tokeni hii",
"p2pkEnterPrivateKey": "Ingiza ufunguo wa siri (nsec)",
"p2pkDeleteTitle": "Futa ufunguo",
"p2pkDeleteConfirm": "Futa ufunguo huu? Hutaweza kupokea tokeni zilizofungwa kwake.",
"p2pkRequiresConnection": "P2PK inahitaji muunganisho kwa mint",
"p2pkErrorMaxKeysReached": "Idadi ya juu ya funguo zilizoingizwa imefikiwa (10)",
"p2pkErrorInvalidNsec": "nsec batili",
"p2pkErrorKeyAlreadyExists": "Ufunguo huu tayari upo",
"p2pkErrorKeyNotFound": "Ufunguo haujapatikana",
"p2pkErrorCannotDeletePrimary": "Haiwezekani kufuta ufunguo mkuu"
}
+538
View File
@@ -0,0 +1,538 @@
{
"@@locale": "zh",
"appName": "ElCaju",
"appTagline": "您的私密电子现金钱包",
"loadingMessage1": "正在加密您的代币...",
"loadingMessage2": "正在准备您的电子代币...",
"loadingMessage3": "正在连接铸造厂...",
"loadingMessage4": "默认隐私保护。",
"loadingMessage5": "正在盲签代币...",
"loadingMessage6": "全力以赴...",
"loadingMessage7": "Cashu + Bitchat = 隐私 + 自由",
"aboutTagline": "无国界隐私。",
"welcomeTitle": "欢迎使用 ElCaju",
"welcomeSubtitle": "面向世界的 Cashu。古巴制造。",
"createWallet": "创建新钱包",
"restoreWallet": "恢复钱包",
"createWalletTitle": "创建钱包",
"creatingWallet": "正在创建您的钱包...",
"generatingSeed": "正在安全生成您的助记词",
"createWalletDescription": "将生成一个12词助记词。\n请将其保存在安全的地方。",
"generateWallet": "生成钱包",
"walletCreated": "钱包已创建!",
"walletCreatedDescription": "您的钱包已准备就绪。我们建议您现在备份助记词。",
"backupWarning": "如果没有备份,一旦丢失设备,您将无法访问您的资金。",
"backupNow": "立即备份",
"backupLater": "稍后备份",
"backupTitle": "备份",
"seedPhraseTitle": "您的助记词",
"seedPhraseDescription": "按顺序保存这12个单词。这是恢复钱包的唯一方式。",
"revealSeedPhrase": "显示助记词",
"tapToReveal": "点击按钮显示\n您的助记词",
"copyToClipboard": "复制到剪贴板",
"seedCopied": "助记词已复制到剪贴板",
"neverShareSeed": "切勿与任何人分享您的助记词。",
"confirmBackup": "我已将助记词保存在安全的地方",
"continue_": "继续",
"restoreTitle": "恢复钱包",
"enterSeedPhrase": "输入您的助记词",
"enterSeedDescription": "输入12或24个单词,用空格分隔。",
"seedPlaceholder": "单词1 单词2 单词3 ...",
"wordCount": "{count} 个单词",
"@wordCount": {
"placeholders": {
"count": { "type": "int" }
}
},
"needWords": "(需要12或24个)",
"restoreScanningMint": "正在扫描铸造厂中的现有代币...",
"restoreError": "恢复错误:{error}",
"@restoreError": {
"placeholders": {
"error": { "type": "String" }
}
},
"homeTitle": "首页",
"receive": "收款",
"send": "付款",
"sendAction": "付款 ↗",
"receiveAction": "↘ 收款",
"deposit": "存入",
"withdraw": "提取",
"lightning": "闪电网络",
"cashu": "Cashu",
"ecash": "电子现金",
"history": "历史记录",
"noTransactions": "暂无交易",
"depositOrReceive": "存入或接收聪开始使用",
"noMint": "无铸造厂",
"balance": "余额",
"sats": "聪",
"pasteEcashToken": "粘贴电子现金代币",
"generateInvoiceToDeposit": "生成存款发票",
"createEcashToken": "创建电子现金代币",
"payLightningInvoice": "支付闪电发票",
"receiveCashu": "接收 Cashu",
"pasteTheCashuToken": "粘贴 Cashu 代币:",
"pasteFromClipboard": "从剪贴板粘贴",
"validToken": "有效代币",
"invalidToken": "无效或格式错误的代币",
"amount": "金额:",
"mint": "铸造厂:",
"claiming": "正在领取...",
"claimTokens": "领取代币",
"tokensReceived": "已收到代币",
"backToHome": "返回首页",
"tokenAlreadyClaimed": "此代币已被领取",
"unknownMint": "来自未知铸造厂的代币",
"claimError": "领取错误:{error}",
"@claimError": {
"placeholders": {
"error": { "type": "String" }
}
},
"sendCashu": "发送 Cashu",
"selectNotesManually": "手动选择票据",
"amountToSend": "发送金额:",
"available": "可用:",
"max": "(最大)",
"memoOptional": "备注(可选):",
"memoPlaceholder": "这笔付款是用于什么?",
"creatingToken": "正在创建代币...",
"createToken": "创建代币",
"noActiveMint": "没有活跃的铸造厂",
"offlineModeMessage": "无连接。使用离线模式...",
"confirmSend": "确认发送",
"confirm": "确认",
"cancel": "取消",
"insufficientBalance": "余额不足",
"tokenCreationError": "创建代币错误:{error}",
"@tokenCreationError": {
"placeholders": {
"error": { "type": "String" }
}
},
"tokenCreated": "代币已创建",
"copy": "复制",
"share": "分享",
"tokenCashu": "Cashu 代币",
"tokenCashuAnimatedQr": "Cashu 代币(动态二维码 - {fragments} 个 UR 片段)",
"@tokenCashuAnimatedQr": {
"placeholders": {
"fragments": { "type": "int" }
}
},
"keepTokenWarning": "请保留此代币直到收款方领取。如果丢失,资金将无法找回。",
"tokenCopiedToClipboard": "代币已复制到剪贴板",
"copyAsEmoji": "复制为表情符号",
"emojiCopiedToClipboard": "代币已复制为表情符号 🥜",
"peanutDecodeError": "无法解码表情符号代币。可能已损坏。",
"nfcWrite": "写入NFC标签",
"nfcRead": "读取NFC标签",
"nfcHoldNear": "将设备靠近NFC标签...",
"nfcWriteSuccess": "代币已写入NFC标签",
"nfcWriteError": "NFC写入错误:{error}",
"@nfcWriteError": { "placeholders": { "error": { "type": "String" } } },
"nfcReadError": "NFC读取错误:{error}",
"@nfcReadError": { "placeholders": { "error": { "type": "String" } } },
"nfcDisabled": "NFC已禁用,请在设置中启用。",
"nfcUnsupported": "此设备不支持NFC",
"amountToDeposit": "存入金额:",
"descriptionOptional": "描述(可选):",
"depositPlaceholder": "这笔存款是用于什么?",
"generating": "正在生成...",
"generateInvoice": "生成发票",
"depositLightning": "闪电存款",
"payInvoiceTitle": "支付发票",
"generatingInvoice": "正在生成发票...",
"waitingForPayment": "等待付款...",
"paymentReceived": "已收到付款",
"tokensIssued": "代币已发行!",
"error": "错误",
"unknownError": "未知错误",
"back": "返回",
"copyInvoice": "复制发票",
"description": "描述:",
"invoiceCopiedToClipboard": "发票已复制到剪贴板",
"deposited": "已存入 {amount} {unit}",
"@deposited": {
"placeholders": {
"amount": { "type": "String" },
"unit": { "type": "String" }
}
},
"pasteLightningInvoice": "粘贴闪电发票:",
"gettingQuote": "正在获取报价...",
"validInvoice": "有效发票",
"invalidInvoice": "无效发票",
"invalidInvoiceMalformed": "无效或格式错误的发票",
"feeReserved": "预留手续费:",
"total": "总计:",
"paying": "正在支付...",
"payInvoice": "支付发票",
"confirmPayment": "确认支付",
"pay": "支付",
"fee": "手续费",
"invoiceExpired": "发票已过期",
"amountOutOfRange": "金额超出允许范围",
"resolvingType": "正在解析 {type}...",
"@resolvingType": {
"placeholders": {
"type": { "type": "String" }
}
},
"invoiceAlreadyPaid": "发票已支付",
"paymentError": "支付错误:{error}",
"@paymentError": {
"placeholders": {
"error": { "type": "String" }
}
},
"sent": "已发送 {amount} {unit}",
"@sent": {
"placeholders": {
"amount": { "type": "String" },
"unit": { "type": "String" }
}
},
"filterAll": "全部",
"filterPending": "待处理",
"filterEcash": "电子现金",
"filterLightning": "闪电",
"receiveTokensToStart": "接收 Cashu 代币开始使用",
"noPendingTransactions": "没有待处理的交易",
"allTransactionsCompleted": "所有交易已完成",
"noEcashTransactions": "没有电子现金交易",
"sendOrReceiveTokens": "发送或接收 Cashu 代币",
"noLightningTransactions": "没有闪电交易",
"depositOrWithdrawLightning": "通过闪电网络存入或提取",
"pendingStatus": "待处理",
"receivedStatus": "已收到",
"sentStatus": "已发送",
"now": "刚刚",
"agoMinutes": "{minutes} 分钟前",
"@agoMinutes": {
"placeholders": {
"minutes": { "type": "int" }
}
},
"agoHours": "{hours} 小时前",
"@agoHours": {
"placeholders": {
"hours": { "type": "int" }
}
},
"agoDays": "{days} 天前",
"@agoDays": {
"placeholders": {
"days": { "type": "int" }
}
},
"lightningInvoice": "闪电发票",
"receivedEcash": "收到电子现金",
"sentEcash": "发送电子现金",
"outgoingLightningPayment": "闪电支出",
"invoiceNotAvailable": "发票不可用",
"tokenNotAvailable": "代币不可用",
"unit": "单位",
"status": "状态",
"pending": "待处理",
"memo": "备注",
"copyInvoiceButton": "复制发票",
"copyButton": "复制",
"invoiceCopied": "发票已复制",
"tokenCopied": "代币已复制",
"speed": "速度:",
"settings": "设置",
"walletSection": "钱包",
"backupSeedPhrase": "备份助记词",
"viewRecoveryWords": "查看恢复词",
"connectedMints": "已连接铸造厂",
"manageCashuMints": "管理您的 Cashu 铸造厂",
"pinAccess": "PIN 访问",
"pinEnabled": "已启用",
"protectWithPin": "用 PIN 保护应用",
"recoverTokens": "恢复代币",
"scanMintsWithSeed": "使用助记词扫描铸造厂",
"appearanceSection": "语言",
"language": "语言",
"informationSection": "信息",
"version": "版本",
"about": "关于",
"deleteWallet": "删除钱包",
"spanish": "Español",
"english": "English",
"portuguese": "Português",
"french": "Français",
"russian": "Русский",
"german": "Deutsch",
"italian": "Italiano",
"korean": "한국어",
"chinese": "中文",
"japanese": "日本語",
"swahili": "Kiswahili",
"mnemonicNotFound": "未找到助记词",
"createPin": "创建 PIN",
"enterPinDigits": "输入4位数 PIN",
"confirmPin": "确认 PIN",
"enterPinAgain": "再次输入 PIN",
"pinMismatch": "PIN 不匹配",
"pinActivated": "PIN 已激活",
"pinDeactivated": "PIN 已停用",
"verifyPin": "验证 PIN",
"enterCurrentPin": "输入当前 PIN",
"incorrectPin": "PIN 错误",
"selectLanguage": "选择语言",
"languageChanged": "语言已更改为 {language}",
"@languageChanged": {
"placeholders": {
"language": { "type": "String" }
}
},
"close": "关闭",
"aboutDescription": "具有古巴基因的 Cashu 钱包,面向全世界。LaChispa 的兄弟项目。",
"couldNotOpenLink": "无法打开链接",
"deleteWalletQuestion": "删除钱包?",
"actionIrreversible": "此操作不可撤销",
"deleteWalletWarning": "所有数据将被删除,包括您的助记词和代币。请确保您有备份。",
"typeDeleteToConfirm": "输入 \"DELETE\" 确认:",
"deleteConfirmWord": "DELETE",
"deleteError": "删除错误:{error}",
"@deleteError": {
"placeholders": {
"error": { "type": "String" }
}
},
"recoverTokensTitle": "恢复代币",
"recoverTokensDescription": "扫描铸造厂以恢复与您的助记词关联的代币(NUT-13)",
"useCurrentSeedPhrase": "使用当前助记词",
"scanWithSavedWords": "使用保存的12个单词扫描铸造厂",
"useOtherSeedPhrase": "使用其他助记词",
"recoverFromOtherWords": "从其他12个单词恢复代币",
"mintsToScan": "要扫描的铸造厂:",
"allMints": "所有铸造厂({count}个)",
"@allMints": {
"placeholders": {
"count": { "type": "int" }
}
},
"specificMint": "特定铸造厂",
"enterMnemonicWords": "输入12个单词,用空格分隔...",
"scanMints": "扫描铸造厂",
"selectMintToScan": "选择要扫描的铸造厂",
"mnemonicMustHaveWords": "助记词必须有12或24个单词",
"noConnectedMintsToScan": "没有已连接的铸造厂可扫描",
"recoveredTokens": "已从 {mints} 个铸造厂恢复 {tokens}",
"@recoveredTokens": {
"placeholders": {
"tokens": { "type": "String" },
"mints": { "type": "int" }
}
},
"scanCompleteNoTokens": "扫描完成。未发现新代币。",
"mintsWithError": "{count}个铸造厂出错)",
"@mintsWithError": {
"placeholders": {
"count": { "type": "int" }
}
},
"recoveredFromMint": "已从 {mint} 恢复 {tokens}",
"@recoveredFromMint": {
"placeholders": {
"tokens": { "type": "String" },
"mint": { "type": "String" }
}
},
"noTokensFoundInMint": "在 {mint} 中未发现代币。",
"@noTokensFoundInMint": {
"placeholders": {
"mint": { "type": "String" }
}
},
"recoveredAndTransferred": "已恢复并转入 {amount} {unit} 到您的钱包!",
"@recoveredAndTransferred": {
"placeholders": {
"amount": { "type": "String" },
"unit": { "type": "String" }
}
},
"noTokensForMnemonic": "未发现与该助记词关联的代币。",
"noConnectedMints": "没有已连接的铸造厂",
"addMintToStart": "添加铸造厂开始使用",
"addMint": "添加铸造厂",
"mintDeleted": "铸造厂已删除",
"activeMintUpdated": "活跃铸造厂已更新",
"mintUrl": "铸造厂 URL",
"mintUrlPlaceholder": "https://mint.example.com",
"urlMustStartWithHttps": "URL 必须以 https:// 开头",
"connectingToMint": "正在连接铸造厂...",
"mintAddedSuccessfully": "铸造厂添加成功",
"couldNotConnectToMint": "无法连接到铸造厂",
"add": "添加",
"success": "成功",
"loading": "加载中...",
"retry": "重试",
"activeMint": "活跃铸造厂",
"mintMessage": "铸造厂消息",
"url": "网址",
"currency": "货币",
"unknown": "未知",
"useThisMint": "使用此铸造厂",
"copyMintUrl": "复制铸造厂 URL",
"deleteMint": "删除铸造厂",
"copied": "{label} 已复制",
"@copied": {
"placeholders": {
"label": { "type": "String" }
}
},
"deleteMintConfirmTitle": "删除铸造厂",
"deleteMintConfirmMessage": "如果您在此铸造厂有余额,将会丢失。确定要删除吗?",
"delete": "删除",
"offlineSend": "离线发送",
"selectAll": "全部",
"deselectAll": "无",
"selectNotesToSend": "选择要发送的票据:",
"totalToSend": "发送总额",
"notesSelected": "已选择 {count} 张票据",
"@notesSelected": {
"placeholders": {
"count": { "type": "int" }
}
},
"loadingProofsError": "加载证明错误:{error}",
"@loadingProofsError": {
"placeholders": {
"error": { "type": "String" }
}
},
"creatingTokenError": "创建代币错误:{error}",
"@creatingTokenError": {
"placeholders": {
"error": { "type": "String" }
}
},
"unknownState": "未知状态",
"depositAmountTitle": "存入 {amount} {unit}",
"@depositAmountTitle": {
"placeholders": {
"amount": { "type": "String" },
"unit": { "type": "String" }
}
},
"receiveNow": "立即接收",
"receiveLater": "稍后接收",
"tokenSavedForLater": "代币已保存,稍后领取",
"noConnectionTokenSaved": "无连接。代币已保存,稍后领取。",
"unknownMintOffline": "此代币来自未知铸造厂。请连接互联网以添加并领取代币。",
"noConnectionTryLater": "无法连接铸造厂。请稍后重试。",
"saveTokenError": "保存代币错误。请重试。",
"pendingTokenLimitReached": "待处理代币达到上限(最多50个)",
"filterToReceive": "待接收",
"noPendingTokens": "没有待处理代币",
"noPendingTokensHint": "保存代币稍后领取",
"pendingBadge": "待处理",
"expiresInDays": "{days, plural, =1{1天后过期} other{{days}天后过期}}",
"@expiresInDays": {
"placeholders": {
"days": { "type": "int" }
}
},
"retryCount": "{count} 次重试",
"@retryCount": {
"placeholders": {
"count": { "type": "int" }
}
},
"claimNow": "立即领取",
"pendingTokenClaimedSuccess": "已领取 {amount} {unit}",
"@pendingTokenClaimedSuccess": {
"placeholders": {
"amount": { "type": "String" },
"unit": { "type": "String" }
}
},
"pendingTokensClaimed": "{count, plural, =1{已领取1个代币({amount} {unit}} other{已领取{count}个代币({amount} {unit}}}",
"@pendingTokensClaimed": {
"placeholders": {
"count": { "type": "int" },
"amount": { "type": "String" },
"unit": { "type": "String" }
}
},
"scan": "扫描",
"scanQrCode": "扫描二维码",
"scanCashuToken": "扫描 Cashu 代币",
"scanLightningInvoice": "扫描发票",
"scanningAnimatedQr": "正在扫描动态二维码...",
"pointCameraAtQr": "将相机对准二维码",
"pointCameraAtCashuQr": "将相机对准 Cashu 代币二维码",
"pointCameraAtInvoiceQr": "将相机对准发票二维码",
"unrecognizedQrCode": "无法识别的二维码",
"scanCashuTokenHint": "扫描 Cashu 代币(cashuA... 或 cashuB...",
"scanLightningInvoiceHint": "扫描闪电发票(lnbc...",
"addMintQuestion": "添加此铸造厂?",
"cameraPermissionDenied": "相机权限被拒绝",
"paymentRequestNotSupported": "付款请求尚不支持",
"p2pkTitle": "P2PK密钥",
"p2pkSettingsDescription": "接收锁定的ecash",
"p2pkExperimental": "P2PK是实验性功能。请谨慎使用。",
"p2pkPendingSendWarning": "您有一笔待处理的P2PK发送。在收款人领取代币后,请前往历史记录刷新。",
"p2pkExperimentalShort": "实验性",
"p2pkPrimaryKey": "主密钥",
"p2pkDerived": "派生的",
"p2pkImported": "已导入",
"p2pkImportedKeys": "已导入的密钥",
"p2pkNoImportedKeys": "没有已导入的密钥",
"p2pkShowQR": "显示二维码",
"p2pkCopy": "复制",
"p2pkImportNsec": "导入nsec",
"p2pkImport": "导入",
"p2pkEnterLabel": "此密钥的名称",
"p2pkLockToKey": "P2PK签名发送",
"p2pkLockDescription": "只有接收者可以领取",
"p2pkReceiverPubkey": "npub1... 或 hex64/66字符)",
"p2pkInvalidPubkey": "无效的公钥",
"p2pkInvalidPrivateKey": "无效的私钥",
"p2pkLockedToYou": "已锁定给您",
"p2pkLockedToOther": "已锁定给其他密钥",
"p2pkCannotUnlock": "您没有解锁此代币的密钥",
"p2pkEnterPrivateKey": "输入私钥(nsec",
"p2pkDeleteTitle": "删除密钥",
"p2pkDeleteConfirm": "删除此密钥?您将无法接收锁定给它的代币。",
"p2pkRequiresConnection": "P2PK需要连接到铸造厂",
"p2pkErrorMaxKeysReached": "已达到导入密钥的最大数量(10)",
"p2pkErrorInvalidNsec": "无效的nsec",
"p2pkErrorKeyAlreadyExists": "此密钥已存在",
"p2pkErrorKeyNotFound": "未找到密钥",
"p2pkErrorCannotDeletePrimary": "无法删除主密钥"
}
+16 -1
View File
@@ -3,11 +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 {
@@ -40,6 +42,7 @@ void main() async {
providers: [
ChangeNotifierProvider(create: (_) => SettingsProvider()),
ChangeNotifierProvider(create: (_) => WalletProvider()),
ChangeNotifierProvider(create: (_) => P2PKProvider()),
ChangeNotifierProvider.value(value: priceProvider),
],
child: const ElCajuApp(),
@@ -74,9 +77,21 @@ class ElCajuApp extends StatelessWidget {
Locale('ru'), // Русский
Locale('de'), // Deutsch
Locale('it'), // Italiano
Locale('ko'), // 한국어
Locale('zh'), // 中文
Locale('ja'), // 日本語
Locale('sw'), // Kiswahili
],
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(),
);
}
+87
View File
@@ -0,0 +1,87 @@
/// Modelo de clave P2PK
///
/// Representa una clave pública/privada para bloquear/desbloquear tokens Cashu
library;
import '../core/utils/nostr_utils.dart';
class P2PKKey {
/// ID único de la clave
final String id;
/// Clave pública en formato hex (64 caracteres)
final String publicKey;
/// Clave privada en formato hex (64 caracteres)
final String privateKey;
/// true si fue derivada del mnemonic (NIP-06)
final bool isDerived;
/// Etiqueta para identificar la clave (ej: "Principal", "Mi Nostr")
final String label;
/// Fecha de creación
final DateTime createdAt;
const P2PKKey({
required this.id,
required this.publicKey,
required this.privateKey,
required this.isDerived,
required this.label,
required this.createdAt,
});
// ============ GETTERS PARA FORMATOS NOSTR ============
/// Clave pública en formato npub (Nostr)
String get npub => NostrUtils.hexToNpub(publicKey);
/// Clave privada en formato nsec (Nostr)
String get nsec => NostrUtils.hexToNsec(privateKey);
// ============ SERIALIZACIÓN ============
Map<String, dynamic> toJson() => {
'id': id,
'publicKey': publicKey,
'privateKey': privateKey,
'isDerived': isDerived,
'label': label,
'createdAt': createdAt.toIso8601String(),
};
factory P2PKKey.fromJson(Map<String, dynamic> json) => P2PKKey(
id: json['id'] as String,
publicKey: json['publicKey'] as String,
privateKey: json['privateKey'] as String,
isDerived: json['isDerived'] as bool,
label: json['label'] as String,
createdAt: DateTime.parse(json['createdAt'] as String),
);
// ============ UTILIDADES ============
P2PKKey copyWith({String? label}) => P2PKKey(
id: id,
publicKey: publicKey,
privateKey: privateKey,
isDerived: isDerived,
label: label ?? this.label,
createdAt: createdAt,
);
@override
String toString() => 'P2PKKey(id: $id, label: $label, isDerived: $isDerived)';
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is P2PKKey &&
runtimeType == other.runtimeType &&
publicKey == other.publicKey;
@override
int get hashCode => publicKey.hashCode;
}
+306
View File
@@ -0,0 +1,306 @@
/// Provider para gestión de claves P2PK
///
/// Funcionalidades:
/// - Derivar clave principal desde mnemonic (NIP-06)
/// - Importar claves adicionales via nsec
/// - Detectar tokens P2PK bloqueados
/// - Verificar si podemos desbloquear un token
library;
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:bip32/bip32.dart' as bip32;
import 'package:cdk_flutter/cdk_flutter.dart' as cdk;
import 'package:uuid/uuid.dart';
import '../models/p2pk_key.dart';
import '../core/utils/nostr_utils.dart';
import '../core/utils/p2pk_utils.dart';
/// Códigos de error para operaciones P2PK
enum P2PKErrorCode {
maxKeysReached,
invalidNsec,
keyAlreadyExists,
keyNotFound,
cannotDeletePrimaryKey,
}
/// Excepción tipada para errores P2PK (traducir en UI con L10n)
class P2PKException implements Exception {
final P2PKErrorCode code;
const P2PKException(this.code);
@override
String toString() => 'P2PKException: $code';
}
class P2PKProvider extends ChangeNotifier {
static const _storageKey = 'p2pk_keys';
static const _maxImportedKeys = 10;
final FlutterSecureStorage _secureStorage;
List<P2PKKey> _keys = [];
P2PKKey? _primaryKey;
bool _isInitialized = false;
P2PKProvider({FlutterSecureStorage? secureStorage})
: _secureStorage = secureStorage ?? const FlutterSecureStorage();
// ============ GETTERS ============
/// Todas las claves (principal + importadas)
List<P2PKKey> get keys => List.unmodifiable(_keys);
/// Clave principal derivada del mnemonic
P2PKKey? get primaryKey => _primaryKey;
/// Solo claves importadas (no derivadas)
List<P2PKKey> get importedKeys =>
_keys.where((k) => !k.isDerived).toList();
/// Si el provider está inicializado
bool get isInitialized => _isInitialized;
/// Cantidad de claves importadas
int get importedCount => importedKeys.length;
/// Si se pueden importar más claves
bool get canImportMore => importedCount < _maxImportedKeys;
// ============ INICIALIZACIÓN ============
/// Inicializa el provider derivando la clave principal del mnemonic
Future<void> initialize(String mnemonic) async {
// Cargar claves guardadas
await _loadKeys();
// Siempre derivar para comparar con la almacenada
final derived = await _deriveFromMnemonic(mnemonic);
if (_primaryKey == null) {
// Primera vez: guardar clave derivada
_primaryKey = derived;
_keys.insert(0, _primaryKey!);
await _saveKeys();
} else if (_primaryKey!.publicKey != derived.publicKey) {
// Mnemonic cambió (ej: wallet restore) — reemplazar clave primaria
_keys.removeWhere((k) => k.isDerived);
_primaryKey = derived;
_keys.insert(0, _primaryKey!);
await _saveKeys();
}
_isInitialized = true;
notifyListeners();
debugPrint('[P2PKProvider] Inicializado con ${_keys.length} claves');
debugPrint('[P2PKProvider] Clave principal: ${_primaryKey?.npub.substring(0, 20)}...');
}
// ============ GESTIÓN DE CLAVES ============
/// Importa una clave desde nsec
Future<P2PKKey> importFromNsec(String nsec, String label) async {
if (!canImportMore) {
throw const P2PKException(P2PKErrorCode.maxKeysReached);
}
// Limpiar input
String cleanNsec = nsec.trim();
if (cleanNsec.startsWith('nostr:')) {
cleanNsec = cleanNsec.substring(6);
}
final privateKeyHex = NostrUtils.nsecToHex(cleanNsec);
if (privateKeyHex == null) {
throw const P2PKException(P2PKErrorCode.invalidNsec);
}
// Obtener pubkey usando cdk-flutter
final publicKeyHex = cdk.getPubKey(secret: privateKeyHex);
// Verificar que no exista ya
if (_keys.any((k) => k.publicKey == publicKeyHex)) {
throw const P2PKException(P2PKErrorCode.keyAlreadyExists);
}
final key = P2PKKey(
id: const Uuid().v4(),
publicKey: publicKeyHex,
privateKey: privateKeyHex,
isDerived: false,
label: label.isEmpty ? 'Importada ${importedCount + 1}' : label,
createdAt: DateTime.now(),
);
_keys.add(key);
await _saveKeys();
notifyListeners();
debugPrint('[P2PKProvider] Clave importada: ${key.npub.substring(0, 20)}...');
return key;
}
/// Elimina una clave importada (no la principal)
Future<void> removeKey(String keyId) async {
final key = _keys.cast<P2PKKey?>().firstWhere(
(k) => k?.id == keyId,
orElse: () => null,
);
if (key == null) {
throw const P2PKException(P2PKErrorCode.keyNotFound);
}
if (key.isDerived) {
throw const P2PKException(P2PKErrorCode.cannotDeletePrimaryKey);
}
_keys.removeWhere((k) => k.id == keyId);
await _saveKeys();
notifyListeners();
debugPrint('[P2PKProvider] Clave eliminada: $keyId');
}
/// Actualiza el label de una clave
Future<void> updateLabel(String keyId, String newLabel) async {
final index = _keys.indexWhere((k) => k.id == keyId);
if (index < 0) return;
_keys[index] = _keys[index].copyWith(label: newLabel);
await _saveKeys();
notifyListeners();
}
// ============ DETECCIÓN DE TOKENS P2PK ============
/// Verifica si un token está bloqueado con P2PK
bool isTokenLocked(String encodedToken) {
return P2PKUtils.isP2PKLocked(encodedToken);
}
/// Verifica si un token está bloqueado para alguna de nuestras claves
bool isTokenLockedToUs(String encodedToken) {
final lockedPubkey = P2PKUtils.extractLockedPubkey(encodedToken);
if (lockedPubkey == null) return false;
// Normalizar a x-only (64 chars) para comparación
final normalizedLocked = _normalizeToXOnly(lockedPubkey);
return _keys.any((k) => _normalizeToXOnly(k.publicKey) == normalizedLocked);
}
/// Obtiene la clave privada para desbloquear un token
/// Prueba todas las claves automáticamente
String? getPrivateKeyForToken(String encodedToken) {
final lockedPubkey = P2PKUtils.extractLockedPubkey(encodedToken);
if (lockedPubkey == null) return null;
// Normalizar a x-only (64 chars) para comparación
final normalizedLocked = _normalizeToXOnly(lockedPubkey);
final key = _keys.cast<P2PKKey?>().firstWhere(
(k) => k != null && _normalizeToXOnly(k.publicKey) == normalizedLocked,
orElse: () => null,
);
return key?.privateKey;
}
/// Normaliza pubkey a formato x-only (64 chars) para comparación
/// Si es SEC1 (66 chars con prefijo 02/03), quita el prefijo
String _normalizeToXOnly(String pubkey) {
final lower = pubkey.toLowerCase();
if (lower.length == 66 && (lower.startsWith('02') || lower.startsWith('03'))) {
return lower.substring(2);
}
return lower;
}
/// Extrae la pubkey bloqueada de un token
String? extractLockedPubkey(String encodedToken) {
return P2PKUtils.extractLockedPubkey(encodedToken);
}
// ============ VALIDACIONES ============
/// Valida si un input es una pubkey válida (npub o hex)
bool isValidPubkey(String input) {
return NostrUtils.isValidPubkey(input);
}
/// Normaliza input (npub/hex/nostr:npub) a hex
String? normalizeToHex(String input) {
return NostrUtils.normalizeToHex(input);
}
// ============ MÉTODOS PRIVADOS ============
/// Deriva la clave principal del mnemonic usando NIP-06
/// Path: m/44'/1237'/0'/0/0
Future<P2PKKey> _deriveFromMnemonic(String mnemonic) async {
// 1. Mnemonic -> Seed (64 bytes) usando cdk-flutter
final seed = cdk.mnemonicToSeed(mnemonic: mnemonic);
// 2. Seed -> BIP32 root
final root = bip32.BIP32.fromSeed(seed);
// 3. Derivar con path NIP-06: m/44'/1237'/0'/0/0
final child = root.derivePath("m/44'/1237'/0'/0/0");
// 4. Obtener private key hex
final privateKeyHex = _bytesToHex(child.privateKey!);
// 5. Obtener public key usando cdk-flutter
final publicKeyHex = cdk.getPubKey(secret: privateKeyHex);
return P2PKKey(
id: 'primary',
publicKey: publicKeyHex,
privateKey: privateKeyHex,
isDerived: true,
label: 'Principal',
createdAt: DateTime.now(),
);
}
Future<void> _loadKeys() async {
try {
final json = await _secureStorage.read(key: _storageKey);
if (json != null) {
final List<dynamic> list = jsonDecode(json);
_keys = list.map((e) => P2PKKey.fromJson(e as Map<String, dynamic>)).toList();
_primaryKey = _keys.cast<P2PKKey?>().firstWhere(
(k) => k?.isDerived == true,
orElse: () => null,
);
}
} catch (e) {
debugPrint('[P2PKProvider] Error cargando claves: $e');
_keys = [];
_primaryKey = null;
}
}
Future<void> _saveKeys() async {
final json = jsonEncode(_keys.map((k) => k.toJson()).toList());
await _secureStorage.write(key: _storageKey, value: json);
}
String _bytesToHex(List<int> bytes) {
return bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join();
}
/// Limpia todas las claves (usado al borrar wallet)
Future<void> clear() async {
_keys = [];
_primaryKey = null;
_isInitialized = false;
await _secureStorage.delete(key: _storageKey);
notifyListeners();
debugPrint('[P2PKProvider] Claves eliminadas');
}
}
+368 -44
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';
@@ -784,8 +791,9 @@ class WalletProvider extends ChangeNotifier {
/// Reclama un token Cashu detectando automáticamente su unidad.
/// Si el mint del token es diferente al activo, lo agrega.
/// NO cambia la unidad activa (comportamiento similar a cashu.me).
/// Si el token es P2PK, pasar la clave privada en p2pkPrivateKey.
/// Retorna monto recibido.
Future<BigInt> receiveToken(String encodedToken) async {
Future<BigInt> receiveToken(String encodedToken, {String? p2pkPrivateKey}) async {
// Parsear token (incluye detección de unidad)
final tokenInfo = parseToken(encodedToken);
if (tokenInfo == null) {
@@ -802,13 +810,13 @@ class WalletProvider extends ChangeNotifier {
// Intentar detectar unidad de nuevo ahora que tenemos keysets
final detectedUnit = detectTokenUnit(encodedToken);
if (detectedUnit != null) {
return await _receiveWithUnit(encodedToken, tokenMint, detectedUnit);
return await _receiveWithUnit(encodedToken, tokenMint, detectedUnit, p2pkPrivateKey);
}
}
// Si tenemos unidad detectada, usarla
if (tokenInfo.unit != null) {
return await _receiveWithUnit(encodedToken, tokenMint, tokenInfo.unit!);
return await _receiveWithUnit(encodedToken, tokenMint, tokenInfo.unit!, p2pkPrivateKey);
}
// Fallback: intentar con cada unidad del mint hasta que funcione
@@ -817,7 +825,7 @@ class WalletProvider extends ChangeNotifier {
for (final unit in units) {
try {
return await _receiveWithUnit(encodedToken, tokenMint, unit);
return await _receiveWithUnit(encodedToken, tokenMint, unit, p2pkPrivateKey);
} catch (e) {
lastError = e as Exception;
debugPrint('Receive falló con unidad $unit: $e');
@@ -835,19 +843,65 @@ class WalletProvider extends ChangeNotifier {
String encodedToken,
String mintUrl,
String unit,
String? p2pkPrivateKey,
) async {
final wallet = await getWallet(mintUrl, unit);
final token = Token.parse(encoded: encodedToken);
final amount = await wallet.receive(token: token);
// Si hay clave P2PK, usarla para desbloquear el token
final opts = p2pkPrivateKey != null
? 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 {
@@ -873,6 +927,7 @@ class WalletProvider extends ChangeNotifier {
}
/// Reclama un token P2PK (bloqueado a una clave pública).
/// Usa la unidad del token automáticamente (no requiere cambiar unidad activa).
Future<BigInt> receiveP2pkToken(
String encodedToken,
List<String> signingKeys,
@@ -882,20 +937,14 @@ class WalletProvider extends ChangeNotifier {
throw Exception('Token inválido');
}
// Cambiar al mint del token
// Agregar mint si no existe
if (!_mintUnits.containsKey(tokenInfo.mintUrl)) {
await addMint(tokenInfo.mintUrl);
}
_activeMintUrl = tokenInfo.mintUrl;
// Verificar si la unidad activa es soportada
final units = _mintUnits[tokenInfo.mintUrl]!;
if (!units.contains(_activeUnit)) {
_activeUnit = units.first;
}
final wallet = await getWallet(tokenInfo.mintUrl, _activeUnit);
// Usar la unidad del token (fallback a 'sat' si no especificada)
final tokenUnit = tokenInfo.unit ?? 'sat';
final wallet = await getWallet(tokenInfo.mintUrl, tokenUnit);
final token = Token.parse(encoded: encodedToken);
final amount = await wallet.receive(
@@ -918,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),
);
}
@@ -979,53 +1029,217 @@ 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);
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;
}
}
/// Envía tokens P2PK.
Future<String> sendTokensP2pk(
BigInt amount,
String pubkey,
String? memo,
) 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);
return await confirmSend(prepared, memo);
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;
}
}
/// Verifica si hay transacciones salientes pendientes en el wallet activo.
Future<bool> hasPendingOutgoingTransactions() async {
try {
final wallet = await getActiveWallet();
final txs = await wallet.listTransactions();
return txs.any((tx) =>
tx.direction == TransactionDirection.outgoing &&
tx.status == TransactionStatus.pending);
} catch (_) {
return false;
}
}
// ============================================================
// 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).
@@ -1046,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');
@@ -1143,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.
@@ -1163,15 +1391,101 @@ 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 {
await wallet.checkPendingTransactions();
} catch (e) {
// Silencioso - puede fallar offline
debugPrint('Check pending failed: $e');
// Iterar todos los mints y todas sus unidades, no solo los wallets
// ya instanciados. initialize() solo precarga units.first por mint,
// así que quotes/sagas/melts en otras unidades se perderían.
for (final entry in _mintUnits.entries) {
for (final unit in entry.value) {
try {
final wallet = await getWallet(entry.key, unit);
try {
// Reclamar quotes pagados pero no emitidos (Lightning → proofs)
await wallet.checkAllMintQuotes();
} catch (e) {
debugPrint('Check mint quotes failed: $e');
}
try {
// Recuperar sagas incompletas (swaps/mints/melts interrumpidos)
await wallet.recoverIncompleteSagas();
} catch (e) {
debugPrint('Recover incomplete sagas failed: $e');
}
try {
// Finalizar retiros Lightning que quedaron a medias
await wallet.finalizePendingMelts();
} catch (e) {
debugPrint('Finalize pending melts failed: $e');
}
try {
await wallet.checkPendingTransactions();
} catch (e) {
// Silencioso - puede fallar offline
debugPrint('Check pending failed: $e');
}
} catch (e) {
debugPrint('Error getting wallet ${entry.key}:$unit: $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.
@@ -1507,4 +1821,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';
+11 -1
View File
@@ -2,12 +2,13 @@ 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';
import '../../providers/settings_provider.dart';
import '../../providers/wallet_provider.dart';
import '../../providers/p2pk_provider.dart';
import '../2_onboarding/welcome_screen.dart';
import '../3_home/home_screen.dart';
@@ -78,6 +79,7 @@ class _SplashScreenState extends State<SplashScreen>
try {
final settingsProvider = context.read<SettingsProvider>();
final walletProvider = context.read<WalletProvider>();
final p2pkProvider = context.read<P2PKProvider>();
// Inicializar settings (cargar preferencias)
await settingsProvider.initialize();
@@ -95,6 +97,14 @@ class _SplashScreenState extends State<SplashScreen>
if (mnemonic != null && mnemonic.isNotEmpty) {
await walletProvider.initialize(mnemonic);
// Inicializar P2PK (derivar clave principal del mnemonic)
// Aislado: P2PK es secundario, su fallo no debe bloquear la wallet
try {
await p2pkProvider.initialize(mnemonic);
} catch (e) {
debugPrint('[SplashScreen] Error initializing P2PK (non-fatal): $e');
}
if (!mounted) return;
// Restaurar mint y unidad activa desde settings
@@ -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';
@@ -10,6 +10,7 @@ import '../../widgets/common/secondary_button.dart';
import '../../widgets/common/glass_card.dart';
import '../../providers/wallet_provider.dart';
import '../../providers/settings_provider.dart';
import '../../providers/p2pk_provider.dart';
import 'backup_seed_screen.dart';
import '../3_home/home_screen.dart';
@@ -36,6 +37,7 @@ class _CreateWalletScreenState extends State<CreateWalletScreen> {
try {
final walletProvider = context.read<WalletProvider>();
final settingsProvider = context.read<SettingsProvider>();
final p2pkProvider = context.read<P2PKProvider>();
// Generar mnemonic real (12 palabras BIP39)
_mnemonic = walletProvider.generateNewMnemonic();
@@ -46,6 +48,13 @@ class _CreateWalletScreenState extends State<CreateWalletScreen> {
// Inicializar wallet con el mnemonic
await walletProvider.initialize(_mnemonic!);
// Inicializar P2PK (derivar clave principal del mnemonic)
try {
await p2pkProvider.initialize(_mnemonic!);
} catch (e) {
debugPrint('[CreateWalletScreen] Error initializing P2PK (non-fatal): $e');
}
setState(() {
_isCreating = false;
_walletCreated = true;
@@ -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';
@@ -9,6 +9,7 @@ import '../../widgets/common/primary_button.dart';
import '../../widgets/common/glass_card.dart';
import '../../providers/wallet_provider.dart';
import '../../providers/settings_provider.dart';
import '../../providers/p2pk_provider.dart';
import '../3_home/home_screen.dart';
/// Pantalla de restauración de wallet
@@ -24,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();
@@ -55,16 +57,40 @@ 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
// 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 wallet (esto valida el mnemonic internamente)
// Si el mnemonic es inválido, cdk_flutter lanzará una excepción
await walletProvider.initialize(mnemonic);
// Inicializar P2PK (derivar clave principal del mnemonic)
try {
await p2pkProvider.initialize(mnemonic);
} catch (e) {
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(
@@ -74,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(() {
@@ -251,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),
),
),
),
],
],
),
),
+30 -94
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';
@@ -10,100 +10,29 @@ import '../../widgets/common/primary_button.dart';
import '../../widgets/common/secondary_button.dart';
import 'create_wallet_screen.dart';
import 'restore_wallet_screen.dart';
import '../8_settings/language_screen.dart';
/// Pantalla de bienvenida - Onboarding
class WelcomeScreen extends StatelessWidget {
const WelcomeScreen({super.key});
static const _languages = [
{'code': 'es', 'flag': '🇪🇸', 'name': 'Español'},
{'code': 'en', 'flag': '🇬🇧', 'name': 'English'},
];
// Mapa de códigos de idioma a banderas
static const Map<String, String> _languageFlags = {
'es': '🇪🇸',
'en': '🇬🇧',
'pt': '🇵🇹',
'fr': '🇫🇷',
'it': '🇮🇹',
'de': '🇩🇪',
'ru': '🇷🇺',
'zh': '🇨🇳',
'ja': '🇯🇵',
'ko': '🇰🇷',
'sw': '🇰🇪',
};
void _showLanguageSelector(BuildContext context) {
final settingsProvider = context.read<SettingsProvider>();
showModalBottomSheet(
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),
),
),
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),
),
),
// Opciones de idioma
..._languages.map((lang) {
final isSelected = settingsProvider.locale == lang['code'];
return GestureDetector(
onTap: () {
settingsProvider.setLocale(lang['code']!);
Navigator.pop(context);
},
child: Container(
width: double.infinity,
margin: const EdgeInsets.only(bottom: 8),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: isSelected
? AppColors.primaryAction.withValues(alpha: 0.2)
: Colors.white.withValues(alpha: 0.05),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: isSelected
? AppColors.primaryAction.withValues(alpha: 0.5)
: Colors.white.withValues(alpha: 0.1),
),
),
child: Row(
children: [
Text(
lang['flag']!,
style: const TextStyle(fontSize: 24),
),
const SizedBox(width: 12),
Text(
lang['name']!,
style: TextStyle(
fontFamily: 'Inter',
fontSize: 16,
fontWeight: isSelected ? FontWeight.w600 : FontWeight.w500,
color: isSelected ? AppColors.primaryAction : Colors.white,
),
),
const Spacer(),
if (isSelected)
const Icon(
LucideIcons.checkCircle,
color: AppColors.primaryAction,
size: 22,
),
],
),
),
);
}),
const SizedBox(height: 8),
],
),
),
);
String _getFlag(String locale) {
return _languageFlags[locale] ?? '🇪🇸';
}
@override
@@ -111,7 +40,7 @@ class WelcomeScreen extends StatelessWidget {
final l10n = L10n.of(context)!;
final settingsProvider = context.watch<SettingsProvider>();
final isSpanish = settingsProvider.locale == 'es';
final currentLocale = settingsProvider.locale;
return GradientBackground(
child: Scaffold(
@@ -125,7 +54,14 @@ class WelcomeScreen extends StatelessWidget {
Align(
alignment: Alignment.topRight,
child: GestureDetector(
onTap: () => _showLanguageSelector(context),
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => const LanguageScreen(),
),
);
},
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
@@ -142,12 +78,12 @@ class WelcomeScreen extends StatelessWidget {
mainAxisSize: MainAxisSize.min,
children: [
Text(
isSpanish ? '🇪🇸' : '🇬🇧',
_getFlag(currentLocale),
style: const TextStyle(fontSize: 18),
),
const SizedBox(width: 6),
Text(
isSpanish ? 'ES' : 'EN',
currentLocale.toUpperCase(),
style: const TextStyle(
fontFamily: 'Inter',
fontSize: 14,
@@ -157,7 +93,7 @@ class WelcomeScreen extends StatelessWidget {
),
const SizedBox(width: 4),
Icon(
LucideIcons.chevronDown,
LucideIcons.chevronRight,
color: Colors.white.withValues(alpha: 0.7),
size: 18,
),
+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,
+607 -22
View File
@@ -2,16 +2,19 @@ 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';
import '../../core/utils/incoming_data_parser.dart' hide TokenInfo;
import '../../core/utils/nostr_utils.dart';
import '../../core/utils/peanut_codec.dart';
import '../../core/services/nfc_service.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';
/// Pantalla para recibir tokens Cashu
@@ -27,8 +30,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;
@@ -38,9 +39,23 @@ class _ReceiveScreenState extends State<ReceiveScreen> {
TokenInfo? _tokenInfo;
String? _errorMessage;
// Estado P2PK
bool _isP2PKLocked = false;
bool _isLockedToUs = false;
String? _lockedToPubkeyHex;
String? _matchingKeyLabel; // Label de nuestra clave si coincide
final TextEditingController _manualKeyController = TextEditingController();
bool _showManualKey = false; // Toggle para mostrar/ocultar nsec
String? _manualKeyError;
// NFC
NfcState _nfcState = NfcState.unsupported;
bool _nfcReading = false;
@override
void initState() {
super.initState();
_checkNfc();
// Pre-cargar token inicial si existe
if (widget.initialToken != null && widget.initialToken!.isNotEmpty) {
WidgetsBinding.instance.addPostFrameCallback((_) {
@@ -52,16 +67,22 @@ class _ReceiveScreenState extends State<ReceiveScreen> {
@override
void dispose() {
if (_nfcReading) NfcService.stopRead();
_tokenController.dispose();
_confettiController.dispose();
_manualKeyController.dispose();
super.dispose();
}
Future<void> _checkNfc() async {
final state = await NfcService.checkState();
if (mounted) {
setState(() => _nfcState = state);
}
}
@override
Widget build(BuildContext context) {
return CashuConfetti(
controller: _confettiController,
child: GradientBackground(
return GradientBackground(
child: Scaffold(
backgroundColor: Colors.transparent,
appBar: AppBar(
@@ -84,7 +105,6 @@ class _ReceiveScreenState extends State<ReceiveScreen> {
child: _showSuccess ? _buildSuccessView() : _buildReceiveForm(),
),
),
),
);
}
@@ -124,6 +144,12 @@ class _ReceiveScreenState extends State<ReceiveScreen> {
// Preview del token (si es válido)
if (_isValidToken && _tokenInfo != null) _buildTokenPreview(),
// Indicador P2PK (si el token está bloqueado)
if (_isValidToken && _isP2PKLocked) ...[
const SizedBox(height: AppDimensions.paddingMedium),
_buildP2PKIndicator(),
],
// Mensaje de error (si hay)
if (_errorMessage != null) _buildErrorMessage(),
],
@@ -234,6 +260,9 @@ class _ReceiveScreenState extends State<ReceiveScreen> {
// Botón pegar (expandido)
Expanded(child: _buildPasteButton()),
const SizedBox(width: 12),
// Botón NFC (siempre visible)
_buildNfcButton(),
const SizedBox(width: 12),
// Botón escanear QR
_buildScanButton(),
],
@@ -280,6 +309,134 @@ class _ReceiveScreenState extends State<ReceiveScreen> {
);
}
Widget _buildNfcButton() {
final isNfcEnabled = _nfcState == NfcState.enabled;
final isNfcUnsupported = _nfcState == NfcState.unsupported;
return Tooltip(
message: isNfcUnsupported
? L10n.of(context)!.nfcUnsupported
: isNfcEnabled
? L10n.of(context)!.nfcRead
: L10n.of(context)!.nfcDisabled,
child: GestureDetector(
onTap: _toggleNfcRead,
child: Opacity(
opacity: isNfcUnsupported ? 0.3 : 1.0,
child: Container(
padding: const EdgeInsets.all(AppDimensions.paddingSmall + 4),
decoration: BoxDecoration(
color: _nfcReading
? AppColors.primaryAction.withValues(alpha: 0.3)
: Colors.white.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: _nfcReading
? AppColors.primaryAction
: Colors.white.withValues(alpha: 0.2),
width: 1,
),
),
child: Icon(
LucideIcons.nfc,
color: _nfcReading
? AppColors.primaryAction
: isNfcEnabled
? AppColors.textSecondary
: AppColors.textSecondary.withValues(alpha: 0.5),
size: 24,
),
),
),
),
);
}
void _toggleNfcRead() async {
// Cancel active session first (before state checks, in case NFC was
// disabled while a session was running)
if (_nfcReading) {
NfcService.stopRead();
setState(() => _nfcReading = false);
return;
}
// Re-check state in case user toggled NFC in settings
final currentState = await NfcService.checkState();
if (mounted) setState(() => _nfcState = currentState);
if (currentState == NfcState.unsupported) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(L10n.of(context)!.nfcUnsupported),
backgroundColor: AppColors.textSecondary,
behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
),
);
return;
}
if (currentState == NfcState.disabled) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(L10n.of(context)!.nfcDisabled),
backgroundColor: AppColors.warning,
behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
),
);
return;
}
setState(() => _nfcReading = true);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(L10n.of(context)!.nfcHoldNear),
backgroundColor: AppColors.primaryAction,
behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
),
);
try {
await NfcService.startRead(
onTokenRead: (token) {
if (!mounted) return;
setState(() => _nfcReading = false);
_tokenController.text = token;
_onTokenChanged(token);
},
onError: (error) {
if (!mounted) return;
setState(() => _nfcReading = false);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(L10n.of(context)!.nfcReadError(error)),
backgroundColor: AppColors.error,
behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8)),
),
);
},
);
} catch (e) {
if (!mounted) return;
setState(() => _nfcReading = false);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(L10n.of(context)!.nfcReadError(e.toString())),
backgroundColor: AppColors.error,
behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8)),
),
);
}
}
Widget _buildScanButton() {
return GestureDetector(
onTap: _openScanner,
@@ -457,41 +614,403 @@ class _ReceiveScreenState extends State<ReceiveScreen> {
);
}
/// Widget que muestra el estado P2PK del token
Widget _buildP2PKIndicator() {
final l10n = L10n.of(context)!;
// Convertir pubkey hex a npub para mostrar
String? npubDisplay;
if (_lockedToPubkeyHex != null) {
try {
npubDisplay = NostrUtils.hexToNpub(_lockedToPubkeyHex!);
} catch (_) {
npubDisplay = _lockedToPubkeyHex; // Fallback a hex si falla conversión
}
}
// Truncar npub para display: npub1abc...xyz
String truncatedNpub = '';
if (npubDisplay != null && npubDisplay.length > 20) {
truncatedNpub = '${npubDisplay.substring(0, 12)}...${npubDisplay.substring(npubDisplay.length - 6)}';
} else {
truncatedNpub = npubDisplay ?? '';
}
return GlassCard(
padding: const EdgeInsets.all(AppDimensions.paddingMedium),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Encabezado con icono y estado
Row(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: _isLockedToUs
? AppColors.success.withValues(alpha: 0.2)
: AppColors.warning.withValues(alpha: 0.2),
shape: BoxShape.circle,
),
child: Icon(
_isLockedToUs ? LucideIcons.unlock : LucideIcons.lock,
color: _isLockedToUs ? AppColors.success : AppColors.warning,
size: 20,
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
_isLockedToUs ? l10n.p2pkLockedToYou : l10n.p2pkLockedToOther,
style: TextStyle(
fontFamily: 'Inter',
fontSize: 14,
fontWeight: FontWeight.w600,
color: _isLockedToUs ? AppColors.success : AppColors.warning,
),
),
if (_isLockedToUs && _matchingKeyLabel != null)
Text(
_matchingKeyLabel!,
style: TextStyle(
fontFamily: 'Inter',
fontSize: 12,
color: AppColors.textSecondary,
),
),
],
),
),
// Badge experimental
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: AppColors.warning.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(8),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
LucideIcons.flaskConical,
size: 12,
color: AppColors.warning,
),
const SizedBox(width: 4),
Text(
l10n.p2pkExperimentalShort,
style: TextStyle(
fontFamily: 'Inter',
fontSize: 10,
fontWeight: FontWeight.w500,
color: AppColors.warning,
),
),
],
),
),
],
),
const SizedBox(height: 12),
// Pubkey (npub truncado) - tap para copiar
GestureDetector(
onTap: () {
if (npubDisplay != null) {
Clipboard.setData(ClipboardData(text: npubDisplay));
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(l10n.copied('npub')),
duration: const Duration(seconds: 2),
backgroundColor: AppColors.success,
),
);
}
},
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.05),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: Colors.white.withValues(alpha: 0.1),
),
),
child: Row(
children: [
Expanded(
child: Text(
truncatedNpub,
style: TextStyle(
fontFamily: 'monospace',
fontSize: 12,
color: AppColors.textSecondary,
),
),
),
Icon(
LucideIcons.copy,
size: 14,
color: AppColors.textSecondary.withValues(alpha: 0.7),
),
],
),
),
),
// Campo nsec manual (solo si NO es nuestra clave)
if (!_isLockedToUs) ...[
const SizedBox(height: 16),
// Mensaje de error si no puede desbloquear
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: AppColors.error.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: AppColors.error.withValues(alpha: 0.2),
),
),
child: Row(
children: [
Icon(
LucideIcons.alertTriangle,
size: 16,
color: AppColors.error,
),
const SizedBox(width: 8),
Expanded(
child: Text(
l10n.p2pkCannotUnlock,
style: TextStyle(
fontFamily: 'Inter',
fontSize: 12,
color: AppColors.error,
),
),
),
],
),
),
const SizedBox(height: 12),
// Campo para ingresar nsec manualmente
Text(
l10n.p2pkEnterPrivateKey,
style: TextStyle(
fontFamily: 'Inter',
fontSize: 14,
fontWeight: FontWeight.w500,
color: AppColors.textSecondary,
),
),
const SizedBox(height: 8),
Container(
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.05),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: _manualKeyError != null
? AppColors.error.withValues(alpha: 0.5)
: Colors.white.withValues(alpha: 0.1),
),
),
child: Row(
children: [
Expanded(
child: TextField(
controller: _manualKeyController,
obscureText: !_showManualKey,
style: const TextStyle(
fontFamily: 'monospace',
fontSize: 14,
color: Colors.white,
),
decoration: InputDecoration(
hintText: 'nsec1... o hex',
hintStyle: TextStyle(
fontFamily: 'monospace',
fontSize: 14,
color: Colors.white.withValues(alpha: 0.3),
),
border: InputBorder.none,
contentPadding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 14,
),
),
onChanged: (_) {
setState(() {
_manualKeyError = null;
});
},
),
),
// Botón mostrar/ocultar
IconButton(
icon: Icon(
_showManualKey ? LucideIcons.eyeOff : LucideIcons.eye,
color: AppColors.textSecondary,
size: 20,
),
onPressed: () {
setState(() {
_showManualKey = !_showManualKey;
});
},
),
// Botón pegar
IconButton(
icon: Icon(
LucideIcons.clipboard,
color: AppColors.textSecondary,
size: 20,
),
onPressed: () async {
final clipboardData = await Clipboard.getData(Clipboard.kTextPlain);
if (clipboardData?.text != null) {
_manualKeyController.text = clipboardData!.text!.trim();
setState(() {
_manualKeyError = null;
});
}
},
),
],
),
),
// Error de clave manual
if (_manualKeyError != null) ...[
const SizedBox(height: 8),
Text(
_manualKeyError!,
style: TextStyle(
fontFamily: 'Inter',
fontSize: 12,
color: AppColors.error,
),
),
],
],
],
),
);
}
Widget _buildReceiveButton() {
final l10n = L10n.of(context)!;
// Botón único "Recibir" - auto-detecta conectividad
// Determinar si el botón debe estar habilitado
bool canReceive = _isValidToken && !_isProcessing;
// Si es P2PK y NO es nuestra clave, necesita nsec manual válido
if (_isP2PKLocked && !_isLockedToUs) {
final manualKey = _manualKeyController.text.trim();
canReceive = canReceive && manualKey.isNotEmpty;
}
return PrimaryButton(
text: _isProcessing ? l10n.claiming : l10n.receive,
onPressed: _isValidToken && !_isProcessing ? _receiveToken : null,
onPressed: canReceive ? _receiveToken : null,
);
}
Future<void> _pasteFromClipboard() async {
final clipboardData = await Clipboard.getData(Clipboard.kTextPlain);
if (!mounted) return;
if (clipboardData?.text != null) {
_tokenController.text = clipboardData!.text!.trim();
_onTokenChanged(clipboardData.text!.trim());
var text = clipboardData!.text!.trim();
// Auto-decode peanut emoji format
if (PeanutCodec.isPeanut(text)) {
final decoded = PeanutCodec.decode(text);
if (decoded != null) {
text = decoded;
} else {
// Malformed peanut: clear field and show specific error
_tokenController.clear();
setState(() {
_isValidToken = false;
_tokenInfo = null;
_errorMessage = L10n.of(context)!.peanutDecodeError;
});
return;
}
}
_tokenController.text = text;
_onTokenChanged(text);
}
}
void _onTokenChanged(String value) {
final walletProvider = context.read<WalletProvider>();
final p2pkProvider = context.read<P2PKProvider>();
// Auto-decode peanut emoji format
var tokenValue = value.trim();
if (PeanutCodec.isPeanut(tokenValue)) {
final decoded = PeanutCodec.decode(tokenValue);
if (decoded != null) {
tokenValue = decoded;
// Update the text field with the decoded token
_tokenController.text = tokenValue;
_tokenController.selection = TextSelection.collapsed(
offset: tokenValue.length,
);
} else {
// Malformed peanut: show specific error
setState(() {
_isValidToken = false;
_tokenInfo = null;
_errorMessage = L10n.of(context)!.peanutDecodeError;
});
return;
}
}
setState(() {
_errorMessage = null;
_manualKeyError = null;
if (value.isEmpty) {
// Reset P2PK state
_isP2PKLocked = false;
_isLockedToUs = false;
_lockedToPubkeyHex = null;
_matchingKeyLabel = null;
if (tokenValue.isEmpty) {
_isValidToken = false;
_tokenInfo = null;
return;
}
// Parsear token real con cdk-flutter
final tokenInfo = walletProvider.parseToken(value.trim());
final tokenInfo = walletProvider.parseToken(tokenValue);
if (tokenInfo != null) {
_isValidToken = true;
_tokenInfo = tokenInfo;
// Detectar P2PK
_isP2PKLocked = p2pkProvider.isTokenLocked(tokenValue);
if (_isP2PKLocked) {
_lockedToPubkeyHex = p2pkProvider.extractLockedPubkey(tokenValue);
_isLockedToUs = p2pkProvider.isTokenLockedToUs(tokenValue);
// Si es nuestra, buscar el label de la clave
if (_isLockedToUs && _lockedToPubkeyHex != null) {
_matchingKeyLabel = _findMatchingKeyLabel(p2pkProvider);
}
}
} else {
_isValidToken = false;
_tokenInfo = null;
@@ -500,6 +1019,31 @@ class _ReceiveScreenState extends State<ReceiveScreen> {
});
}
/// Busca el label de la clave que coincide con el token P2PK
String? _findMatchingKeyLabel(P2PKProvider p2pkProvider) {
if (_lockedToPubkeyHex == null) return null;
// Normalizar a x-only para comparar
final lockedNormalized = _normalizeToXOnly(_lockedToPubkeyHex!);
for (final key in p2pkProvider.keys) {
final keyNormalized = _normalizeToXOnly(key.publicKey);
if (keyNormalized == lockedNormalized) {
return key.label;
}
}
return null;
}
/// Normaliza pubkey a x-only (64 chars) quitando prefijo SEC1 si existe
String _normalizeToXOnly(String pubkey) {
final lower = pubkey.toLowerCase();
if (lower.length == 66 && (lower.startsWith('02') || lower.startsWith('03'))) {
return lower.substring(2);
}
return lower;
}
/// Método principal: recibe token con detección automática de conectividad.
/// 1. Si el mint es desconocido y no hay conexión → rechazar
/// 2. Ping al mint (3s) → si OK → claim → si falla → guardar pendiente
@@ -605,15 +1149,56 @@ class _ReceiveScreenState extends State<ReceiveScreen> {
/// Reclama el token directamente (cuando hay conexión).
Future<void> _claimToken() async {
final walletProvider = context.read<WalletProvider>();
final p2pkProvider = context.read<P2PKProvider>();
final l10n = L10n.of(context)!;
// Guardar unidad detectada del token ANTES de reclamar
final detectedUnit = _tokenInfo?.unit ?? walletProvider.activeUnit;
final token = _tokenController.text.trim();
try {
// Reclamar token (usa unidad detectada internamente)
final amountReceived = await walletProvider.receiveToken(
_tokenController.text.trim(),
);
BigInt amountReceived;
if (_isP2PKLocked) {
// Token P2PK - necesita clave privada para desbloquear
String? privateKeyHex;
if (_isLockedToUs) {
// Es nuestra clave - obtener automáticamente
privateKeyHex = p2pkProvider.getPrivateKeyForToken(token);
if (privateKeyHex == null) {
throw Exception(l10n.p2pkCannotUnlock);
}
} else {
// No es nuestra - usar clave manual
final manualInput = _manualKeyController.text.trim();
// Intentar convertir nsec a hex
if (manualInput.startsWith('nsec1')) {
privateKeyHex = NostrUtils.nsecToHex(manualInput);
} else if (RegExp(r'^[0-9a-fA-F]{64}$').hasMatch(manualInput)) {
// Ya es hex de 64 caracteres
privateKeyHex = manualInput.toLowerCase();
}
if (privateKeyHex == null) {
setState(() {
_manualKeyError = l10n.p2pkInvalidPrivateKey;
_isProcessing = false;
});
return;
}
}
// Reclamar token P2PK
amountReceived = await walletProvider.receiveP2pkToken(
token,
[privateKeyHex],
);
} else {
// Token normal - flujo existente
amountReceived = await walletProvider.receiveToken(token);
}
if (mounted) {
setState(() {
@@ -623,12 +1208,10 @@ class _ReceiveScreenState extends State<ReceiveScreen> {
_isProcessing = false;
});
// Disparar confetti
_confettiController.fire();
// Confetti se dispara globalmente desde WalletProvider.receiveToken
}
} catch (e) {
if (!mounted) return;
final l10n = L10n.of(context)!;
// Mensajes de error más amigables
final errorStr = e.toString().toLowerCase();
String errorMessage;
@@ -636,6 +1219,8 @@ class _ReceiveScreenState extends State<ReceiveScreen> {
errorMessage = l10n.tokenAlreadyClaimed;
} else if (errorStr.contains('unknown mint') || errorStr.contains('mint not found')) {
errorMessage = l10n.unknownMint;
} else if (errorStr.contains('cannot unlock') || errorStr.contains('p2pk')) {
errorMessage = l10n.p2pkCannotUnlock;
} else {
errorMessage = l10n.claimError(e.toString());
}
+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
+244 -12
View File
@@ -1,8 +1,9 @@
import 'package:flutter/material.dart';
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';
@@ -11,6 +12,8 @@ import '../../widgets/common/glass_card.dart';
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';
@@ -24,6 +27,7 @@ class SendScreen extends StatefulWidget {
class _SendScreenState extends State<SendScreen> {
final TextEditingController _memoController = TextEditingController();
final TextEditingController _pubkeyController = TextEditingController();
String _amountValue = '';
bool _isProcessing = false;
@@ -31,6 +35,10 @@ class _SendScreenState extends State<SendScreen> {
BigInt _availableBalance = BigInt.zero;
late String _activeUnit;
// P2PK
bool _useP2PK = false;
String? _pubkeyError;
@override
void initState() {
super.initState();
@@ -51,6 +59,7 @@ class _SendScreenState extends State<SendScreen> {
@override
void dispose() {
_memoController.dispose();
_pubkeyController.dispose();
super.dispose();
}
@@ -111,6 +120,11 @@ class _SendScreenState extends State<SendScreen> {
const SizedBox(height: AppDimensions.paddingMedium),
// P2PK (bloquear a clave pública)
_buildP2PKSection(),
const SizedBox(height: AppDimensions.paddingMedium),
// Mensaje de error (si hay)
if (_errorMessage != null) _buildErrorMessage(),
],
@@ -260,6 +274,194 @@ class _SendScreenState extends State<SendScreen> {
);
}
Widget _buildP2PKSection() {
final l10n = L10n.of(context)!;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
GlassCard(
padding: const EdgeInsets.symmetric(
horizontal: AppDimensions.paddingMedium,
vertical: AppDimensions.paddingSmall,
),
child: Row(
children: [
Icon(
LucideIcons.lock,
color: _useP2PK ? AppColors.primaryAction : AppColors.textSecondary,
size: 20,
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.p2pkLockToKey,
style: const TextStyle(
fontFamily: 'Inter',
fontSize: 14,
fontWeight: FontWeight.w500,
color: Colors.white,
),
),
Text(
l10n.p2pkLockDescription,
style: TextStyle(
fontFamily: 'Inter',
fontSize: 12,
color: AppColors.textSecondary.withValues(alpha: 0.7),
),
),
],
),
),
Switch(
value: _useP2PK,
onChanged: (value) => setState(() => _useP2PK = value),
activeColor: AppColors.primaryAction,
),
],
),
),
// Campo de pubkey cuando P2PK está activo
if (_useP2PK) ...[
const SizedBox(height: AppDimensions.paddingSmall),
GlassCard(
padding: const EdgeInsets.all(AppDimensions.paddingSmall),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextField(
controller: _pubkeyController,
onChanged: _validatePubkey,
maxLines: 1,
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,
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,
),
),
),
],
),
),
],
],
);
}
void _validatePubkey(String value) {
if (value.isEmpty) {
setState(() => _pubkeyError = null);
return;
}
final isValid = NostrUtils.isValidP2PKPubkey(value);
setState(() {
_pubkeyError = isValid ? null : L10n.of(context)!.p2pkInvalidPubkey;
});
}
Future<void> _pastePubkey() async {
final clipboardData = await Clipboard.getData(Clipboard.kTextPlain);
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),
@@ -297,9 +499,13 @@ class _SendScreenState extends State<SendScreen> {
Widget _buildCreateButton() {
final l10n = L10n.of(context)!;
// Si P2PK está habilitado, también debe haber una pubkey válida
final isP2PKValid = !_useP2PK ||
(_pubkeyController.text.isNotEmpty && _pubkeyError == null);
final canCreate = _isValidAmount && !_isProcessing && isP2PKValid;
return PrimaryButton(
text: _isProcessing ? l10n.creatingToken : l10n.createToken,
onPressed: _isValidAmount && !_isProcessing ? _showConfirmation : null,
onPressed: canCreate ? _showConfirmation : null,
);
}
@@ -359,6 +565,13 @@ class _SendScreenState extends State<SendScreen> {
});
if (!isOnline) {
// P2PK requiere conexión al mint
if (_useP2PK) {
setState(() {
_errorMessage = L10n.of(context)!.p2pkRequiresConnection;
});
return;
}
// Offline: ir directo a selección de monedas
_goToOfflineModeWithMessage();
return;
@@ -394,6 +607,8 @@ class _SendScreenState extends State<SendScreen> {
}
Future<void> _createToken() async {
final l10n = L10n.of(context)!;
setState(() {
_isProcessing = true;
_errorMessage = null;
@@ -405,7 +620,21 @@ class _SendScreenState extends State<SendScreen> {
// Crear token real con cdk-flutter
final memo = _memoController.text.isNotEmpty ? _memoController.text : null;
final amount = _amount;
final token = await walletProvider.sendTokens(amount, memo);
String token;
// P2PK: bloquear a clave pública si está habilitado
if (_useP2PK && _pubkeyController.text.isNotEmpty) {
// Usar normalizeToCompressedHex para obtener formato SEC1 (66 chars)
final pubkeyHex = NostrUtils.normalizeToCompressedHex(_pubkeyController.text);
if (pubkeyHex == null) {
throw Exception(l10n.p2pkInvalidPubkey);
}
token = await walletProvider.sendTokensP2pk(amount, pubkeyHex, memo);
} else {
debugPrint('[SendScreen] Sending normal token, amount: $amount');
token = await walletProvider.sendTokens(amount, memo);
}
if (mounted) {
Navigator.pushReplacement(
@@ -420,7 +649,9 @@ class _SendScreenState extends State<SendScreen> {
),
);
}
} catch (e) {
} catch (e, stackTrace) {
debugPrint('[SendScreen] ERROR creating token: $e');
debugPrint('[SendScreen] Stack trace: $stackTrace');
final errorStr = e.toString().toLowerCase();
// Detectar errores de red y redirigir a modo offline
@@ -434,14 +665,15 @@ class _SendScreenState extends State<SendScreen> {
return;
}
final l10n = L10n.of(context)!;
setState(() {
if (errorStr.contains('insufficient') || errorStr.contains('not enough')) {
_errorMessage = l10n.insufficientBalance;
} else {
_errorMessage = l10n.tokenCreationError(e.toString());
}
});
if (mounted) {
setState(() {
if (errorStr.contains('insufficient') || errorStr.contains('not enough')) {
_errorMessage = l10n.insufficientBalance;
} else {
_errorMessage = l10n.tokenCreationError(e.toString());
}
});
}
} finally {
if (mounted) {
setState(() {
+166 -2
View File
@@ -5,10 +5,12 @@ 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';
import '../../core/utils/peanut_codec.dart';
import '../../core/services/nfc_service.dart';
import '../../widgets/common/gradient_background.dart';
import '../../widgets/common/glass_card.dart';
import '../../widgets/common/primary_button.dart';
@@ -44,18 +46,31 @@ class _ShareTokenScreenState extends State<ShareTokenScreen> {
int _currentInterval = _intervalMedium;
String _speedLabel = 'M';
// NFC
NfcState _nfcState = NfcState.unsupported;
bool _nfcWriting = false;
@override
void initState() {
super.initState();
_encodeTokenToUR();
_checkNfc();
}
@override
void dispose() {
_animationTimer?.cancel();
if (_nfcWriting) NfcService.stopEmulating();
super.dispose();
}
Future<void> _checkNfc() async {
final state = await NfcService.checkState();
if (mounted) {
setState(() => _nfcState = state);
}
}
void _encodeTokenToUR() {
// Parsear el token string a objeto Token de cdk-flutter
final token = cdk.Token.parse(encoded: widget.token);
@@ -155,9 +170,14 @@ class _ShareTokenScreenState extends State<ShareTokenScreen> {
const SizedBox(height: AppDimensions.paddingMedium),
// Botones copiar y compartir
// Botones principales: copiar y compartir
_buildActionButtons(context),
const SizedBox(height: AppDimensions.paddingSmall),
// Botones secundarios: bean (peanut) y NFC
_buildSecondaryActions(context),
const SizedBox(height: AppDimensions.paddingLarge),
// Advertencia
@@ -450,6 +470,75 @@ class _ShareTokenScreenState extends State<ShareTokenScreen> {
);
}
Widget _buildSecondaryActions(BuildContext context) {
final isNfcEnabled = _nfcState == NfcState.enabled;
final isNfcUnsupported = _nfcState == NfcState.unsupported;
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// Copiar como emoji (peanut encoding)
Tooltip(
message: L10n.of(context)!.copyAsEmoji,
child: GestureDetector(
onTap: () => _copyPeanut(context),
child: Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.07),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: Colors.white.withValues(alpha: 0.1),
width: 1,
),
),
child: Icon(LucideIcons.bean, color: AppColors.primaryAction, size: 20),
),
),
),
const SizedBox(width: 12),
// NFC - siempre visible con 3 estados
Tooltip(
message: isNfcUnsupported
? L10n.of(context)!.nfcUnsupported
: isNfcEnabled
? L10n.of(context)!.nfcWrite
: L10n.of(context)!.nfcDisabled,
child: GestureDetector(
onTap: () => _writeNfc(context),
child: Opacity(
opacity: isNfcUnsupported ? 0.3 : 1.0,
child: Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: _nfcWriting
? AppColors.primaryAction.withValues(alpha: 0.3)
: Colors.white.withValues(alpha: 0.07),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: _nfcWriting
? AppColors.primaryAction
: Colors.white.withValues(alpha: 0.1),
width: 1,
),
),
child: Icon(
LucideIcons.nfc,
color: _nfcWriting
? AppColors.primaryAction
: isNfcEnabled
? AppColors.textSecondary
: AppColors.textSecondary.withValues(alpha: 0.5),
size: 20,
),
),
),
),
),
],
);
}
Widget _buildWarning() {
return Container(
padding: const EdgeInsets.all(AppDimensions.paddingMedium),
@@ -492,6 +581,19 @@ class _ShareTokenScreenState extends State<ShareTokenScreen> {
);
}
void _copyPeanut(BuildContext context) {
final peanut = PeanutCodec.encode(widget.token);
Clipboard.setData(ClipboardData(text: peanut));
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(L10n.of(context)!.emojiCopiedToClipboard),
backgroundColor: AppColors.success,
behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
),
);
}
void _shareToken(BuildContext context) async {
final memo = widget.memo != null && widget.memo!.isNotEmpty
? '\n"${widget.memo}"'
@@ -508,6 +610,68 @@ class _ShareTokenScreenState extends State<ShareTokenScreen> {
);
}
void _writeNfc(BuildContext context) async {
// Toggle off: stop emulating
if (_nfcWriting) {
await NfcService.stopEmulating();
setState(() => _nfcWriting = false);
return;
}
// Re-check state in case user toggled NFC in settings
final currentState = await NfcService.checkState();
if (mounted) setState(() => _nfcState = currentState);
if (currentState == NfcState.unsupported) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(L10n.of(context)!.nfcUnsupported),
backgroundColor: AppColors.textSecondary,
behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
),
);
return;
}
if (currentState == NfcState.disabled) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(L10n.of(context)!.nfcDisabled),
backgroundColor: AppColors.warning,
behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
),
);
return;
}
try {
await NfcService.startEmulating(widget.token);
setState(() => _nfcWriting = true);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(L10n.of(context)!.nfcHoldNear),
backgroundColor: AppColors.primaryAction,
behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
),
);
} catch (e) {
if (!mounted) return;
setState(() => _nfcWriting = false);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(L10n.of(context)!.nfcWriteError(e.toString())),
backgroundColor: AppColors.error,
behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8)),
),
);
}
}
void _goToHome(BuildContext context) {
Navigator.of(context).popUntil((route) => route.isFirst);
}
+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';
+218
View File
@@ -0,0 +1,218 @@
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 '../../providers/settings_provider.dart';
import '../../widgets/common/gradient_background.dart';
import '../../widgets/common/glass_card.dart';
/// Pantalla de selección de idioma
class LanguageScreen extends StatelessWidget {
const LanguageScreen({super.key});
// Lista de idiomas ordenados por prioridad
static const List<_LanguageData> _languages = [
_LanguageData(code: 'es', flag: '🇪🇸'),
_LanguageData(code: 'en', flag: '🇬🇧'),
_LanguageData(code: 'pt', flag: '🇵🇹'),
_LanguageData(code: 'fr', flag: '🇫🇷'),
_LanguageData(code: 'it', flag: '🇮🇹'),
_LanguageData(code: 'de', flag: '🇩🇪'),
_LanguageData(code: 'ru', flag: '🇷🇺'),
_LanguageData(code: 'zh', flag: '🇨🇳'),
_LanguageData(code: 'ja', flag: '🇯🇵'),
_LanguageData(code: 'ko', flag: '🇰🇷'),
_LanguageData(code: 'sw', flag: '🇰🇪'),
];
@override
Widget build(BuildContext context) {
final l10n = L10n.of(context)!;
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.language,
style: const TextStyle(
fontFamily: 'Inter',
fontWeight: FontWeight.w600,
color: Colors.white,
),
),
),
body: Consumer<SettingsProvider>(
builder: (context, settingsProvider, child) {
return SafeArea(
child: ListView.builder(
padding: const EdgeInsets.all(AppDimensions.paddingMedium),
itemCount: _languages.length,
itemBuilder: (context, index) {
final lang = _languages[index];
final isSelected = settingsProvider.locale == lang.code;
final name = _getLanguageName(context, lang.code);
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: _buildLanguageTile(
context: context,
settingsProvider: settingsProvider,
code: lang.code,
name: name,
flag: lang.flag,
isSelected: isSelected,
),
);
},
),
);
},
),
),
);
}
Widget _buildLanguageTile({
required BuildContext context,
required SettingsProvider settingsProvider,
required String code,
required String name,
required String flag,
required bool isSelected,
}) {
return GlassCard(
onTap: () => _selectLanguage(context, settingsProvider, code, name),
padding: const EdgeInsets.symmetric(
horizontal: AppDimensions.paddingMedium,
vertical: AppDimensions.paddingMedium,
),
child: Row(
children: [
// Bandera
Container(
width: 44,
height: 44,
decoration: BoxDecoration(
color: isSelected
? AppColors.primaryAction.withValues(alpha: 0.2)
: Colors.white.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(12),
),
child: Center(
child: Text(
flag,
style: const TextStyle(fontSize: 24),
),
),
),
const SizedBox(width: AppDimensions.paddingMedium),
// Nombre del idioma
Expanded(
child: Text(
name,
style: TextStyle(
fontFamily: 'Inter',
fontSize: 16,
fontWeight: isSelected ? FontWeight.w600 : FontWeight.w400,
color: Colors.white,
),
),
),
// Indicador de selección
if (isSelected)
Container(
width: 24,
height: 24,
decoration: BoxDecoration(
color: AppColors.primaryAction,
shape: BoxShape.circle,
),
child: const Icon(
LucideIcons.check,
color: Colors.white,
size: 14,
),
)
else
Container(
width: 24,
height: 24,
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(
color: Colors.white.withValues(alpha: 0.3),
width: 2,
),
),
),
],
),
);
}
String _getLanguageName(BuildContext context, String code) {
final l10n = L10n.of(context)!;
switch (code) {
case 'es':
return l10n.spanish;
case 'en':
return l10n.english;
case 'pt':
return l10n.portuguese;
case 'fr':
return l10n.french;
case 'de':
return l10n.german;
case 'it':
return l10n.italian;
case 'ru':
return l10n.russian;
case 'zh':
return l10n.chinese;
case 'ja':
return l10n.japanese;
case 'ko':
return l10n.korean;
case 'sw':
return l10n.swahili;
default:
return l10n.spanish;
}
}
Future<void> _selectLanguage(
BuildContext context,
SettingsProvider settingsProvider,
String code,
String name,
) async {
await settingsProvider.setLocale(code);
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(L10n.of(context)!.languageChanged(name)),
backgroundColor: AppColors.success,
),
);
}
}
}
/// Datos de cada idioma
class _LanguageData {
final String code;
final String flag;
const _LanguageData({
required this.code,
required this.flag,
});
}
@@ -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';
@@ -0,0 +1,772 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:lucide_icons/lucide_icons.dart';
import 'package:provider/provider.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';
import '../../core/utils/nostr_utils.dart';
import '../../providers/p2pk_provider.dart';
import '../../models/p2pk_key.dart';
import '../../widgets/common/gradient_background.dart';
import '../../widgets/common/glass_card.dart';
/// Pantalla para gestionar claves P2PK
class P2PKKeysScreen extends StatefulWidget {
const P2PKKeysScreen({super.key});
@override
State<P2PKKeysScreen> createState() => _P2PKKeysScreenState();
}
class _P2PKKeysScreenState extends State<P2PKKeysScreen> {
@override
Widget build(BuildContext context) {
final l10n = L10n.of(context)!;
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.p2pkTitle,
style: const TextStyle(
fontFamily: 'Inter',
fontWeight: FontWeight.w600,
color: Colors.white,
),
),
),
body: SafeArea(
child: Consumer<P2PKProvider>(
builder: (context, p2pkProvider, child) {
if (!p2pkProvider.isInitialized) {
return const Center(
child: CircularProgressIndicator(
color: AppColors.primaryAction,
),
);
}
return Column(
children: [
// Contenido scrolleable
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.all(AppDimensions.paddingMedium),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Banner experimental
_buildExperimentalBanner(l10n),
const SizedBox(height: AppDimensions.paddingMedium),
// Clave principal
if (p2pkProvider.primaryKey != null) ...[
_buildSectionHeader(l10n.p2pkPrimaryKey),
const SizedBox(height: AppDimensions.paddingSmall),
_buildKeyCard(p2pkProvider.primaryKey!, p2pkProvider, l10n),
const SizedBox(height: AppDimensions.paddingLarge),
],
// Claves importadas
_buildSectionHeader(l10n.p2pkImportedKeys),
const SizedBox(height: AppDimensions.paddingSmall),
if (p2pkProvider.importedKeys.isEmpty)
_buildEmptyImportedState(l10n)
else
...p2pkProvider.importedKeys.map(
(key) => Padding(
padding: const EdgeInsets.only(
bottom: AppDimensions.paddingSmall,
),
child: _buildKeyCard(key, p2pkProvider, l10n),
),
),
],
),
),
),
// Botón importar (fijo abajo)
if (p2pkProvider.canImportMore)
Padding(
padding: const EdgeInsets.all(AppDimensions.paddingMedium),
child: _buildImportButton(p2pkProvider, l10n),
),
],
);
},
),
),
),
);
}
Widget _buildExperimentalBanner(L10n l10n) {
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: AppColors.warning.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: AppColors.warning.withValues(alpha: 0.3),
),
),
child: Row(
children: [
Icon(
LucideIcons.flaskConical,
color: AppColors.warning,
size: 20,
),
const SizedBox(width: 12),
Expanded(
child: Text(
l10n.p2pkExperimental,
style: TextStyle(
fontFamily: 'Inter',
fontSize: 13,
color: AppColors.warning,
),
),
),
],
),
);
}
Widget _buildSectionHeader(String text) {
return Text(
text,
style: TextStyle(
fontFamily: 'Inter',
fontSize: 12,
fontWeight: FontWeight.bold,
letterSpacing: 1.5,
color: AppColors.textSecondary.withValues(alpha: 0.6),
),
);
}
Widget _buildKeyCard(P2PKKey key, P2PKProvider provider, L10n l10n) {
String truncatedNpub;
try {
final npub = NostrUtils.hexToNpub(key.publicKey);
truncatedNpub = '${npub.substring(0, 12)}...${npub.substring(npub.length - 8)}';
} catch (_) {
truncatedNpub = key.publicKey.length > 16
? '${key.publicKey.substring(0, 16)}...'
: key.publicKey;
}
return GlassCard(
padding: const EdgeInsets.all(AppDimensions.paddingMedium),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header con label y badge
Row(
children: [
Expanded(
child: Text(
key.label,
style: const TextStyle(
fontFamily: 'Inter',
fontSize: 16,
fontWeight: FontWeight.w600,
color: Colors.white,
),
),
),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: key.isDerived
? AppColors.primaryAction.withValues(alpha: 0.2)
: AppColors.secondaryAction.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(6),
),
child: Text(
key.isDerived ? l10n.p2pkDerived : l10n.p2pkImported,
style: TextStyle(
fontFamily: 'Inter',
fontSize: 11,
fontWeight: FontWeight.w500,
color: key.isDerived
? AppColors.primaryAction
: AppColors.secondaryAction,
),
),
),
],
),
const SizedBox(height: 12),
// npub (tap to copy)
GestureDetector(
onTap: () {
final fullNpub = _safeNpub(key.publicKey);
Clipboard.setData(ClipboardData(text: fullNpub));
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(l10n.copied('npub')),
duration: const Duration(seconds: 2),
backgroundColor: AppColors.success,
),
);
},
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.05),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: Colors.white.withValues(alpha: 0.1),
),
),
child: Row(
children: [
Icon(
LucideIcons.key,
color: AppColors.textSecondary,
size: 16,
),
const SizedBox(width: 10),
Expanded(
child: Text(
truncatedNpub,
style: TextStyle(
fontFamily: 'monospace',
fontSize: 13,
color: AppColors.textSecondary,
),
),
),
Icon(
LucideIcons.copy,
color: AppColors.textSecondary.withValues(alpha: 0.5),
size: 16,
),
],
),
),
),
const SizedBox(height: 12),
// Acciones
Row(
children: [
// Mostrar QR
Expanded(
child: GestureDetector(
onTap: () => _showQRDialog(key, l10n),
child: Container(
padding: const EdgeInsets.symmetric(vertical: 10),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(8),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
LucideIcons.qrCode,
color: AppColors.textSecondary,
size: 16,
),
const SizedBox(width: 8),
Text(
l10n.p2pkShowQR,
style: TextStyle(
fontFamily: 'Inter',
fontSize: 13,
color: AppColors.textSecondary,
),
),
],
),
),
),
),
// Eliminar (solo para importadas)
if (!key.isDerived) ...[
const SizedBox(width: 8),
GestureDetector(
onTap: () => _showDeleteDialog(key, provider, l10n),
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 10,
),
decoration: BoxDecoration(
color: AppColors.error.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(8),
),
child: Icon(
LucideIcons.trash2,
color: AppColors.error,
size: 16,
),
),
),
],
],
),
],
),
);
}
Widget _buildEmptyImportedState(L10n l10n) {
return Container(
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.03),
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: Colors.white.withValues(alpha: 0.1),
),
),
child: Column(
children: [
Icon(
LucideIcons.keyRound,
color: AppColors.textSecondary.withValues(alpha: 0.4),
size: 40,
),
const SizedBox(height: 12),
Text(
l10n.p2pkNoImportedKeys,
style: TextStyle(
fontFamily: 'Inter',
fontSize: 14,
color: AppColors.textSecondary.withValues(alpha: 0.6),
),
textAlign: TextAlign.center,
),
],
),
);
}
Widget _buildImportButton(P2PKProvider provider, L10n l10n) {
return GestureDetector(
onTap: () => _showImportDialog(provider, l10n),
child: Container(
padding: const EdgeInsets.symmetric(vertical: 16),
decoration: BoxDecoration(
gradient: const LinearGradient(
colors: [AppColors.primaryAction, Color(0xFFFF9100)],
),
borderRadius: BorderRadius.circular(16),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(LucideIcons.plus, color: Colors.white, size: 20),
const SizedBox(width: 8),
Text(
l10n.p2pkImportNsec,
style: const TextStyle(
fontFamily: 'Inter',
fontSize: 16,
fontWeight: FontWeight.w600,
color: Colors.white,
),
),
],
),
),
);
}
// ============ DIÁLOGOS ============
void _showQRDialog(P2PKKey key, L10n l10n) {
final npub = _safeNpub(key.publicKey);
showDialog(
context: context,
builder: (context) => AlertDialog(
backgroundColor: AppColors.deepVoidPurple,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)),
content: SizedBox(
width: 280,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
key.label,
style: const TextStyle(
fontFamily: 'Inter',
fontSize: 18,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
const SizedBox(height: 16),
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
),
child: QrImageView(
data: npub,
version: QrVersions.auto,
size: 200,
backgroundColor: Colors.white,
),
),
const SizedBox(height: 16),
GestureDetector(
onTap: () {
Clipboard.setData(ClipboardData(text: npub));
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(l10n.copied('npub')),
duration: const Duration(seconds: 2),
backgroundColor: AppColors.success,
),
);
},
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 10,
),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
LucideIcons.copy,
color: AppColors.textSecondary,
size: 16,
),
const SizedBox(width: 8),
Text(
l10n.p2pkCopy,
style: TextStyle(
fontFamily: 'Inter',
fontSize: 14,
color: AppColors.textSecondary,
),
),
],
),
),
),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(
l10n.close,
style: const TextStyle(
fontFamily: 'Inter',
color: AppColors.textSecondary,
),
),
),
],
),
);
}
void _showImportDialog(P2PKProvider provider, L10n l10n) {
final nsecController = TextEditingController();
final labelController = TextEditingController();
bool obscureNsec = true;
String? errorMessage;
showDialog(
context: context,
builder: (context) => StatefulBuilder(
builder: (context, setDialogState) {
final nsecBorder = OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: errorMessage != null
? const BorderSide(color: AppColors.error)
: BorderSide.none,
);
return AlertDialog(
backgroundColor: AppColors.deepVoidPurple,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(24),
),
title: Text(
l10n.p2pkImportNsec,
style: const TextStyle(
fontFamily: 'Inter',
fontSize: 20,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
content: SizedBox(
width: 300,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Label
TextField(
controller: labelController,
style: const TextStyle(
fontFamily: 'Inter',
fontSize: 14,
color: Colors.white,
),
decoration: InputDecoration(
hintText: l10n.p2pkEnterLabel,
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,
),
prefixIcon: Icon(
LucideIcons.tag,
color: AppColors.textSecondary,
size: 18,
),
),
),
const SizedBox(height: 12),
// nsec input
TextField(
controller: nsecController,
obscureText: obscureNsec,
style: const TextStyle(
fontFamily: 'monospace',
fontSize: 14,
color: Colors.white,
),
decoration: InputDecoration(
hintText: 'nsec1...',
hintStyle: TextStyle(
fontFamily: 'monospace',
fontSize: 14,
color: Colors.white.withValues(alpha: 0.3),
),
filled: true,
fillColor: Colors.white.withValues(alpha: 0.05),
border: nsecBorder,
enabledBorder: nsecBorder,
focusedBorder: nsecBorder,
prefixIcon: Icon(
LucideIcons.keyRound,
color: AppColors.textSecondary,
size: 18,
),
suffixIcon: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: Icon(
obscureNsec ? LucideIcons.eye : LucideIcons.eyeOff,
color: AppColors.textSecondary,
size: 18,
),
onPressed: () {
setDialogState(() => obscureNsec = !obscureNsec);
},
),
IconButton(
icon: Icon(
LucideIcons.clipboard,
color: AppColors.textSecondary,
size: 18,
),
onPressed: () async {
final data = await Clipboard.getData('text/plain');
if (data?.text != null) {
nsecController.text = data!.text!;
}
},
),
],
),
),
),
// Error message
if (errorMessage != null) ...[
const SizedBox(height: 8),
Text(
errorMessage!,
style: const TextStyle(
fontFamily: 'Inter',
fontSize: 12,
color: AppColors.error,
),
),
],
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(
l10n.cancel,
style: const TextStyle(
fontFamily: 'Inter',
color: AppColors.textSecondary,
),
),
),
TextButton(
onPressed: () async {
try {
await provider.importFromNsec(
nsecController.text.trim(),
labelController.text.trim(),
);
if (context.mounted) {
Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(l10n.success),
backgroundColor: AppColors.success,
),
);
}
} on P2PKException catch (e) {
setDialogState(() {
errorMessage = _getErrorMessage(e.code, l10n);
});
}
},
child: Text(
l10n.p2pkImport,
style: const TextStyle(
fontFamily: 'Inter',
fontWeight: FontWeight.w600,
color: AppColors.primaryAction,
),
),
),
],
);
},
),
);
}
void _showDeleteDialog(P2PKKey key, P2PKProvider provider, L10n l10n) {
showDialog(
context: context,
builder: (context) => AlertDialog(
backgroundColor: AppColors.deepVoidPurple,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)),
title: Text(
l10n.p2pkDeleteTitle,
style: const TextStyle(
fontFamily: 'Inter',
fontSize: 18,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
content: Text(
l10n.p2pkDeleteConfirm,
style: TextStyle(
fontFamily: 'Inter',
fontSize: 14,
color: AppColors.textSecondary,
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(
l10n.cancel,
style: const TextStyle(
fontFamily: 'Inter',
color: AppColors.textSecondary,
),
),
),
TextButton(
onPressed: () async {
try {
await provider.removeKey(key.id);
if (context.mounted) {
Navigator.pop(context);
}
} catch (e) {
if (context.mounted) {
Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(l10n.error),
backgroundColor: AppColors.error,
),
);
}
}
},
child: Text(
l10n.delete,
style: const TextStyle(
fontFamily: 'Inter',
fontWeight: FontWeight.w600,
color: AppColors.error,
),
),
),
],
),
);
}
/// Convierte hex a npub de forma segura, retornando el hex como fallback.
String _safeNpub(String hex) {
try {
return NostrUtils.hexToNpub(hex);
} catch (_) {
return hex;
}
}
String _getErrorMessage(P2PKErrorCode code, L10n l10n) {
switch (code) {
case P2PKErrorCode.maxKeysReached:
return l10n.p2pkErrorMaxKeysReached;
case P2PKErrorCode.invalidNsec:
return l10n.p2pkErrorInvalidNsec;
case P2PKErrorCode.keyAlreadyExists:
return l10n.p2pkErrorKeyAlreadyExists;
case P2PKErrorCode.keyNotFound:
return l10n.p2pkErrorKeyNotFound;
case P2PKErrorCode.cannotDeletePrimaryKey:
return l10n.p2pkErrorCannotDeletePrimary;
}
}
}
+207 -205
View File
@@ -1,8 +1,9 @@
import 'package:flutter/material.dart';
import 'package:lucide_icons/lucide_icons.dart';
import 'package:package_info_plus/package_info_plus.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';
@@ -12,6 +13,8 @@ import '../../widgets/common/gradient_background.dart';
import '../../widgets/common/glass_card.dart';
import '../2_onboarding/backup_seed_screen.dart';
import 'mints_screen.dart';
import 'language_screen.dart';
import 'p2pk_keys_screen.dart';
/// Pantalla de configuración
class SettingsScreen extends StatefulWidget {
@@ -22,6 +25,25 @@ class SettingsScreen extends StatefulWidget {
}
class _SettingsScreenState extends State<SettingsScreen> {
String _appVersion = '';
@override
void initState() {
super.initState();
_loadVersion();
}
Future<void> _loadVersion() async {
try {
final info = await PackageInfo.fromPlatform();
if (!mounted) return;
setState(() => _appVersion = info.version);
} catch (_) {
if (!mounted) return;
setState(() => _appVersion = '');
}
}
@override
Widget build(BuildContext context) {
final l10n = L10n.of(context)!;
@@ -93,6 +115,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
subtitle: l10n.scanMintsWithSeed,
onTap: () => _showRecoverTokensDialog(context, settingsProvider),
),
_buildP2PKTile(l10n),
const SizedBox(height: AppDimensions.paddingLarge),
@@ -103,7 +126,14 @@ class _SettingsScreenState extends State<SettingsScreen> {
icon: LucideIcons.globe,
title: l10n.language,
subtitle: _getLanguageName(settingsProvider.locale),
onTap: () => _showLanguageSelector(context, settingsProvider),
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => const LanguageScreen(),
),
);
},
),
const SizedBox(height: AppDimensions.paddingLarge),
@@ -114,7 +144,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
_buildInfoTile(
icon: LucideIcons.tag,
title: l10n.version,
subtitle: '0.0.1',
subtitle: _appVersion,
),
_buildSettingTile(
icon: LucideIcons.info,
@@ -217,6 +247,100 @@ class _SettingsScreenState extends State<SettingsScreen> {
);
}
Widget _buildP2PKTile(L10n l10n) {
return GlassCard(
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => const P2PKKeysScreen(),
),
);
},
padding: const EdgeInsets.symmetric(
horizontal: AppDimensions.paddingMedium,
vertical: AppDimensions.paddingMedium,
),
child: Row(
children: [
Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(10),
),
child: const Icon(LucideIcons.keyRound, color: AppColors.textSecondary, size: 20),
),
const SizedBox(width: AppDimensions.paddingMedium),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Título con badge experimental
Row(
children: [
Text(
l10n.p2pkTitle,
style: const TextStyle(
fontFamily: 'Inter',
fontSize: 16,
fontWeight: FontWeight.w500,
color: Colors.white,
),
),
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: AppColors.error.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(4),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
LucideIcons.flaskConical,
color: AppColors.error,
size: 12,
),
const SizedBox(width: 4),
Text(
l10n.p2pkExperimentalShort,
style: const TextStyle(
fontFamily: 'Inter',
fontSize: 10,
fontWeight: FontWeight.w600,
color: AppColors.error,
),
),
],
),
),
],
),
// Descripción normal
Text(
l10n.p2pkSettingsDescription,
style: TextStyle(
fontFamily: 'Inter',
fontSize: 14,
color: AppColors.textSecondary.withValues(alpha: 0.7),
),
),
],
),
),
Icon(
LucideIcons.chevronRight,
color: AppColors.textSecondary.withValues(alpha: 0.5),
size: 20,
),
],
),
);
}
Widget _buildInfoTile({
required IconData icon,
required String title,
@@ -323,6 +447,14 @@ class _SettingsScreenState extends State<SettingsScreen> {
return l10n.german;
case 'it':
return l10n.italian;
case 'ko':
return l10n.korean;
case 'zh':
return l10n.chinese;
case 'ja':
return l10n.japanese;
case 'sw':
return l10n.swahili;
default:
return l10n.spanish;
}
@@ -471,167 +603,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
}
}
/// 3. Selector de idioma
void _showLanguageSelector(
BuildContext context, SettingsProvider settingsProvider) {
final l10n = L10n.of(context)!;
showModalBottomSheet(
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),
width: 1,
),
),
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),
),
),
Text(
l10n.selectLanguage,
style: const TextStyle(
fontFamily: 'Inter',
fontSize: 20,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
const SizedBox(height: AppDimensions.paddingMedium),
_buildLanguageOption(
context,
settingsProvider,
'es',
l10n.spanish,
'🇪🇸',
),
_buildLanguageOption(
context,
settingsProvider,
'en',
l10n.english,
'🇬🇧',
),
_buildLanguageOption(
context,
settingsProvider,
'pt',
l10n.portuguese,
'🇵🇹',
),
_buildLanguageOption(
context,
settingsProvider,
'fr',
l10n.french,
'🇫🇷',
),
_buildLanguageOption(
context,
settingsProvider,
'ru',
l10n.russian,
'🇷🇺',
),
_buildLanguageOption(
context,
settingsProvider,
'de',
l10n.german,
'🇩🇪',
),
_buildLanguageOption(
context,
settingsProvider,
'it',
l10n.italian,
'🇮🇹',
),
const SizedBox(height: AppDimensions.paddingSmall),
],
),
),
);
}
Widget _buildLanguageOption(
BuildContext context,
SettingsProvider settingsProvider,
String locale,
String name,
String flag,
) {
final isSelected = settingsProvider.locale == locale;
return GestureDetector(
onTap: () async {
await settingsProvider.setLocale(locale);
if (mounted) {
Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(L10n.of(context)!.languageChanged(name)),
backgroundColor: AppColors.success,
),
);
}
},
child: Container(
margin: const EdgeInsets.only(bottom: 8),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
color: isSelected
? AppColors.primaryAction.withValues(alpha: 0.2)
: Colors.white.withValues(alpha: 0.05),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: isSelected
? AppColors.primaryAction.withValues(alpha: 0.5)
: Colors.transparent,
width: 1,
),
),
child: Row(
children: [
Text(flag, style: const TextStyle(fontSize: 24)),
const SizedBox(width: 12),
Expanded(
child: Text(
name,
style: TextStyle(
fontFamily: 'Inter',
fontSize: 16,
fontWeight: isSelected ? FontWeight.w600 : FontWeight.w400,
color: Colors.white,
),
),
),
if (isSelected)
Icon(
LucideIcons.check,
color: AppColors.primaryAction,
size: 20,
),
],
),
),
);
}
/// 4. Acerca de
/// 3. Acerca de
void _showAboutDialog(BuildContext context) {
showDialog(
context: context,
@@ -668,7 +640,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
),
const SizedBox(height: 4),
Text(
'v0.0.1',
'v$_appVersion',
style: TextStyle(
fontFamily: 'Inter',
fontSize: 14,
@@ -686,43 +658,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
Text(
L10n.of(context)!.aboutDescription,
style: TextStyle(
fontFamily: 'Inter',
fontSize: 14,
color: AppColors.textSecondary.withValues(alpha: 0.7),
),
textAlign: TextAlign.center,
),
const SizedBox(height: 24),
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.05),
borderRadius: BorderRadius.circular(12),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
LucideIcons.bitcoin,
color: AppColors.secondaryAction,
size: 16,
),
const SizedBox(width: 8),
Text(
'Cuba Bitcoin',
style: TextStyle(
fontFamily: 'Inter',
fontSize: 14,
fontWeight: FontWeight.w500,
color: AppColors.secondaryAction,
),
),
],
),
),
_buildAboutDescription(context),
],
),
actions: [
@@ -741,7 +677,73 @@ class _SettingsScreenState extends State<SettingsScreen> {
);
}
/// 5. Abrir GitHub
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 (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(L10n.of(context)!.couldNotOpenLink),
backgroundColor: AppColors.error,
),
);
}
}
}
/// 4. Abrir GitHub
Future<void> _openGitHub() async {
final url = Uri.parse('https://github.com/Forte11Cuba/elcaju');
try {
@@ -758,7 +760,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
}
}
/// 6. Recuperar tokens
/// 5. Recuperar tokens
void _showRecoverTokensDialog(
BuildContext context, SettingsProvider settingsProvider) {
showModalBottomSheet(
@@ -771,7 +773,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
);
}
/// 7. Borrar wallet
/// 6. Borrar wallet
void _showDeleteWalletDialog(
BuildContext context, SettingsProvider settingsProvider) {
showModalBottomSheet(
+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(
+31
View File
@@ -0,0 +1,31 @@
// This file is automatically generated, so please do not edit it.
// @generated by `flutter_rust_bridge`@ 2.11.1.
// ignore_for_file: invalid_use_of_internal_member, unused_import, unnecessary_import
import '../frb_generated.dart';
import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated.dart';
import 'package:freezed_annotation/freezed_annotation.dart' hide protected;
part 'error.freezed.dart';
// These function are ignored because they are on traits that is not defined in current crate (put an empty `#[frb]` on it to unignore): `fmt`, `fmt`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`
@freezed
sealed class Error with _$Error implements FrbException {
const Error._();
/// Error del protocolo Cashu (cdk)
const factory Error.cdk(String field0) = Error_Cdk;
/// Error de base de datos (SQLite)
const factory Error.database(String field0) = Error_Database;
/// Input inválido del usuario
const factory Error.invalidInput() = Error_InvalidInput;
/// Error de red (HTTP, conexión)
const factory Error.network(String field0) = Error_Network;
/// Error de UR (QR multiframe)
const factory Error.ur(String field0) = Error_Ur;
}
+863
View File
@@ -0,0 +1,863 @@
// coverage:ignore-file
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
part of 'error.dart';
// **************************************************************************
// FreezedGenerator
// **************************************************************************
T _$identity<T>(T value) => value;
final _privateConstructorUsedError = UnsupportedError(
'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models',
);
/// @nodoc
mixin _$Error {
@optionalTypeArgs
TResult when<TResult extends Object?>({
required TResult Function(String field0) cdk,
required TResult Function(String field0) database,
required TResult Function() invalidInput,
required TResult Function(String field0) network,
required TResult Function(String field0) ur,
}) => throw _privateConstructorUsedError;
@optionalTypeArgs
TResult? whenOrNull<TResult extends Object?>({
TResult? Function(String field0)? cdk,
TResult? Function(String field0)? database,
TResult? Function()? invalidInput,
TResult? Function(String field0)? network,
TResult? Function(String field0)? ur,
}) => throw _privateConstructorUsedError;
@optionalTypeArgs
TResult maybeWhen<TResult extends Object?>({
TResult Function(String field0)? cdk,
TResult Function(String field0)? database,
TResult Function()? invalidInput,
TResult Function(String field0)? network,
TResult Function(String field0)? ur,
required TResult orElse(),
}) => throw _privateConstructorUsedError;
@optionalTypeArgs
TResult map<TResult extends Object?>({
required TResult Function(Error_Cdk value) cdk,
required TResult Function(Error_Database value) database,
required TResult Function(Error_InvalidInput value) invalidInput,
required TResult Function(Error_Network value) network,
required TResult Function(Error_Ur value) ur,
}) => throw _privateConstructorUsedError;
@optionalTypeArgs
TResult? mapOrNull<TResult extends Object?>({
TResult? Function(Error_Cdk value)? cdk,
TResult? Function(Error_Database value)? database,
TResult? Function(Error_InvalidInput value)? invalidInput,
TResult? Function(Error_Network value)? network,
TResult? Function(Error_Ur value)? ur,
}) => throw _privateConstructorUsedError;
@optionalTypeArgs
TResult maybeMap<TResult extends Object?>({
TResult Function(Error_Cdk value)? cdk,
TResult Function(Error_Database value)? database,
TResult Function(Error_InvalidInput value)? invalidInput,
TResult Function(Error_Network value)? network,
TResult Function(Error_Ur value)? ur,
required TResult orElse(),
}) => throw _privateConstructorUsedError;
}
/// @nodoc
abstract class $ErrorCopyWith<$Res> {
factory $ErrorCopyWith(Error value, $Res Function(Error) then) =
_$ErrorCopyWithImpl<$Res, Error>;
}
/// @nodoc
class _$ErrorCopyWithImpl<$Res, $Val extends Error>
implements $ErrorCopyWith<$Res> {
_$ErrorCopyWithImpl(this._value, this._then);
// ignore: unused_field
final $Val _value;
// ignore: unused_field
final $Res Function($Val) _then;
/// Create a copy of Error
/// with the given fields replaced by the non-null parameter values.
}
/// @nodoc
abstract class _$$Error_CdkImplCopyWith<$Res> {
factory _$$Error_CdkImplCopyWith(
_$Error_CdkImpl value,
$Res Function(_$Error_CdkImpl) then,
) = __$$Error_CdkImplCopyWithImpl<$Res>;
@useResult
$Res call({String field0});
}
/// @nodoc
class __$$Error_CdkImplCopyWithImpl<$Res>
extends _$ErrorCopyWithImpl<$Res, _$Error_CdkImpl>
implements _$$Error_CdkImplCopyWith<$Res> {
__$$Error_CdkImplCopyWithImpl(
_$Error_CdkImpl _value,
$Res Function(_$Error_CdkImpl) _then,
) : super(_value, _then);
/// Create a copy of Error
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline')
@override
$Res call({Object? field0 = null}) {
return _then(
_$Error_CdkImpl(
null == field0
? _value.field0
: field0 // ignore: cast_nullable_to_non_nullable
as String,
),
);
}
}
/// @nodoc
class _$Error_CdkImpl extends Error_Cdk {
const _$Error_CdkImpl(this.field0) : super._();
@override
final String field0;
@override
String toString() {
return 'Error.cdk(field0: $field0)';
}
@override
bool operator ==(Object other) {
return identical(this, other) ||
(other.runtimeType == runtimeType &&
other is _$Error_CdkImpl &&
(identical(other.field0, field0) || other.field0 == field0));
}
@override
int get hashCode => Object.hash(runtimeType, field0);
/// Create a copy of Error
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@override
@pragma('vm:prefer-inline')
_$$Error_CdkImplCopyWith<_$Error_CdkImpl> get copyWith =>
__$$Error_CdkImplCopyWithImpl<_$Error_CdkImpl>(this, _$identity);
@override
@optionalTypeArgs
TResult when<TResult extends Object?>({
required TResult Function(String field0) cdk,
required TResult Function(String field0) database,
required TResult Function() invalidInput,
required TResult Function(String field0) network,
required TResult Function(String field0) ur,
}) {
return cdk(field0);
}
@override
@optionalTypeArgs
TResult? whenOrNull<TResult extends Object?>({
TResult? Function(String field0)? cdk,
TResult? Function(String field0)? database,
TResult? Function()? invalidInput,
TResult? Function(String field0)? network,
TResult? Function(String field0)? ur,
}) {
return cdk?.call(field0);
}
@override
@optionalTypeArgs
TResult maybeWhen<TResult extends Object?>({
TResult Function(String field0)? cdk,
TResult Function(String field0)? database,
TResult Function()? invalidInput,
TResult Function(String field0)? network,
TResult Function(String field0)? ur,
required TResult orElse(),
}) {
if (cdk != null) {
return cdk(field0);
}
return orElse();
}
@override
@optionalTypeArgs
TResult map<TResult extends Object?>({
required TResult Function(Error_Cdk value) cdk,
required TResult Function(Error_Database value) database,
required TResult Function(Error_InvalidInput value) invalidInput,
required TResult Function(Error_Network value) network,
required TResult Function(Error_Ur value) ur,
}) {
return cdk(this);
}
@override
@optionalTypeArgs
TResult? mapOrNull<TResult extends Object?>({
TResult? Function(Error_Cdk value)? cdk,
TResult? Function(Error_Database value)? database,
TResult? Function(Error_InvalidInput value)? invalidInput,
TResult? Function(Error_Network value)? network,
TResult? Function(Error_Ur value)? ur,
}) {
return cdk?.call(this);
}
@override
@optionalTypeArgs
TResult maybeMap<TResult extends Object?>({
TResult Function(Error_Cdk value)? cdk,
TResult Function(Error_Database value)? database,
TResult Function(Error_InvalidInput value)? invalidInput,
TResult Function(Error_Network value)? network,
TResult Function(Error_Ur value)? ur,
required TResult orElse(),
}) {
if (cdk != null) {
return cdk(this);
}
return orElse();
}
}
abstract class Error_Cdk extends Error {
const factory Error_Cdk(final String field0) = _$Error_CdkImpl;
const Error_Cdk._() : super._();
String get field0;
/// Create a copy of Error
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
_$$Error_CdkImplCopyWith<_$Error_CdkImpl> get copyWith =>
throw _privateConstructorUsedError;
}
/// @nodoc
abstract class _$$Error_DatabaseImplCopyWith<$Res> {
factory _$$Error_DatabaseImplCopyWith(
_$Error_DatabaseImpl value,
$Res Function(_$Error_DatabaseImpl) then,
) = __$$Error_DatabaseImplCopyWithImpl<$Res>;
@useResult
$Res call({String field0});
}
/// @nodoc
class __$$Error_DatabaseImplCopyWithImpl<$Res>
extends _$ErrorCopyWithImpl<$Res, _$Error_DatabaseImpl>
implements _$$Error_DatabaseImplCopyWith<$Res> {
__$$Error_DatabaseImplCopyWithImpl(
_$Error_DatabaseImpl _value,
$Res Function(_$Error_DatabaseImpl) _then,
) : super(_value, _then);
/// Create a copy of Error
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline')
@override
$Res call({Object? field0 = null}) {
return _then(
_$Error_DatabaseImpl(
null == field0
? _value.field0
: field0 // ignore: cast_nullable_to_non_nullable
as String,
),
);
}
}
/// @nodoc
class _$Error_DatabaseImpl extends Error_Database {
const _$Error_DatabaseImpl(this.field0) : super._();
@override
final String field0;
@override
String toString() {
return 'Error.database(field0: $field0)';
}
@override
bool operator ==(Object other) {
return identical(this, other) ||
(other.runtimeType == runtimeType &&
other is _$Error_DatabaseImpl &&
(identical(other.field0, field0) || other.field0 == field0));
}
@override
int get hashCode => Object.hash(runtimeType, field0);
/// Create a copy of Error
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@override
@pragma('vm:prefer-inline')
_$$Error_DatabaseImplCopyWith<_$Error_DatabaseImpl> get copyWith =>
__$$Error_DatabaseImplCopyWithImpl<_$Error_DatabaseImpl>(
this,
_$identity,
);
@override
@optionalTypeArgs
TResult when<TResult extends Object?>({
required TResult Function(String field0) cdk,
required TResult Function(String field0) database,
required TResult Function() invalidInput,
required TResult Function(String field0) network,
required TResult Function(String field0) ur,
}) {
return database(field0);
}
@override
@optionalTypeArgs
TResult? whenOrNull<TResult extends Object?>({
TResult? Function(String field0)? cdk,
TResult? Function(String field0)? database,
TResult? Function()? invalidInput,
TResult? Function(String field0)? network,
TResult? Function(String field0)? ur,
}) {
return database?.call(field0);
}
@override
@optionalTypeArgs
TResult maybeWhen<TResult extends Object?>({
TResult Function(String field0)? cdk,
TResult Function(String field0)? database,
TResult Function()? invalidInput,
TResult Function(String field0)? network,
TResult Function(String field0)? ur,
required TResult orElse(),
}) {
if (database != null) {
return database(field0);
}
return orElse();
}
@override
@optionalTypeArgs
TResult map<TResult extends Object?>({
required TResult Function(Error_Cdk value) cdk,
required TResult Function(Error_Database value) database,
required TResult Function(Error_InvalidInput value) invalidInput,
required TResult Function(Error_Network value) network,
required TResult Function(Error_Ur value) ur,
}) {
return database(this);
}
@override
@optionalTypeArgs
TResult? mapOrNull<TResult extends Object?>({
TResult? Function(Error_Cdk value)? cdk,
TResult? Function(Error_Database value)? database,
TResult? Function(Error_InvalidInput value)? invalidInput,
TResult? Function(Error_Network value)? network,
TResult? Function(Error_Ur value)? ur,
}) {
return database?.call(this);
}
@override
@optionalTypeArgs
TResult maybeMap<TResult extends Object?>({
TResult Function(Error_Cdk value)? cdk,
TResult Function(Error_Database value)? database,
TResult Function(Error_InvalidInput value)? invalidInput,
TResult Function(Error_Network value)? network,
TResult Function(Error_Ur value)? ur,
required TResult orElse(),
}) {
if (database != null) {
return database(this);
}
return orElse();
}
}
abstract class Error_Database extends Error {
const factory Error_Database(final String field0) = _$Error_DatabaseImpl;
const Error_Database._() : super._();
String get field0;
/// Create a copy of Error
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
_$$Error_DatabaseImplCopyWith<_$Error_DatabaseImpl> get copyWith =>
throw _privateConstructorUsedError;
}
/// @nodoc
abstract class _$$Error_InvalidInputImplCopyWith<$Res> {
factory _$$Error_InvalidInputImplCopyWith(
_$Error_InvalidInputImpl value,
$Res Function(_$Error_InvalidInputImpl) then,
) = __$$Error_InvalidInputImplCopyWithImpl<$Res>;
}
/// @nodoc
class __$$Error_InvalidInputImplCopyWithImpl<$Res>
extends _$ErrorCopyWithImpl<$Res, _$Error_InvalidInputImpl>
implements _$$Error_InvalidInputImplCopyWith<$Res> {
__$$Error_InvalidInputImplCopyWithImpl(
_$Error_InvalidInputImpl _value,
$Res Function(_$Error_InvalidInputImpl) _then,
) : super(_value, _then);
/// Create a copy of Error
/// with the given fields replaced by the non-null parameter values.
}
/// @nodoc
class _$Error_InvalidInputImpl extends Error_InvalidInput {
const _$Error_InvalidInputImpl() : super._();
@override
String toString() {
return 'Error.invalidInput()';
}
@override
bool operator ==(Object other) {
return identical(this, other) ||
(other.runtimeType == runtimeType && other is _$Error_InvalidInputImpl);
}
@override
int get hashCode => runtimeType.hashCode;
@override
@optionalTypeArgs
TResult when<TResult extends Object?>({
required TResult Function(String field0) cdk,
required TResult Function(String field0) database,
required TResult Function() invalidInput,
required TResult Function(String field0) network,
required TResult Function(String field0) ur,
}) {
return invalidInput();
}
@override
@optionalTypeArgs
TResult? whenOrNull<TResult extends Object?>({
TResult? Function(String field0)? cdk,
TResult? Function(String field0)? database,
TResult? Function()? invalidInput,
TResult? Function(String field0)? network,
TResult? Function(String field0)? ur,
}) {
return invalidInput?.call();
}
@override
@optionalTypeArgs
TResult maybeWhen<TResult extends Object?>({
TResult Function(String field0)? cdk,
TResult Function(String field0)? database,
TResult Function()? invalidInput,
TResult Function(String field0)? network,
TResult Function(String field0)? ur,
required TResult orElse(),
}) {
if (invalidInput != null) {
return invalidInput();
}
return orElse();
}
@override
@optionalTypeArgs
TResult map<TResult extends Object?>({
required TResult Function(Error_Cdk value) cdk,
required TResult Function(Error_Database value) database,
required TResult Function(Error_InvalidInput value) invalidInput,
required TResult Function(Error_Network value) network,
required TResult Function(Error_Ur value) ur,
}) {
return invalidInput(this);
}
@override
@optionalTypeArgs
TResult? mapOrNull<TResult extends Object?>({
TResult? Function(Error_Cdk value)? cdk,
TResult? Function(Error_Database value)? database,
TResult? Function(Error_InvalidInput value)? invalidInput,
TResult? Function(Error_Network value)? network,
TResult? Function(Error_Ur value)? ur,
}) {
return invalidInput?.call(this);
}
@override
@optionalTypeArgs
TResult maybeMap<TResult extends Object?>({
TResult Function(Error_Cdk value)? cdk,
TResult Function(Error_Database value)? database,
TResult Function(Error_InvalidInput value)? invalidInput,
TResult Function(Error_Network value)? network,
TResult Function(Error_Ur value)? ur,
required TResult orElse(),
}) {
if (invalidInput != null) {
return invalidInput(this);
}
return orElse();
}
}
abstract class Error_InvalidInput extends Error {
const factory Error_InvalidInput() = _$Error_InvalidInputImpl;
const Error_InvalidInput._() : super._();
}
/// @nodoc
abstract class _$$Error_NetworkImplCopyWith<$Res> {
factory _$$Error_NetworkImplCopyWith(
_$Error_NetworkImpl value,
$Res Function(_$Error_NetworkImpl) then,
) = __$$Error_NetworkImplCopyWithImpl<$Res>;
@useResult
$Res call({String field0});
}
/// @nodoc
class __$$Error_NetworkImplCopyWithImpl<$Res>
extends _$ErrorCopyWithImpl<$Res, _$Error_NetworkImpl>
implements _$$Error_NetworkImplCopyWith<$Res> {
__$$Error_NetworkImplCopyWithImpl(
_$Error_NetworkImpl _value,
$Res Function(_$Error_NetworkImpl) _then,
) : super(_value, _then);
/// Create a copy of Error
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline')
@override
$Res call({Object? field0 = null}) {
return _then(
_$Error_NetworkImpl(
null == field0
? _value.field0
: field0 // ignore: cast_nullable_to_non_nullable
as String,
),
);
}
}
/// @nodoc
class _$Error_NetworkImpl extends Error_Network {
const _$Error_NetworkImpl(this.field0) : super._();
@override
final String field0;
@override
String toString() {
return 'Error.network(field0: $field0)';
}
@override
bool operator ==(Object other) {
return identical(this, other) ||
(other.runtimeType == runtimeType &&
other is _$Error_NetworkImpl &&
(identical(other.field0, field0) || other.field0 == field0));
}
@override
int get hashCode => Object.hash(runtimeType, field0);
/// Create a copy of Error
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@override
@pragma('vm:prefer-inline')
_$$Error_NetworkImplCopyWith<_$Error_NetworkImpl> get copyWith =>
__$$Error_NetworkImplCopyWithImpl<_$Error_NetworkImpl>(this, _$identity);
@override
@optionalTypeArgs
TResult when<TResult extends Object?>({
required TResult Function(String field0) cdk,
required TResult Function(String field0) database,
required TResult Function() invalidInput,
required TResult Function(String field0) network,
required TResult Function(String field0) ur,
}) {
return network(field0);
}
@override
@optionalTypeArgs
TResult? whenOrNull<TResult extends Object?>({
TResult? Function(String field0)? cdk,
TResult? Function(String field0)? database,
TResult? Function()? invalidInput,
TResult? Function(String field0)? network,
TResult? Function(String field0)? ur,
}) {
return network?.call(field0);
}
@override
@optionalTypeArgs
TResult maybeWhen<TResult extends Object?>({
TResult Function(String field0)? cdk,
TResult Function(String field0)? database,
TResult Function()? invalidInput,
TResult Function(String field0)? network,
TResult Function(String field0)? ur,
required TResult orElse(),
}) {
if (network != null) {
return network(field0);
}
return orElse();
}
@override
@optionalTypeArgs
TResult map<TResult extends Object?>({
required TResult Function(Error_Cdk value) cdk,
required TResult Function(Error_Database value) database,
required TResult Function(Error_InvalidInput value) invalidInput,
required TResult Function(Error_Network value) network,
required TResult Function(Error_Ur value) ur,
}) {
return network(this);
}
@override
@optionalTypeArgs
TResult? mapOrNull<TResult extends Object?>({
TResult? Function(Error_Cdk value)? cdk,
TResult? Function(Error_Database value)? database,
TResult? Function(Error_InvalidInput value)? invalidInput,
TResult? Function(Error_Network value)? network,
TResult? Function(Error_Ur value)? ur,
}) {
return network?.call(this);
}
@override
@optionalTypeArgs
TResult maybeMap<TResult extends Object?>({
TResult Function(Error_Cdk value)? cdk,
TResult Function(Error_Database value)? database,
TResult Function(Error_InvalidInput value)? invalidInput,
TResult Function(Error_Network value)? network,
TResult Function(Error_Ur value)? ur,
required TResult orElse(),
}) {
if (network != null) {
return network(this);
}
return orElse();
}
}
abstract class Error_Network extends Error {
const factory Error_Network(final String field0) = _$Error_NetworkImpl;
const Error_Network._() : super._();
String get field0;
/// Create a copy of Error
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
_$$Error_NetworkImplCopyWith<_$Error_NetworkImpl> get copyWith =>
throw _privateConstructorUsedError;
}
/// @nodoc
abstract class _$$Error_UrImplCopyWith<$Res> {
factory _$$Error_UrImplCopyWith(
_$Error_UrImpl value,
$Res Function(_$Error_UrImpl) then,
) = __$$Error_UrImplCopyWithImpl<$Res>;
@useResult
$Res call({String field0});
}
/// @nodoc
class __$$Error_UrImplCopyWithImpl<$Res>
extends _$ErrorCopyWithImpl<$Res, _$Error_UrImpl>
implements _$$Error_UrImplCopyWith<$Res> {
__$$Error_UrImplCopyWithImpl(
_$Error_UrImpl _value,
$Res Function(_$Error_UrImpl) _then,
) : super(_value, _then);
/// Create a copy of Error
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline')
@override
$Res call({Object? field0 = null}) {
return _then(
_$Error_UrImpl(
null == field0
? _value.field0
: field0 // ignore: cast_nullable_to_non_nullable
as String,
),
);
}
}
/// @nodoc
class _$Error_UrImpl extends Error_Ur {
const _$Error_UrImpl(this.field0) : super._();
@override
final String field0;
@override
String toString() {
return 'Error.ur(field0: $field0)';
}
@override
bool operator ==(Object other) {
return identical(this, other) ||
(other.runtimeType == runtimeType &&
other is _$Error_UrImpl &&
(identical(other.field0, field0) || other.field0 == field0));
}
@override
int get hashCode => Object.hash(runtimeType, field0);
/// Create a copy of Error
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@override
@pragma('vm:prefer-inline')
_$$Error_UrImplCopyWith<_$Error_UrImpl> get copyWith =>
__$$Error_UrImplCopyWithImpl<_$Error_UrImpl>(this, _$identity);
@override
@optionalTypeArgs
TResult when<TResult extends Object?>({
required TResult Function(String field0) cdk,
required TResult Function(String field0) database,
required TResult Function() invalidInput,
required TResult Function(String field0) network,
required TResult Function(String field0) ur,
}) {
return ur(field0);
}
@override
@optionalTypeArgs
TResult? whenOrNull<TResult extends Object?>({
TResult? Function(String field0)? cdk,
TResult? Function(String field0)? database,
TResult? Function()? invalidInput,
TResult? Function(String field0)? network,
TResult? Function(String field0)? ur,
}) {
return ur?.call(field0);
}
@override
@optionalTypeArgs
TResult maybeWhen<TResult extends Object?>({
TResult Function(String field0)? cdk,
TResult Function(String field0)? database,
TResult Function()? invalidInput,
TResult Function(String field0)? network,
TResult Function(String field0)? ur,
required TResult orElse(),
}) {
if (ur != null) {
return ur(field0);
}
return orElse();
}
@override
@optionalTypeArgs
TResult map<TResult extends Object?>({
required TResult Function(Error_Cdk value) cdk,
required TResult Function(Error_Database value) database,
required TResult Function(Error_InvalidInput value) invalidInput,
required TResult Function(Error_Network value) network,
required TResult Function(Error_Ur value) ur,
}) {
return ur(this);
}
@override
@optionalTypeArgs
TResult? mapOrNull<TResult extends Object?>({
TResult? Function(Error_Cdk value)? cdk,
TResult? Function(Error_Database value)? database,
TResult? Function(Error_InvalidInput value)? invalidInput,
TResult? Function(Error_Network value)? network,
TResult? Function(Error_Ur value)? ur,
}) {
return ur?.call(this);
}
@override
@optionalTypeArgs
TResult maybeMap<TResult extends Object?>({
TResult Function(Error_Cdk value)? cdk,
TResult Function(Error_Database value)? database,
TResult Function(Error_InvalidInput value)? invalidInput,
TResult Function(Error_Network value)? network,
TResult Function(Error_Ur value)? ur,
required TResult orElse(),
}) {
if (ur != null) {
return ur(this);
}
return orElse();
}
}
abstract class Error_Ur extends Error {
const factory Error_Ur(final String field0) = _$Error_UrImpl;
const Error_Ur._() : super._();
String get field0;
/// Create a copy of Error
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
_$$Error_UrImplCopyWith<_$Error_UrImpl> get copyWith =>
throw _privateConstructorUsedError;
}
+20
View File
@@ -0,0 +1,20 @@
// This file is automatically generated, so please do not edit it.
// @generated by `flutter_rust_bridge`@ 2.11.1.
// ignore_for_file: invalid_use_of_internal_member, unused_import, unnecessary_import
import '../frb_generated.dart';
import 'error.dart';
import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated.dart';
/// Generate a new 12-word BIP39 mnemonic
String generateMnemonic() =>
RustLib.instance.api.crateApiKeysGenerateMnemonic();
/// Convert a mnemonic to a 64-byte seed
Uint8List mnemonicToSeed({required String mnemonic}) =>
RustLib.instance.api.crateApiKeysMnemonicToSeed(mnemonic: mnemonic);
/// Derive the public key (hex) from a secret key (hex)
String getPubKey({required String secret}) =>
RustLib.instance.api.crateApiKeysGetPubKey(secret: secret);
+309
View File
@@ -0,0 +1,309 @@
// This file is automatically generated, so please do not edit it.
// @generated by `flutter_rust_bridge`@ 2.11.1.
// ignore_for_file: invalid_use_of_internal_member, unused_import, unnecessary_import
import '../frb_generated.dart';
import 'error.dart';
import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated.dart';
// These function are ignored because they are on traits that is not defined in current crate (put an empty `#[frb]` on it to unignore): `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`
/// Fetch full mint info from a mint URL
Future<MintInfo> getMintInfo({required String mintUrl}) =>
RustLib.instance.api.crateApiMintInfoGetMintInfo(mintUrl: mintUrl);
/// Ping a mint to check if it's reachable (replaces Dart HTTP call)
Future<bool> pingMint({required String mintUrl}) =>
RustLib.instance.api.crateApiMintInfoPingMint(mintUrl: mintUrl);
/// Fetch keysets from a mint (replaces Dart HTTP call)
Future<List<KeysetInfo>> fetchKeysets({required String mintUrl}) =>
RustLib.instance.api.crateApiMintInfoFetchKeysets(mintUrl: mintUrl);
class ContactInfo {
final String method;
final String info;
const ContactInfo({required this.method, required this.info});
@override
int get hashCode => method.hashCode ^ info.hashCode;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is ContactInfo &&
runtimeType == other.runtimeType &&
method == other.method &&
info == other.info;
}
/// Keyset info returned by fetch_keysets
class KeysetInfo {
final String id;
final String unit;
final bool active;
const KeysetInfo({
required this.id,
required this.unit,
required this.active,
});
@override
int get hashCode => id.hashCode ^ unit.hashCode ^ active.hashCode;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is KeysetInfo &&
runtimeType == other.runtimeType &&
id == other.id &&
unit == other.unit &&
active == other.active;
}
class MeltMethodSettings {
final String method;
final String unit;
final BigInt? minAmount;
final BigInt? maxAmount;
const MeltMethodSettings({
required this.method,
required this.unit,
this.minAmount,
this.maxAmount,
});
@override
int get hashCode =>
method.hashCode ^ unit.hashCode ^ minAmount.hashCode ^ maxAmount.hashCode;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is MeltMethodSettings &&
runtimeType == other.runtimeType &&
method == other.method &&
unit == other.unit &&
minAmount == other.minAmount &&
maxAmount == other.maxAmount;
}
class MintInfo {
final String? name;
final String? pubkey;
final MintVersion? version;
final String? description;
final String? descriptionLong;
final List<ContactInfo>? contact;
final Nuts nuts;
final String? iconUrl;
final List<String>? urls;
final String? motd;
final BigInt? time;
final String? tosUrl;
const MintInfo({
this.name,
this.pubkey,
this.version,
this.description,
this.descriptionLong,
this.contact,
required this.nuts,
this.iconUrl,
this.urls,
this.motd,
this.time,
this.tosUrl,
});
@override
int get hashCode =>
name.hashCode ^
pubkey.hashCode ^
version.hashCode ^
description.hashCode ^
descriptionLong.hashCode ^
contact.hashCode ^
nuts.hashCode ^
iconUrl.hashCode ^
urls.hashCode ^
motd.hashCode ^
time.hashCode ^
tosUrl.hashCode;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is MintInfo &&
runtimeType == other.runtimeType &&
name == other.name &&
pubkey == other.pubkey &&
version == other.version &&
description == other.description &&
descriptionLong == other.descriptionLong &&
contact == other.contact &&
nuts == other.nuts &&
iconUrl == other.iconUrl &&
urls == other.urls &&
motd == other.motd &&
time == other.time &&
tosUrl == other.tosUrl;
}
class MintMethodSettings {
final String method;
final String unit;
final BigInt? minAmount;
final BigInt? maxAmount;
const MintMethodSettings({
required this.method,
required this.unit,
this.minAmount,
this.maxAmount,
});
@override
int get hashCode =>
method.hashCode ^ unit.hashCode ^ minAmount.hashCode ^ maxAmount.hashCode;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is MintMethodSettings &&
runtimeType == other.runtimeType &&
method == other.method &&
unit == other.unit &&
minAmount == other.minAmount &&
maxAmount == other.maxAmount;
}
class MintVersion {
final String name;
final String version;
const MintVersion({required this.name, required this.version});
@override
int get hashCode => name.hashCode ^ version.hashCode;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is MintVersion &&
runtimeType == other.runtimeType &&
name == other.name &&
version == other.version;
}
class Nut04Settings {
final List<MintMethodSettings> methods;
final bool disabled;
const Nut04Settings({required this.methods, required this.disabled});
@override
int get hashCode => methods.hashCode ^ disabled.hashCode;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is Nut04Settings &&
runtimeType == other.runtimeType &&
methods == other.methods &&
disabled == other.disabled;
}
class Nut05Settings {
final List<MeltMethodSettings> methods;
final bool disabled;
const Nut05Settings({required this.methods, required this.disabled});
@override
int get hashCode => methods.hashCode ^ disabled.hashCode;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is Nut05Settings &&
runtimeType == other.runtimeType &&
methods == other.methods &&
disabled == other.disabled;
}
class Nuts {
final Nut04Settings nut04;
final Nut05Settings nut05;
final SupportedSettings nut07;
final SupportedSettings nut08;
final SupportedSettings nut09;
final SupportedSettings nut10;
final SupportedSettings nut11;
final SupportedSettings nut12;
final SupportedSettings nut14;
final SupportedSettings nut20;
const Nuts({
required this.nut04,
required this.nut05,
required this.nut07,
required this.nut08,
required this.nut09,
required this.nut10,
required this.nut11,
required this.nut12,
required this.nut14,
required this.nut20,
});
@override
int get hashCode =>
nut04.hashCode ^
nut05.hashCode ^
nut07.hashCode ^
nut08.hashCode ^
nut09.hashCode ^
nut10.hashCode ^
nut11.hashCode ^
nut12.hashCode ^
nut14.hashCode ^
nut20.hashCode;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is Nuts &&
runtimeType == other.runtimeType &&
nut04 == other.nut04 &&
nut05 == other.nut05 &&
nut07 == other.nut07 &&
nut08 == other.nut08 &&
nut09 == other.nut09 &&
nut10 == other.nut10 &&
nut11 == other.nut11 &&
nut12 == other.nut12 &&
nut14 == other.nut14 &&
nut20 == other.nut20;
}
class SupportedSettings {
final bool supported;
const SupportedSettings({required this.supported});
@override
int get hashCode => supported.hashCode;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is SupportedSettings &&
runtimeType == other.runtimeType &&
supported == other.supported;
}
+62
View File
@@ -0,0 +1,62 @@
// This file is automatically generated, so please do not edit it.
// @generated by `flutter_rust_bridge`@ 2.11.1.
// ignore_for_file: invalid_use_of_internal_member, unused_import, unnecessary_import
import '../frb_generated.dart';
import 'error.dart';
import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated.dart';
// These functions are ignored because they are not marked as `pub`: `proofs`
// These function are ignored because they are on traits that is not defined in current crate (put an empty `#[frb]` on it to unignore): `fmt`, `from_str`, `try_from`, `try_into`, `try_into`
List<String> encodeQrToken({required Token token, BigInt? maxFragmentLength}) =>
RustLib.instance.api.crateApiTokenEncodeQrToken(
token: token,
maxFragmentLength: maxFragmentLength,
);
// Rust type: RustOpaqueMoi<flutter_rust_bridge::for_generated::RustAutoOpaqueInner<TokenDecoder>>
abstract class TokenDecoder implements RustOpaqueInterface {
bool isComplete();
factory TokenDecoder() => RustLib.instance.api.crateApiTokenTokenDecoderNew();
void receive({required String input});
Token? value();
}
class Token {
final String encoded;
final Uint8List? raw;
final BigInt amount;
final String mintUrl;
const Token({
required this.encoded,
this.raw,
required this.amount,
required this.mintUrl,
});
static Token fromRawBytes({required List<int> raw}) =>
RustLib.instance.api.crateApiTokenTokenFromRawBytes(raw: raw);
static Token parse({required String encoded}) =>
RustLib.instance.api.crateApiTokenTokenParse(encoded: encoded);
@override
int get hashCode =>
encoded.hashCode ^ raw.hashCode ^ amount.hashCode ^ mintUrl.hashCode;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is Token &&
runtimeType == other.runtimeType &&
encoded == other.encoded &&
raw == other.raw &&
amount == other.amount &&
mintUrl == other.mintUrl;
}
+292
View File
@@ -0,0 +1,292 @@
// This file is automatically generated, so please do not edit it.
// @generated by `flutter_rust_bridge`@ 2.11.1.
// ignore_for_file: invalid_use_of_internal_member, unused_import, unnecessary_import
import '../frb_generated.dart';
import 'error.dart';
import 'mint_info.dart';
import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated.dart';
import 'token.dart';
// These functions are ignored because they are not marked as `pub`: `mint_url`, `unit`, `update_balance_streams`
// 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<PreparedSend>>
abstract class PreparedSend implements RustOpaqueInterface {
BigInt get amount;
BigInt get fee;
BigInt get sendFee;
BigInt get swapFee;
set amount(BigInt amount);
set fee(BigInt fee);
set sendFee(BigInt sendFee);
set swapFee(BigInt swapFee);
}
// Rust type: RustOpaqueMoi<flutter_rust_bridge::for_generated::RustAutoOpaqueInner<Wallet>>
abstract class Wallet implements RustOpaqueInterface {
String get mintUrl;
String get unit;
set mintUrl(String mintUrl);
set unit(String unit);
Future<BigInt> balance();
Future<void> cancelSend({required PreparedSend send});
Future<void> checkAllMintQuotes();
Future<void> checkPendingTransactions();
Future<void> finalizePendingMelts();
Future<MintInfo?> getMint();
Future<bool> isTokenSpent({required Token token});
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});
factory Wallet({
required String mintUrl,
required String unit,
required String mnemonic,
BigInt? targetProofCount,
required WalletDatabase db,
}) => RustLib.instance.api.crateApiWalletWalletNew(
mintUrl: mintUrl,
unit: unit,
mnemonic: mnemonic,
targetProofCount: targetProofCount,
db: db,
);
Future<PreparedSend> prepareSend({required BigInt amount, SendOptions? opts});
Future<BigInt> receive({required Token token, ReceiveOptions? opts});
Future<void> recoverIncompleteSagas();
Future<void> restore();
Future<Token> send({
required PreparedSend send,
String? memo,
bool? includeMemo,
});
Stream<BigInt> streamBalance();
}
// Rust type: RustOpaqueMoi<flutter_rust_bridge::for_generated::RustAutoOpaqueInner<WalletDatabase>>
abstract class WalletDatabase implements RustOpaqueInterface {
String get path;
set path(String path);
static Future<WalletDatabase> newInstance({required String path}) =>
RustLib.instance.api.crateApiWalletWalletDatabaseNewInstance(path: path);
Future<void> removeMint({required String mintUrl});
}
class MeltQuote {
final String id;
final String request;
final BigInt amount;
final BigInt feeReserve;
final BigInt expiry;
const MeltQuote({
required this.id,
required this.request,
required this.amount,
required this.feeReserve,
required this.expiry,
});
@override
int get hashCode =>
id.hashCode ^
request.hashCode ^
amount.hashCode ^
feeReserve.hashCode ^
expiry.hashCode;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is MeltQuote &&
runtimeType == other.runtimeType &&
id == other.id &&
request == other.request &&
amount == other.amount &&
feeReserve == other.feeReserve &&
expiry == other.expiry;
}
class MintQuote {
final String id;
final String request;
final BigInt? amount;
final BigInt? expiry;
final MintQuoteState state;
final Token? token;
final String? error;
const MintQuote({
required this.id,
required this.request,
this.amount,
this.expiry,
required this.state,
this.token,
this.error,
});
@override
int get hashCode =>
id.hashCode ^
request.hashCode ^
amount.hashCode ^
expiry.hashCode ^
state.hashCode ^
token.hashCode ^
error.hashCode;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is MintQuote &&
runtimeType == other.runtimeType &&
id == other.id &&
request == other.request &&
amount == other.amount &&
expiry == other.expiry &&
state == other.state &&
token == other.token &&
error == other.error;
}
enum MintQuoteState { unpaid, paid, issued, error }
class ReceiveOptions {
final List<String>? signingKeys;
final List<String>? preimages;
const ReceiveOptions({this.signingKeys, this.preimages});
static Future<ReceiveOptions> default_() =>
RustLib.instance.api.crateApiWalletReceiveOptionsDefault();
@override
int get hashCode => signingKeys.hashCode ^ preimages.hashCode;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is ReceiveOptions &&
runtimeType == other.runtimeType &&
signingKeys == other.signingKeys &&
preimages == other.preimages;
}
class SendOptions {
final String? pubkey;
final bool? includeFee;
const SendOptions({this.pubkey, this.includeFee});
static Future<SendOptions> default_() =>
RustLib.instance.api.crateApiWalletSendOptionsDefault();
@override
int get hashCode => pubkey.hashCode ^ includeFee.hashCode;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is SendOptions &&
runtimeType == other.runtimeType &&
pubkey == other.pubkey &&
includeFee == other.includeFee;
}
class Transaction {
final String id;
final String mintUrl;
final TransactionDirection direction;
final BigInt amount;
final BigInt fee;
final String unit;
final List<String> ys;
final BigInt timestamp;
final String? memo;
final Map<String, String> metadata;
final TransactionStatus status;
const Transaction({
required this.id,
required this.mintUrl,
required this.direction,
required this.amount,
required this.fee,
required this.unit,
required this.ys,
required this.timestamp,
this.memo,
required this.metadata,
required this.status,
});
@override
int get hashCode =>
id.hashCode ^
mintUrl.hashCode ^
direction.hashCode ^
amount.hashCode ^
fee.hashCode ^
unit.hashCode ^
ys.hashCode ^
timestamp.hashCode ^
memo.hashCode ^
metadata.hashCode ^
status.hashCode;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is Transaction &&
runtimeType == other.runtimeType &&
id == other.id &&
mintUrl == other.mintUrl &&
direction == other.direction &&
amount == other.amount &&
fee == other.fee &&
unit == other.unit &&
ys == other.ys &&
timestamp == other.timestamp &&
memo == other.memo &&
metadata == other.metadata &&
status == other.status;
}
enum TransactionDirection { incoming, outgoing }
enum TransactionStatus { pending, settled }
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
+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';
@@ -7,6 +7,7 @@ import Foundation
import flutter_secure_storage_macos
import mobile_scanner
import package_info_plus
import path_provider_foundation
import share_plus
import shared_preferences_foundation
@@ -16,6 +17,7 @@ import url_launcher_macos
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
FlutterSecureStoragePlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStoragePlugin"))
MobileScannerPlugin.register(with: registry.registrar(forPlugin: "MobileScannerPlugin"))
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin"))
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
+340 -36
View File
@@ -1,6 +1,22 @@
# Generated by pub
# See https://dart.dev/tools/pub/glossary#lockfile
packages:
_fe_analyzer_shared:
dependency: transitive
description:
name: _fe_analyzer_shared
sha256: da0d9209ca76bde579f2da330aeb9df62b6319c834fa7baae052021b0462401f
url: "https://pub.dev"
source: hosted
version: "85.0.0"
analyzer:
dependency: transitive
description:
name: analyzer
sha256: "974859dc0ff5f37bc4313244b3218c791810d03ab3470a579580279ba971a48d"
url: "https://pub.dev"
source: hosted
version: "7.7.1"
ansicolor:
dependency: transitive
description:
@@ -41,6 +57,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.2.2"
bip32:
dependency: "direct main"
description:
name: bip32
sha256: "54787cd7a111e9d37394aabbf53d1fc5e2e0e0af2cd01c459147a97c0e3f8a97"
url: "https://pub.dev"
source: hosted
version: "2.0.0"
boolean_selector:
dependency: transitive
description:
@@ -49,6 +73,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.1.1"
bs58check:
dependency: transitive
description:
name: bs58check
sha256: c4a164d42b25c2f6bc88a8beccb9fc7d01440f3c60ba23663a20a70faf484ea9
url: "https://pub.dev"
source: hosted
version: "1.0.2"
build:
dependency: transitive
description:
name: build
sha256: "51dc711996cbf609b90cbe5b335bbce83143875a9d58e4b5c6d3c4f684d3dda7"
url: "https://pub.dev"
source: hosted
version: "2.5.4"
build_cli_annotations:
dependency: transitive
description:
@@ -57,6 +97,62 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.1.0"
build_config:
dependency: transitive
description:
name: build_config
sha256: "4ae2de3e1e67ea270081eaee972e1bd8f027d459f249e0f1186730784c2e7e33"
url: "https://pub.dev"
source: hosted
version: "1.1.2"
build_daemon:
dependency: transitive
description:
name: build_daemon
sha256: bf05f6e12cfea92d3c09308d7bcdab1906cd8a179b023269eed00c071004b957
url: "https://pub.dev"
source: hosted
version: "4.1.1"
build_resolvers:
dependency: transitive
description:
name: build_resolvers
sha256: ee4257b3f20c0c90e72ed2b57ad637f694ccba48839a821e87db762548c22a62
url: "https://pub.dev"
source: hosted
version: "2.5.4"
build_runner:
dependency: "direct dev"
description:
name: build_runner
sha256: "382a4d649addbfb7ba71a3631df0ec6a45d5ab9b098638144faf27f02778eb53"
url: "https://pub.dev"
source: hosted
version: "2.5.4"
build_runner_core:
dependency: transitive
description:
name: build_runner_core
sha256: "85fbbb1036d576d966332a3f5ce83f2ce66a40bea1a94ad2d5fc29a19a0d3792"
url: "https://pub.dev"
source: hosted
version: "9.1.2"
built_collection:
dependency: transitive
description:
name: built_collection
sha256: "376e3dd27b51ea877c28d525560790aee2e6fbb5f20e2f85d5081027d94e2100"
url: "https://pub.dev"
source: hosted
version: "5.1.1"
built_value:
dependency: transitive
description:
name: built_value
sha256: "0730c18c770d05636a8f945c32a4d7d81cb6e0f0148c8db4ad12e7748f7e49af"
url: "https://pub.dev"
source: hosted
version: "8.12.5"
cbor:
dependency: "direct main"
description:
@@ -70,7 +166,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"
@@ -78,10 +174,10 @@ packages:
dependency: transitive
description:
name: characters
sha256: "04a925763edad70e8443c99234dc3328f442e811f1d8fd1a72f1c8ad0f69a605"
sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b
url: "https://pub.dev"
source: hosted
version: "1.3.0"
version: "1.4.1"
checked_yaml:
dependency: transitive
description:
@@ -102,18 +198,26 @@ packages:
dependency: transitive
description:
name: clock
sha256: cb6d7f03e1de671e34607e909a7213e31d7752be4fb66a86d29fe1eb14bfb5cf
sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b
url: "https://pub.dev"
source: hosted
version: "1.1.1"
version: "1.1.2"
code_builder:
dependency: transitive
description:
name: code_builder
sha256: "6a6cab2ba4680d6423f34a9b972a4c9a94ebe1b62ecec4e1a1f2cba91fd1319d"
url: "https://pub.dev"
source: hosted
version: "4.11.1"
collection:
dependency: transitive
description:
name: collection
sha256: a1ace0a119f20aabc852d165077c036cd864315bd99b7eaa10a60100341941bf
sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76"
url: "https://pub.dev"
source: hosted
version: "1.19.0"
version: "1.19.1"
convert:
dependency: transitive
description:
@@ -154,14 +258,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.0.8"
dart_style:
dependency: transitive
description:
name: dart_style
sha256: "8a0e5fba27e8ee025d2ffb4ee820b4e6e2cf5e4246a6b1a477eb66866947e0bb"
url: "https://pub.dev"
source: hosted
version: "3.1.1"
fake_async:
dependency: transitive
description:
name: fake_async
sha256: "511392330127add0b769b75a987850d136345d9227c6b94c96a04cf4a391bf78"
sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44"
url: "https://pub.dev"
source: hosted
version: "1.3.1"
version: "1.3.3"
ffi:
dependency: transitive
description:
@@ -221,7 +333,7 @@ packages:
source: hosted
version: "2.4.4"
flutter_rust_bridge:
dependency: transitive
dependency: "direct main"
description:
name: flutter_rust_bridge
sha256: "37ef40bc6f863652e865f0b2563ea07f0d3c58d8efad803cc01933a4b2ee067e"
@@ -286,14 +398,38 @@ packages:
description: flutter
source: sdk
version: "0.0.0"
freezed:
dependency: "direct dev"
description:
name: freezed
sha256: "59a584c24b3acdc5250bb856d0d3e9c0b798ed14a4af1ddb7dc1c7b41df91c9c"
url: "https://pub.dev"
source: hosted
version: "2.5.8"
freezed_annotation:
dependency: transitive
dependency: "direct main"
description:
name: freezed_annotation
sha256: c2e2d632dd9b8a2b7751117abcfc2b4888ecfe181bd9fca7170d9ef02e595fe2
url: "https://pub.dev"
source: hosted
version: "2.4.4"
frontend_server_client:
dependency: transitive
description:
name: frontend_server_client
sha256: f64a0333a82f30b0cca061bc3d143813a486dc086b574bfb233b7c1372427694
url: "https://pub.dev"
source: hosted
version: "4.0.0"
glob:
dependency: transitive
description:
name: glob
sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de
url: "https://pub.dev"
source: hosted
version: "2.1.3"
google_fonts:
dependency: "direct main"
description:
@@ -302,6 +438,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "6.3.0"
graphs:
dependency: transitive
description:
name: graphs
sha256: "741bbf84165310a68ff28fe9e727332eef1407342fca52759cb21ad8177bb8d0"
url: "https://pub.dev"
source: hosted
version: "2.3.2"
hex:
dependency: transitive
description:
@@ -326,6 +470,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.6.0"
http_multi_server:
dependency: transitive
description:
name: http_multi_server
sha256: aa6199f908078bb1c5efb8d8638d4ae191aac11b311132c3ef48ce352fb52ef8
url: "https://pub.dev"
source: hosted
version: "3.2.2"
http_parser:
dependency: transitive
description:
@@ -354,10 +506,18 @@ packages:
dependency: "direct main"
description:
name: intl
sha256: d6f56758b7d3014a48af9701c085700aac781a92a87a62b1333b46d8879661cf
sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5"
url: "https://pub.dev"
source: hosted
version: "0.19.0"
version: "0.20.2"
io:
dependency: transitive
description:
name: io
sha256: dfd5a80599cf0165756e3181807ed3e77daf6dd4137caaad72d0b7931597650b
url: "https://pub.dev"
source: hosted
version: "1.0.5"
js:
dependency: transitive
description:
@@ -378,26 +538,26 @@ packages:
dependency: transitive
description:
name: leak_tracker
sha256: "7bb2830ebd849694d1ec25bf1f44582d6ac531a57a365a803a6034ff751d2d06"
sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de"
url: "https://pub.dev"
source: hosted
version: "10.0.7"
version: "11.0.2"
leak_tracker_flutter_testing:
dependency: transitive
description:
name: leak_tracker_flutter_testing
sha256: "9491a714cca3667b60b5c420da8217e6de0d1ba7a5ec322fab01758f6998f379"
sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1"
url: "https://pub.dev"
source: hosted
version: "3.0.8"
version: "3.0.10"
leak_tracker_testing:
dependency: transitive
description:
name: leak_tracker_testing
sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3"
sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1"
url: "https://pub.dev"
source: hosted
version: "3.0.1"
version: "3.0.2"
lints:
dependency: transitive
description:
@@ -406,6 +566,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "5.1.1"
logging:
dependency: transitive
description:
name: logging
sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61
url: "https://pub.dev"
source: hosted
version: "1.3.0"
lucide_icons:
dependency: "direct main"
description:
@@ -418,26 +586,26 @@ packages:
dependency: transitive
description:
name: matcher
sha256: d2323aa2060500f906aa31a895b4030b6da3ebdcc5619d14ce1aada65cd161cb
sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861
url: "https://pub.dev"
source: hosted
version: "0.12.16+1"
version: "0.12.19"
material_color_utilities:
dependency: transitive
description:
name: material_color_utilities
sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec
sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b"
url: "https://pub.dev"
source: hosted
version: "0.11.1"
version: "0.13.0"
meta:
dependency: transitive
description:
name: meta
sha256: bdb68674043280c3428e9ec998512fb681678676b3c54e773629ffe74419f8c7
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
url: "https://pub.dev"
source: hosted
version: "1.15.0"
version: "1.17.0"
mime:
dependency: transitive
description:
@@ -454,6 +622,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "5.2.3"
ndef_record:
dependency: "direct main"
description:
name: ndef_record
sha256: "210ffb12284961cab9e44b99462143316d9a20cd992581170706069ef77d74a6"
url: "https://pub.dev"
source: hosted
version: "1.4.2"
nested:
dependency: transitive
description:
@@ -462,14 +638,46 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.0.0"
nfc_manager:
dependency: "direct main"
description:
name: nfc_manager
sha256: "24c78b0e5702da53e7f8794d073624c0bee7cd99924f257cbd11f5d1c5866879"
url: "https://pub.dev"
source: hosted
version: "4.1.1"
package_config:
dependency: transitive
description:
name: package_config
sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc
url: "https://pub.dev"
source: hosted
version: "2.2.0"
package_info_plus:
dependency: "direct main"
description:
name: package_info_plus
sha256: f69da0d3189a4b4ceaeb1a3defb0f329b3b352517f52bed4290f83d4f06bc08d
url: "https://pub.dev"
source: hosted
version: "9.0.0"
package_info_plus_platform_interface:
dependency: transitive
description:
name: package_info_plus_platform_interface
sha256: "202a487f08836a592a6bd4f901ac69b3a8f146af552bbd14407b6b41e1c3f086"
url: "https://pub.dev"
source: hosted
version: "3.2.1"
path:
dependency: transitive
description:
name: path
sha256: "087ce49c3f0dc39180befefc60fdb4acd8f8620e5682fe2476afd0b3688bb4af"
sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5"
url: "https://pub.dev"
source: hosted
version: "1.9.0"
version: "1.9.1"
path_provider:
dependency: "direct main"
description:
@@ -542,6 +750,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.1.8"
pointycastle:
dependency: transitive
description:
name: pointycastle
sha256: "4be0097fcf3fd3e8449e53730c631200ebc7b88016acecab2b0da2f0149222fe"
url: "https://pub.dev"
source: hosted
version: "3.9.1"
pool:
dependency: transitive
description:
name: pool
sha256: "978783255c543aa3586a1b3c21f6e9d720eb315376a915872c61ef8b5c20177d"
url: "https://pub.dev"
source: hosted
version: "1.5.2"
posix:
dependency: transitive
description:
@@ -558,6 +782,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "6.1.5+1"
pub_semver:
dependency: transitive
description:
name: pub_semver
sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585"
url: "https://pub.dev"
source: hosted
version: "2.2.0"
pubspec_parse:
dependency: transitive
description:
name: pubspec_parse
sha256: "0560ba233314abbed0a48a2956f7f022cce7c3e1e73df540277da7544cad4082"
url: "https://pub.dev"
source: hosted
version: "1.5.0"
qr:
dependency: transitive
description:
@@ -646,11 +886,35 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.4.1"
shelf:
dependency: transitive
description:
name: shelf
sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12
url: "https://pub.dev"
source: hosted
version: "1.4.2"
shelf_web_socket:
dependency: transitive
description:
name: shelf_web_socket
sha256: "3632775c8e90d6c9712f883e633716432a27758216dfb61bd86a8321c0580925"
url: "https://pub.dev"
source: hosted
version: "3.0.0"
sky_engine:
dependency: transitive
description: flutter
source: sdk
version: "0.0.0"
source_gen:
dependency: transitive
description:
name: source_gen
sha256: "35c8150ece9e8c8d263337a265153c3329667640850b9304861faea59fc98f6b"
url: "https://pub.dev"
source: hosted
version: "2.0.0"
source_span:
dependency: transitive
description:
@@ -719,18 +983,26 @@ packages:
dependency: transitive
description:
name: stack_trace
sha256: "9f47fd3630d76be3ab26f0ee06d213679aa425996925ff3feffdec504931c377"
sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1"
url: "https://pub.dev"
source: hosted
version: "1.12.0"
version: "1.12.1"
stream_channel:
dependency: transitive
description:
name: stream_channel
sha256: ba2aa5d8cc609d96bbb2899c28934f9e1af5cddbd60a827822ea467161eb54e7
sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d"
url: "https://pub.dev"
source: hosted
version: "2.1.2"
version: "2.1.4"
stream_transform:
dependency: transitive
description:
name: stream_transform
sha256: ad47125e588cfd37a9a7f86c7d6356dde8dfe89d071d293f80ca9e9273a33871
url: "https://pub.dev"
source: hosted
version: "2.1.1"
string_scanner:
dependency: transitive
description:
@@ -759,10 +1031,18 @@ packages:
dependency: transitive
description:
name: test_api
sha256: "664d3a9a64782fcdeb83ce9c6b39e78fd2971d4e37827b9b06c3aa1edc5e760c"
sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a"
url: "https://pub.dev"
source: hosted
version: "0.7.3"
version: "0.7.10"
timing:
dependency: transitive
description:
name: timing
sha256: "62ee18aca144e4a9f29d212f5a4c6a053be252b895ab14b5821996cff4ed90fe"
url: "https://pub.dev"
source: hosted
version: "1.0.2"
typed_data:
dependency: transitive
description:
@@ -855,10 +1135,10 @@ packages:
dependency: transitive
description:
name: vector_math
sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803"
sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b
url: "https://pub.dev"
source: hosted
version: "2.1.4"
version: "2.2.0"
vm_service:
dependency: transitive
description:
@@ -867,6 +1147,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "14.3.0"
watcher:
dependency: transitive
description:
name: watcher
sha256: "1398c9f081a753f9226febe8900fce8f7d0a67163334e1c94a2438339d79d635"
url: "https://pub.dev"
source: hosted
version: "1.2.1"
web:
dependency: transitive
description:
@@ -875,6 +1163,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.1.1"
web_socket:
dependency: transitive
description:
name: web_socket
sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c"
url: "https://pub.dev"
source: hosted
version: "1.0.1"
web_socket_channel:
dependency: transitive
description:
name: web_socket_channel
sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8
url: "https://pub.dev"
source: hosted
version: "3.0.3"
win32:
dependency: transitive
description:
@@ -908,5 +1212,5 @@ packages:
source: hosted
version: "3.1.3"
sdks:
dart: ">=3.6.0 <4.0.0"
dart: ">=3.11.0 <4.0.0"
flutter: ">=3.27.0"
+14 -2
View File
@@ -1,10 +1,10 @@
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
sdk: ^3.11.0
dependencies:
flutter:
@@ -56,12 +56,24 @@ dependencies:
# UUID generator for pending tokens
uuid: ^4.3.3
# NFC for token sharing
nfc_manager: ^4.1.1
ndef_record: ^1.4.2
# BIP32 for NIP-06 key derivation (P2PK)
bip32: ^2.0.0
package_info_plus: ^9.0.0
flutter_rust_bridge: 2.11.1
freezed_annotation: ^2.4.4
dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints: ^5.0.0
flutter_launcher_icons: ^0.14.3
flutter_native_splash: ^2.4.4
freezed: ^2.5.8
build_runner: ^2.5.4
# App Icon configuration
flutter_launcher_icons:
+3861
View File
File diff suppressed because it is too large Load Diff
+62
View File
@@ -0,0 +1,62 @@
[package]
name = "elcaju_core"
version = "0.1.0"
edition = "2024"
[lib]
crate-type = ["cdylib", "staticlib"]
[dependencies]
# Cashu — acceso directo, sin intermediarios
cdk = { version = "0.15.1", default-features = false, features = ["wallet"] }
cdk-common = { version = "0.15.1", default-features = false }
cdk-sqlite = { version = "0.15.1", default-features = false, features = ["wallet"] }
# Bridge Flutter <-> Rust
flutter_rust_bridge = { version = "=2.11.1", default-features = false, features = [
"anyhow",
"dart-opaque",
"portable-atomic",
"rust-async",
"thread-pool",
"wasm-start",
] }
# QR multiframe (bc-ur)
bc-ur = { version = "0.12.0", default-features = false }
# Mnemonic
bip39 = { version = "2.1", default-features = false, features = ["std"] }
# Entropy
getrandom = { version = "0.3", default-features = false, features = ["std"] }
# Async runtime
tokio = { version = "1", default-features = false }
# HTTP (para ping_mint, fetch_keysets)
reqwest = { version = "0.12", default-features = false, features = [
"deflate",
"gzip",
"json",
"rustls-tls",
"rustls-tls-native-roots",
] }
# Serialización
serde_json = { version = "1.0", default-features = false, features = ["std"] }
# UUID (for PreparedSend operation_id)
uuid = { version = "1", default-features = false }
# Logging
log = { version = "0.4", default-features = false }
[target.'cfg(target_os = "android")'.dependencies]
android_logger = "0.15"
[target.'cfg(any(target_os = "ios", target_os = "macos"))'.dependencies]
oslog = "0.2.0"
[lints.rust]
unexpected_cfgs = { level = "warn", check-cfg = ['cfg(frb_expand)'] }
+100
View File
@@ -0,0 +1,100 @@
use std::fmt;
/// Errores unificados de elcaju_core
#[derive(Debug)]
pub enum Error {
/// Error del protocolo Cashu (cdk)
Cdk(String),
/// Error de base de datos (SQLite)
Database(String),
/// Input inválido del usuario
InvalidInput,
/// Error de red (HTTP, conexión)
Network(String),
/// Error de UR (QR multiframe)
Ur(String),
}
impl fmt::Display for Error {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Error::Cdk(msg) => write!(f, "Cashu error: {msg}"),
Error::Database(msg) => write!(f, "Database error: {msg}"),
Error::InvalidInput => write!(f, "Invalid input"),
Error::Network(msg) => write!(f, "Network error: {msg}"),
Error::Ur(msg) => write!(f, "UR error: {msg}"),
}
}
}
impl std::error::Error for Error {}
// --- cdk errors ---
impl From<cdk::error::Error> for Error {
fn from(e: cdk::error::Error) -> Self {
Error::Cdk(e.to_string())
}
}
impl From<cdk::amount::Error> for Error {
fn from(e: cdk::amount::Error) -> Self {
Error::Cdk(e.to_string())
}
}
impl From<cdk::cdk_database::Error> for Error {
fn from(e: cdk::cdk_database::Error) -> Self {
Error::Database(e.to_string())
}
}
impl From<cdk::mint_url::Error> for Error {
fn from(e: cdk::mint_url::Error) -> Self {
Error::Network(e.to_string())
}
}
impl From<cdk::nuts::nut00::Error> for Error {
fn from(e: cdk::nuts::nut00::Error) -> Self {
Error::Cdk(e.to_string())
}
}
impl From<cdk::nuts::nut01::Error> for Error {
fn from(e: cdk::nuts::nut01::Error) -> Self {
Error::Cdk(e.to_string())
}
}
impl From<cdk::nuts::nut11::Error> for Error {
fn from(e: cdk::nuts::nut11::Error) -> Self {
Error::Cdk(e.to_string())
}
}
// --- external errors ---
impl From<bc_ur::Error> for Error {
fn from(e: bc_ur::Error) -> Self {
Error::Ur(e.to_string())
}
}
impl From<reqwest::Error> for Error {
fn from(e: reqwest::Error) -> Self {
Error::Network(e.to_string())
}
}
impl From<serde_json::Error> for Error {
fn from(e: serde_json::Error) -> Self {
Error::Cdk(e.to_string())
}
}
impl From<std::string::FromUtf8Error> for Error {
fn from(e: std::string::FromUtf8Error) -> Self {
Error::Cdk(e.to_string())
}
}
+31
View File
@@ -0,0 +1,31 @@
use std::str::FromStr;
use bip39::Mnemonic;
use cdk_common::SecretKey;
use flutter_rust_bridge::frb;
use super::error::Error;
/// Generate a new 12-word BIP39 mnemonic
#[frb(sync)]
pub fn generate_mnemonic() -> Result<String, Error> {
let mut entropy = [0u8; 16]; // 128 bits → 12 words
getrandom::fill(&mut entropy).map_err(|_| Error::InvalidInput)?;
let mnemonic = Mnemonic::from_entropy(&entropy).map_err(|_| Error::InvalidInput)?;
Ok(mnemonic.to_string())
}
/// Convert a mnemonic to a 64-byte seed
#[frb(sync)]
pub fn mnemonic_to_seed(mnemonic: String) -> Result<Vec<u8>, Error> {
let mnemonic = Mnemonic::parse(&mnemonic).map_err(|_| Error::InvalidInput)?;
Ok(mnemonic.to_seed("").to_vec())
}
/// Derive the public key (hex) from a secret key (hex)
#[frb(sync)]
pub fn get_pub_key(secret: String) -> Result<String, Error> {
let secret = SecretKey::from_str(&secret)?;
let pub_key = secret.public_key();
Ok(pub_key.to_string())
}
+243
View File
@@ -0,0 +1,243 @@
use std::str::FromStr;
use cdk::{
nuts::{nut04, nut05, nut06},
wallet::{HttpClient, MintConnector},
};
use cdk_common::{
mint_url::MintUrl, ContactInfo as CdkContactInfo, MintInfo as CdkMintInfo,
MintVersion as CdkMintVersion, Nuts as CdkNuts,
};
use super::error::Error;
// === MintInfo structs ===
pub struct MintInfo {
pub name: Option<String>,
pub pubkey: Option<String>,
pub version: Option<MintVersion>,
pub description: Option<String>,
pub description_long: Option<String>,
pub contact: Option<Vec<ContactInfo>>,
pub nuts: Nuts,
pub icon_url: Option<String>,
pub urls: Option<Vec<String>>,
pub motd: Option<String>,
pub time: Option<u64>,
pub tos_url: Option<String>,
}
impl From<CdkMintInfo> for MintInfo {
fn from(value: CdkMintInfo) -> Self {
Self {
name: value.name,
pubkey: value.pubkey.map(|p| p.to_string()),
version: value.version.map(|v| v.into()),
description: value.description,
description_long: value.description_long,
contact: value
.contact
.map(|c| c.into_iter().map(|c| c.into()).collect()),
nuts: value.nuts.into(),
icon_url: value.icon_url,
urls: value.urls,
motd: value.motd,
time: value.time,
tos_url: value.tos_url,
}
}
}
pub struct MintVersion {
pub name: String,
pub version: String,
}
impl From<CdkMintVersion> for MintVersion {
fn from(value: CdkMintVersion) -> Self {
Self {
name: value.name,
version: value.version,
}
}
}
pub struct ContactInfo {
pub method: String,
pub info: String,
}
impl From<CdkContactInfo> for ContactInfo {
fn from(c: CdkContactInfo) -> Self {
Self {
method: c.method,
info: c.info,
}
}
}
// === NUT settings ===
pub struct Nuts {
pub nut04: Nut04Settings,
pub nut05: Nut05Settings,
pub nut07: SupportedSettings,
pub nut08: SupportedSettings,
pub nut09: SupportedSettings,
pub nut10: SupportedSettings,
pub nut11: SupportedSettings,
pub nut12: SupportedSettings,
pub nut14: SupportedSettings,
pub nut20: SupportedSettings,
}
impl From<CdkNuts> for Nuts {
fn from(value: CdkNuts) -> Self {
Self {
nut04: value.nut04.into(),
nut05: value.nut05.into(),
nut07: value.nut07.into(),
nut08: value.nut08.into(),
nut09: value.nut09.into(),
nut10: value.nut10.into(),
nut11: value.nut11.into(),
nut12: value.nut12.into(),
nut14: value.nut14.into(),
nut20: value.nut20.into(),
}
}
}
pub struct SupportedSettings {
pub supported: bool,
}
impl From<nut06::SupportedSettings> for SupportedSettings {
fn from(value: nut06::SupportedSettings) -> Self {
Self {
supported: value.supported,
}
}
}
pub struct Nut04Settings {
pub methods: Vec<MintMethodSettings>,
pub disabled: bool,
}
impl From<nut04::Settings> for Nut04Settings {
fn from(value: nut04::Settings) -> Self {
Self {
methods: value.methods.into_iter().map(|m| m.into()).collect(),
disabled: value.disabled,
}
}
}
pub struct MintMethodSettings {
pub method: String,
pub unit: String,
pub min_amount: Option<u64>,
pub max_amount: Option<u64>,
}
impl From<nut04::MintMethodSettings> for MintMethodSettings {
fn from(value: nut04::MintMethodSettings) -> Self {
Self {
method: value.method.to_string(),
unit: value.unit.to_string(),
min_amount: value.min_amount.map(|v| v.into()),
max_amount: value.max_amount.map(|v| v.into()),
}
}
}
pub struct Nut05Settings {
pub methods: Vec<MeltMethodSettings>,
pub disabled: bool,
}
impl From<nut05::Settings> for Nut05Settings {
fn from(value: nut05::Settings) -> Self {
Self {
methods: value.methods.into_iter().map(|m| m.into()).collect(),
disabled: value.disabled,
}
}
}
pub struct MeltMethodSettings {
pub method: String,
pub unit: String,
pub min_amount: Option<u64>,
pub max_amount: Option<u64>,
}
impl From<nut05::MeltMethodSettings> for MeltMethodSettings {
fn from(value: nut05::MeltMethodSettings) -> Self {
Self {
method: value.method.to_string(),
unit: value.unit.to_string(),
min_amount: value.min_amount.map(|v| v.into()),
max_amount: value.max_amount.map(|v| v.into()),
}
}
}
// === API functions ===
/// Fetch full mint info from a mint URL
pub async fn get_mint_info(mint_url: &str) -> Result<MintInfo, Error> {
let mint_url = MintUrl::from_str(mint_url)?;
let client = HttpClient::new(mint_url, None);
Ok(client.get_mint_info().await?.into())
}
/// Ping a mint to check if it's reachable (replaces Dart HTTP call)
pub async fn ping_mint(mint_url: &str) -> Result<bool, Error> {
let url = format!("{}/v1/info", mint_url.trim_end_matches('/'));
let resp = reqwest::Client::new()
.get(&url)
.timeout(std::time::Duration::from_secs(3))
.send()
.await;
match resp {
Ok(r) => Ok(r.status().is_success()),
Err(_) => Ok(false),
}
}
/// Keyset info returned by fetch_keysets
pub struct KeysetInfo {
pub id: String,
pub unit: String,
pub active: bool,
}
/// Fetch keysets from a mint (replaces Dart HTTP call)
pub async fn fetch_keysets(mint_url: &str) -> Result<Vec<KeysetInfo>, Error> {
let url = format!("{}/v1/keysets", mint_url.trim_end_matches('/'));
let resp = reqwest::Client::new()
.get(&url)
.timeout(std::time::Duration::from_secs(5))
.send()
.await?
.text()
.await?;
let data: serde_json::Value = serde_json::from_str(&resp)?;
let keysets = data["keysets"]
.as_array()
.ok_or(Error::InvalidInput)?
.iter()
.filter_map(|ks| {
Some(KeysetInfo {
id: ks["id"].as_str()?.to_string(),
unit: ks["unit"].as_str()?.to_string(),
active: ks["active"].as_bool().unwrap_or(false),
})
})
.collect();
Ok(keysets)
}
+5
View File
@@ -0,0 +1,5 @@
pub mod error;
pub mod keys;
pub mod mint_info;
pub mod token;
pub mod wallet;
+161
View File
@@ -0,0 +1,161 @@
use std::{
str::FromStr,
sync::{Arc, RwLock},
};
use bc_ur::{prelude::CBOR, MultipartDecoder, MultipartEncoder, UR};
use cdk::nuts::Token as CdkToken;
use cdk_common::{KeySetInfo, Proofs};
use flutter_rust_bridge::frb;
use super::error::Error;
pub struct Token {
pub encoded: String,
pub raw: Option<Vec<u8>>,
pub amount: u64,
pub mint_url: String,
}
impl Token {
#[frb(sync)]
pub fn parse(encoded: &str) -> Result<Self, Error> {
let encoded = encoded.strip_prefix("cashu:").unwrap_or(encoded);
let token = CdkToken::from_str(encoded)?;
Token::try_from(token)
}
#[frb(sync)]
pub fn from_raw_bytes(raw: Vec<u8>) -> Result<Self, Error> {
let token = CdkToken::try_from(&raw)?;
Token::try_from(token)
}
pub(crate) fn proofs(&self, mint_keysets: &[KeySetInfo]) -> Result<Proofs, Error> {
let token: CdkToken = self.try_into()?;
Ok(token.proofs(mint_keysets)?)
}
}
impl std::fmt::Display for Token {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.encoded)
}
}
impl FromStr for Token {
type Err = Error;
fn from_str(s: &str) -> Result<Self, Error> {
Token::parse(s)
}
}
impl TryFrom<CdkToken> for Token {
type Error = Error;
fn try_from(token: CdkToken) -> Result<Self, Error> {
match token {
CdkToken::TokenV3(token_v3) => Ok(Token {
encoded: token_v3.to_string(),
raw: None,
amount: token_v3.value()?.into(),
mint_url: token_v3
.mint_urls()
.first()
.ok_or(Error::InvalidInput)?
.to_string(),
}),
CdkToken::TokenV4(token_v4) => Ok(Token {
encoded: token_v4.to_string(),
raw: Some(token_v4.to_raw_bytes()?),
amount: token_v4.value()?.into(),
mint_url: token_v4.mint_url.to_string(),
}),
}
}
}
impl TryInto<CdkToken> for Token {
type Error = Error;
fn try_into(self) -> Result<CdkToken, Error> {
Ok(CdkToken::from_str(&self.encoded)?)
}
}
impl TryInto<CdkToken> for &Token {
type Error = Error;
fn try_into(self) -> Result<CdkToken, Error> {
Ok(CdkToken::from_str(&self.encoded)?)
}
}
// === QR Multiframe (bc-ur) ===
pub struct TokenDecoder {
decoder: Arc<RwLock<MultipartDecoder>>,
}
impl TokenDecoder {
#[frb(sync)]
pub fn new() -> Self {
Self {
decoder: Arc::new(RwLock::new(MultipartDecoder::default())),
}
}
#[frb(sync)]
pub fn is_complete(&self) -> bool {
self.decoder
.read()
.map(|decoder| decoder.is_complete())
.unwrap_or(false)
}
#[frb(sync)]
pub fn receive(&self, input: String) -> Result<(), Error> {
let mut decoder = self
.decoder
.write()
.map_err(|_| Error::Ur("Token decoder lock poisoned".to_string()))?;
decoder.receive(&input)?;
Ok(())
}
#[frb(sync)]
pub fn value(&self) -> Result<Option<Token>, Error> {
let decoder = self
.decoder
.read()
.map_err(|_| Error::Ur("Token decoder lock poisoned".to_string()))?;
let ur = decoder.message()?;
match ur {
Some(ur) => {
let bytes = ur
.cbor()
.try_byte_string()
.map_err(|e| Error::Ur(format!("Failed to decode CBOR byte string: {}", e)))?;
let utf8_encoded = String::from_utf8(bytes)?;
let token = Token::from_str(&utf8_encoded)?;
Ok(Some(token))
}
None => Ok(None),
}
}
}
#[frb(sync)]
pub fn encode_qr_token(
token: &Token,
max_fragment_length: Option<usize>,
) -> Result<Vec<String>, Error> {
let ur = UR::new("", CBOR::to_byte_string(&token.encoded))?;
let mut encoder = MultipartEncoder::new(&ur, max_fragment_length.unwrap_or(150))?;
let mut parts = Vec::new();
for _ in 0..encoder.parts_count() {
parts.push(encoder.next_part()?);
}
Ok(parts)
}
+649
View File
@@ -0,0 +1,649 @@
use std::{collections::HashMap, str::FromStr, sync::Arc, time::Duration};
use bip39::Mnemonic;
use cdk::{
amount::{Amount, SplitTarget},
cdk_database::WalletDatabase as _,
mint_url::MintUrl,
nuts::{
nut00::ProofsMethods, CurrencyUnit, MintQuoteState as CdkMintQuoteState, PaymentMethod,
PublicKey, SecretKey, SpendingConditions, State as ProofState, Token as CdkToken,
},
wallet::{
MeltQuote as CdkMeltQuote, MintQuote as CdkMintQuote, PreparedSend as CdkPreparedSend,
ReceiveOptions as CdkReceiveOptions, SendMemo, SendOptions as CdkSendOptions,
Wallet as CdkWallet,
},
};
use cdk_common::{
util::unix_time,
wallet::{
Transaction as CdkTransaction, TransactionDirection as CdkTransactionDirection,
},
};
use cdk_sqlite::WalletSqliteDatabase;
use flutter_rust_bridge::frb;
use log::info;
use tokio::{sync::broadcast, time::sleep};
use uuid::Uuid;
use crate::frb_generated::StreamSink;
use super::{
error::Error,
mint_info::MintInfo,
token::Token,
};
// ========================================================================
// Wallet
// ========================================================================
#[derive(Clone)]
pub struct Wallet {
pub mint_url: String,
pub unit: String,
balance_broadcast: broadcast::Sender<u64>,
inner: CdkWallet,
seed: [u8; 64],
}
impl Wallet {
#[frb(sync)]
pub fn new(
mint_url: String,
unit: String,
mnemonic: String,
target_proof_count: Option<usize>,
db: &WalletDatabase,
) -> Result<Self, Error> {
let mnemonic = Mnemonic::parse(&mnemonic).map_err(|_| Error::InvalidInput)?;
let seed: [u8; 64] = mnemonic.to_seed("").into();
let unit = CurrencyUnit::from_str(&unit).unwrap_or(CurrencyUnit::Custom(unit.to_string()));
Ok(Self {
mint_url: mint_url.clone(),
unit: unit.to_string(),
balance_broadcast: broadcast::channel(1).0,
inner: CdkWallet::new(
&mint_url,
unit,
db.inner.clone(),
seed,
target_proof_count,
)?,
seed,
})
}
// === Balance ===
pub async fn balance(&self) -> Result<u64, Error> {
Ok(self.inner.total_balance().await?.into())
}
pub async fn stream_balance(&self, sink: StreamSink<u64>) -> Result<(), Error> {
let mut receiver = self.balance_broadcast.subscribe();
let _ = sink.add(self.balance().await?);
flutter_rust_bridge::spawn(async move {
loop {
match receiver.recv().await {
Ok(balance) => {
if sink.add(balance).is_err() {
break;
}
}
Err(_) => break,
}
}
});
Ok(())
}
// === Receive ===
pub async fn receive(&self, token: Token, opts: Option<ReceiveOptions>) -> Result<u64, Error> {
let amount = self
.inner
.receive(&token.encoded, opts.unwrap_or_default().try_into()?)
.await?
.into();
self.update_balance_streams().await;
Ok(amount)
}
// === Send ===
pub async fn prepare_send(
&self,
amount: u64,
opts: Option<SendOptions>,
) -> Result<PreparedSend, Error> {
let prepared_send = self
.inner
.prepare_send(amount.into(), opts.unwrap_or_default().try_into()?)
.await?;
Ok(prepared_send.into())
}
pub async fn send(
&self,
send: PreparedSend,
memo: Option<String>,
include_memo: Option<bool>,
) -> Result<Token, Error> {
let send_memo = memo.map(|m| SendMemo {
memo: m,
include_memo: include_memo.unwrap_or_default(),
});
let token = self
.inner
.confirm_send(
send.operation_id,
send.cdk_amount,
send.cdk_options,
send.proofs_to_swap,
send.proofs_to_send,
send.cdk_swap_fee,
send.cdk_send_fee,
send_memo,
)
.await?
.to_string();
self.update_balance_streams().await;
Ok(Token::from_str(&token)?)
}
pub async fn cancel_send(&self, send: PreparedSend) -> Result<(), Error> {
self.inner
.cancel_send(
send.operation_id,
send.proofs_to_swap,
send.proofs_to_send,
)
.await?;
Ok(())
}
// === Lightning Deposit (NUT-04) ===
pub async fn mint(
&self,
amount: u64,
description: Option<String>,
sink: StreamSink<MintQuote>,
) -> Result<(), Error> {
let mint_url = self.mint_url()?;
let unit = self.unit();
let quote = self
.inner
.mint_quote(PaymentMethod::BOLT11, Some(amount.into()), description, None)
.await?;
let _ = sink.add(MintQuote::from(quote.clone()));
let _self = self.clone();
flutter_rust_bridge::spawn(async move {
loop {
sleep(Duration::from_secs(3)).await;
info!("Checking mint quote state for {}", quote.id);
match _self.inner.check_mint_quote_status(&quote.id).await {
Ok(state_res) => match state_res.state {
CdkMintQuoteState::Unpaid => {
if state_res.expiry < unix_time() {
let _ = sink.add(MintQuote {
id: quote.id,
request: quote.request,
amount: quote.amount.map(|a| a.into()),
expiry: Some(state_res.expiry),
state: MintQuoteState::Error,
token: None,
error: Some("Quote expired".to_string()),
});
break;
}
continue;
}
CdkMintQuoteState::Issued => {
break;
}
CdkMintQuoteState::Paid => {
let _ = sink.add(MintQuote {
id: quote.id.clone(),
request: quote.request.clone(),
amount: quote.amount.map(|a| a.into()),
expiry: Some(quote.expiry),
state: CdkMintQuoteState::Paid.into(),
token: None,
error: None,
});
match _self
.inner
.mint(&quote.id, SplitTarget::None, None)
.await
{
Ok(mint_proofs) => {
let mint_amount =
mint_proofs.total_amount().unwrap_or_default();
let _ = sink.add(MintQuote {
id: quote.id,
request: quote.request,
amount: Some(mint_amount.into()),
expiry: Some(quote.expiry),
state: CdkMintQuoteState::Issued.into(),
token: Token::try_from(CdkToken::new(
mint_url,
mint_proofs,
None,
unit,
))
.ok(),
error: None,
});
_self.update_balance_streams().await;
break;
}
Err(e) => {
let _ = sink.add(MintQuote {
id: quote.id,
request: quote.request,
amount: quote.amount.map(|a| a.into()),
expiry: Some(quote.expiry),
state: MintQuoteState::Error,
token: None,
error: Some(e.to_string()),
});
break;
}
}
}
},
Err(e) => {
let _ = sink.add(MintQuote {
id: quote.id,
request: quote.request,
amount: quote.amount.map(|a| a.into()),
expiry: Some(quote.expiry),
state: MintQuoteState::Error,
token: None,
error: Some(e.to_string()),
});
break;
}
}
}
});
Ok(())
}
pub async fn check_all_mint_quotes(&self) -> Result<(), Error> {
let minted = self.inner.mint_unissued_quotes().await?;
if minted > Amount::ZERO {
self.update_balance_streams().await;
}
Ok(())
}
// === Lightning Withdrawal (NUT-05) ===
pub async fn melt_quote(&self, request: String) -> Result<MeltQuote, Error> {
Ok(self
.inner
.melt_quote(PaymentMethod::BOLT11, request, None, None)
.await?
.into())
}
pub async fn melt(&self, quote: MeltQuote) -> Result<u64, Error> {
let melted = self
.inner
.prepare_melt(&quote.id, HashMap::new())
.await?
.confirm()
.await?;
self.update_balance_streams().await;
Ok(melted.total_amount().into())
}
// === Transactions ===
pub async fn list_transactions(
&self,
direction: Option<TransactionDirection>,
) -> Result<Vec<Transaction>, Error> {
let pending_ys = self
.inner
.get_pending_spent_proofs()
.await?
.into_iter()
.map(|p| p.y())
.collect::<Result<Vec<_>, _>>()?;
let cdk_txs = self
.inner
.list_transactions(direction.map(|d| d.into()))
.await?;
let mut txs = Vec::new();
for tx in cdk_txs {
let status = if pending_ys.iter().any(|y| tx.ys.contains(y)) {
TransactionStatus::Pending
} else {
TransactionStatus::Settled
};
let mut transaction: Transaction = tx.into();
transaction.status = status;
txs.push(transaction);
}
Ok(txs)
}
pub async fn check_pending_transactions(&self) -> Result<(), Error> {
self.inner.check_all_pending_proofs().await?;
self.update_balance_streams().await;
Ok(())
}
// === Recovery ===
pub async fn restore(&self) -> Result<(), Error> {
self.inner.restore().await?;
self.update_balance_streams().await;
Ok(())
}
pub async fn recover_incomplete_sagas(&self) -> Result<(), Error> {
self.inner.recover_incomplete_sagas().await?;
self.update_balance_streams().await;
Ok(())
}
pub async fn finalize_pending_melts(&self) -> Result<(), Error> {
self.inner.finalize_pending_melts().await?;
self.update_balance_streams().await;
Ok(())
}
// === 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 proof_states = self
.inner
.check_proofs_spent(token.proofs(&mint_keysets)?)
.await?;
Ok(proof_states
.iter()
.any(|state| state.state == ProofState::Spent))
}
pub async fn get_mint(&self) -> Result<Option<MintInfo>, Error> {
Ok(self.inner.fetch_mint_info().await?.map(|info| info.into()))
}
// === Internal helpers ===
fn mint_url(&self) -> Result<MintUrl, Error> {
Ok(MintUrl::from_str(&self.mint_url)?)
}
fn unit(&self) -> CurrencyUnit {
CurrencyUnit::from_str(&self.unit).unwrap_or(CurrencyUnit::Custom(self.unit.clone()))
}
async fn update_balance_streams(&self) {
let balance = self
.inner
.total_balance()
.await
.unwrap_or(Amount::ZERO)
.into();
let _ = self.balance_broadcast.send(balance);
}
}
// ========================================================================
// Types
// ========================================================================
pub struct MintQuote {
pub id: String,
pub request: String,
pub amount: Option<u64>,
pub expiry: Option<u64>,
pub state: MintQuoteState,
pub token: Option<Token>,
pub error: Option<String>,
}
impl From<CdkMintQuote> for MintQuote {
fn from(quote: CdkMintQuote) -> Self {
Self {
id: quote.id,
request: quote.request,
amount: quote.amount.map(|a| a.into()),
expiry: Some(quote.expiry),
state: quote.state.into(),
token: None,
error: None,
}
}
}
pub enum MintQuoteState {
Unpaid,
Paid,
Issued,
Error,
}
impl From<CdkMintQuoteState> for MintQuoteState {
fn from(state: CdkMintQuoteState) -> Self {
match state {
CdkMintQuoteState::Unpaid => Self::Unpaid,
CdkMintQuoteState::Paid => Self::Paid,
CdkMintQuoteState::Issued => Self::Issued,
}
}
}
pub struct MeltQuote {
pub id: String,
pub request: String,
pub amount: u64,
pub fee_reserve: u64,
pub expiry: u64,
}
impl From<CdkMeltQuote> for MeltQuote {
fn from(quote: CdkMeltQuote) -> Self {
Self {
id: quote.id,
request: quote.request,
amount: quote.amount.into(),
fee_reserve: quote.fee_reserve.into(),
expiry: quote.expiry,
}
}
}
pub struct PreparedSend {
pub amount: u64,
pub swap_fee: u64,
pub send_fee: u64,
pub fee: u64,
operation_id: Uuid,
cdk_amount: Amount,
cdk_options: CdkSendOptions,
proofs_to_swap: cdk::nuts::Proofs,
proofs_to_send: cdk::nuts::Proofs,
cdk_swap_fee: Amount,
cdk_send_fee: Amount,
}
impl<'a> From<CdkPreparedSend<'a>> for PreparedSend {
fn from(ps: CdkPreparedSend<'a>) -> Self {
let amount = ps.amount();
let swap_fee = ps.swap_fee();
let send_fee = ps.send_fee();
let fee = ps.fee();
Self {
amount: amount.into(),
swap_fee: swap_fee.into(),
send_fee: send_fee.into(),
fee: fee.into(),
operation_id: ps.operation_id(),
cdk_amount: amount,
cdk_options: ps.options().clone(),
proofs_to_swap: ps.proofs_to_swap().to_vec(),
proofs_to_send: ps.proofs_to_send().to_vec(),
cdk_swap_fee: swap_fee,
cdk_send_fee: send_fee,
}
}
}
#[derive(Default)]
pub struct ReceiveOptions {
pub signing_keys: Option<Vec<String>>,
pub preimages: Option<Vec<String>>,
}
impl TryInto<CdkReceiveOptions> for ReceiveOptions {
type Error = Error;
fn try_into(self) -> Result<CdkReceiveOptions, Self::Error> {
let p2pk_signing_keys = self
.signing_keys
.unwrap_or_default()
.into_iter()
.map(|s| SecretKey::from_str(&s))
.collect::<Result<Vec<_>, _>>()?;
Ok(CdkReceiveOptions {
p2pk_signing_keys,
preimages: self.preimages.unwrap_or_default(),
..Default::default()
})
}
}
#[derive(Default)]
pub struct SendOptions {
pub pubkey: Option<String>,
pub include_fee: Option<bool>,
}
impl TryInto<CdkSendOptions> for SendOptions {
type Error = Error;
fn try_into(self) -> Result<CdkSendOptions, Self::Error> {
let pubkey = self.pubkey.map(|s| PublicKey::from_str(&s)).transpose()?;
Ok(CdkSendOptions {
conditions: pubkey.map(|pubkey| SpendingConditions::new_p2pk(pubkey, None)),
include_fee: self.include_fee.unwrap_or_default(),
..Default::default()
})
}
}
#[derive(PartialEq, Eq)]
pub struct Transaction {
pub id: String,
pub mint_url: String,
pub direction: TransactionDirection,
pub amount: u64,
pub fee: u64,
pub unit: String,
pub ys: Vec<String>,
pub timestamp: u64,
pub memo: Option<String>,
pub metadata: HashMap<String, String>,
pub status: TransactionStatus,
}
impl From<CdkTransaction> for Transaction {
fn from(tx: CdkTransaction) -> Self {
Self {
id: tx.id().to_string(),
mint_url: tx.mint_url.to_string(),
direction: tx.direction.into(),
amount: tx.amount.into(),
fee: tx.fee.into(),
unit: tx.unit.to_string(),
ys: tx.ys.iter().map(|y| y.to_string()).collect(),
timestamp: tx.timestamp,
memo: tx.memo,
metadata: tx.metadata,
status: TransactionStatus::Settled,
}
}
}
impl PartialOrd for Transaction {
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
Some(self.cmp(other))
}
}
impl Ord for Transaction {
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
self.timestamp.cmp(&other.timestamp).reverse()
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum TransactionDirection {
Incoming,
Outgoing,
}
impl From<CdkTransactionDirection> for TransactionDirection {
fn from(direction: CdkTransactionDirection) -> Self {
match direction {
CdkTransactionDirection::Incoming => Self::Incoming,
CdkTransactionDirection::Outgoing => Self::Outgoing,
}
}
}
impl Into<CdkTransactionDirection> for TransactionDirection {
fn into(self) -> CdkTransactionDirection {
match self {
Self::Incoming => CdkTransactionDirection::Incoming,
Self::Outgoing => CdkTransactionDirection::Outgoing,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TransactionStatus {
Pending,
Settled,
}
// ========================================================================
// WalletDatabase
// ========================================================================
#[derive(Clone)]
pub struct WalletDatabase {
pub path: String,
inner: Arc<WalletSqliteDatabase>,
}
impl WalletDatabase {
pub async fn new_instance(path: String) -> Result<Self, Error> {
let inner = WalletSqliteDatabase::new(path.as_str()).await?;
Ok(Self {
inner: Arc::new(inner),
path,
})
}
pub async fn remove_mint(&self, mint_url: &str) -> Result<(), Error> {
let mint_url = MintUrl::from_str(mint_url)?;
self.inner.remove_mint(mint_url).await?;
Ok(())
}
}
File diff suppressed because it is too large Load Diff
+2
View File
@@ -0,0 +1,2 @@
mod frb_generated; /* AUTO INJECTED BY flutter_rust_bridge. This line may not be accurate, and you can change it according to your needs. */
pub mod api;
+49
View File
@@ -0,0 +1,49 @@
// Android NDK build integration for elcaju_core Rust crate.
group 'com.elcaju.rust_builder'
version '1.0'
buildscript {
repositories {
google()
mavenCentral()
}
dependencies {
classpath 'com.android.tools.build:gradle:7.3.0'
}
}
rootProject.allprojects {
repositories {
google()
mavenCentral()
}
}
apply plugin: 'com.android.library'
android {
if (project.android.hasProperty("namespace")) {
namespace 'com.elcaju.rust_builder'
}
compileSdkVersion 33
ndkVersion "27.0.12077973"
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
defaultConfig {
minSdkVersion 19
}
}
apply from: "../cargokit/gradle/plugin.gradle"
cargokit {
manifestDir = "../../rust"
libname = "elcaju_core"
}
+4
View File
@@ -0,0 +1,4 @@
target
.dart_tool
*.iml
!pubspec.lock
+42
View File
@@ -0,0 +1,42 @@
/// This is copied from Cargokit (which is the official way to use it currently)
/// Details: https://fzyzcjy.github.io/flutter_rust_bridge/manual/integrate/builtin
Copyright 2022 Matej Knopp
================================================================================
MIT LICENSE
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
of the Software, and to permit persons to whom the Software is furnished to do
so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS
OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
================================================================================
APACHE LICENSE, VERSION 2.0
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+11
View File
@@ -0,0 +1,11 @@
/// This is copied from Cargokit (which is the official way to use it currently)
/// Details: https://fzyzcjy.github.io/flutter_rust_bridge/manual/integrate/builtin
Experimental repository to provide glue for seamlessly integrating cargo build
with flutter plugins and packages.
See https://matejknopp.com/post/flutter_plugin_in_rust_with_no_prebuilt_binaries/
for a tutorial on how to use Cargokit.
Example plugin available at https://github.com/irondash/hello_rust_ffi_plugin.
+60
View File
@@ -0,0 +1,60 @@
#!/bin/bash
set -e
BASEDIR=$(dirname "$0")
# Workaround for https://github.com/dart-lang/pub/issues/4010
BASEDIR=$(cd "$BASEDIR" ; pwd -P)
# Remove XCode SDK from path. Otherwise this breaks tool compilation when building iOS project
NEW_PATH=`echo $PATH | tr ":" "\n" | grep -v "Contents/Developer/" | tr "\n" ":"`
export PATH=${NEW_PATH%?} # remove trailing :
if [ -n "$CARGOKIT_DEBUG" ]; then
env | grep -E '^(CARGOKIT|FLUTTER|PLATFORM_NAME|ARCHS|CONFIGURATION|PODS_)='
fi
# Platform name (macosx, iphoneos, iphonesimulator)
export CARGOKIT_DARWIN_PLATFORM_NAME=$PLATFORM_NAME
# Active architectures (arm64, armv7, x86_64), space separated.
export CARGOKIT_DARWIN_ARCHS=$ARCHS
# Current build configuration (Debug, Release)
export CARGOKIT_CONFIGURATION=$CONFIGURATION
# Path to directory containing Cargo.toml.
export CARGOKIT_MANIFEST_DIR=$PODS_TARGET_SRCROOT/$1
# Temporary directory for build artifacts.
export CARGOKIT_TARGET_TEMP_DIR=$TARGET_TEMP_DIR
# Output directory for final artifacts.
export CARGOKIT_OUTPUT_DIR=$PODS_CONFIGURATION_BUILD_DIR/$PRODUCT_NAME
# Directory to store built tool artifacts.
export CARGOKIT_TOOL_TEMP_DIR=$TARGET_TEMP_DIR/build_tool
# Directory inside root project. Not necessarily the top level directory of root project.
export CARGOKIT_ROOT_PROJECT_DIR=$SRCROOT
FLUTTER_EXPORT_BUILD_ENVIRONMENT=(
"$PODS_ROOT/../Flutter/ephemeral/flutter_export_environment.sh" # macOS
"$PODS_ROOT/../Flutter/flutter_export_environment.sh" # iOS
)
for path in "${FLUTTER_EXPORT_BUILD_ENVIRONMENT[@]}"
do
if [[ -f "$path" ]]; then
source "$path"
fi
done
bash "$BASEDIR/run_build_tool.sh" build-pod "$@"
# Make a symlink from built framework to phony file, which will be used as input to
# build script. This should force rebuild (podspec currently doesn't support alwaysOutOfDate
# attribute on custom build phase)
ln -fs "$OBJROOT/XCBuildData/build.db" "${BUILT_PRODUCTS_DIR}/cargokit_phony"
ln -fs "${BUILT_PRODUCTS_DIR}/${EXECUTABLE_PATH}" "${BUILT_PRODUCTS_DIR}/cargokit_phony_out"
@@ -0,0 +1,5 @@
/// This is copied from Cargokit (which is the official way to use it currently)
/// Details: https://fzyzcjy.github.io/flutter_rust_bridge/manual/integrate/builtin
A sample command-line application with an entrypoint in `bin/`, library code
in `lib/`, and example unit test in `test/`.
@@ -0,0 +1,34 @@
# This is copied from Cargokit (which is the official way to use it currently)
# Details: https://fzyzcjy.github.io/flutter_rust_bridge/manual/integrate/builtin
# This file configures the static analysis results for your project (errors,
# warnings, and lints).
#
# This enables the 'recommended' set of lints from `package:lints`.
# This set helps identify many issues that may lead to problems when running
# or consuming Dart code, and enforces writing Dart using a single, idiomatic
# style and format.
#
# If you want a smaller set of lints you can change this to specify
# 'package:lints/core.yaml'. These are just the most critical lints
# (the recommended set includes the core lints).
# The core lints are also what is used by pub.dev for scoring packages.
include: package:lints/recommended.yaml
# Uncomment the following section to specify additional rules.
linter:
rules:
- prefer_relative_imports
- directives_ordering
# analyzer:
# exclude:
# - path/to/excluded/files/**
# For more information about the core and recommended set of lints, see
# https://dart.dev/go/core-lints
# For additional information about configuring this file, see
# https://dart.dev/guides/language/analysis-options
@@ -0,0 +1,8 @@
/// This is copied from Cargokit (which is the official way to use it currently)
/// Details: https://fzyzcjy.github.io/flutter_rust_bridge/manual/integrate/builtin
import 'package:build_tool/build_tool.dart' as build_tool;
void main(List<String> arguments) {
build_tool.runMain(arguments);
}
@@ -0,0 +1,8 @@
/// This is copied from Cargokit (which is the official way to use it currently)
/// Details: https://fzyzcjy.github.io/flutter_rust_bridge/manual/integrate/builtin
import 'src/build_tool.dart' as build_tool;
Future<void> runMain(List<String> args) async {
return build_tool.runMain(args);
}
@@ -0,0 +1,195 @@
/// This is copied from Cargokit (which is the official way to use it currently)
/// Details: https://fzyzcjy.github.io/flutter_rust_bridge/manual/integrate/builtin
import 'dart:io';
import 'dart:isolate';
import 'dart:math' as math;
import 'package:collection/collection.dart';
import 'package:path/path.dart' as path;
import 'package:version/version.dart';
import 'target.dart';
import 'util.dart';
class AndroidEnvironment {
AndroidEnvironment({
required this.sdkPath,
required this.ndkVersion,
required this.minSdkVersion,
required this.targetTempDir,
required this.target,
});
static void clangLinkerWrapper(List<String> args) {
final clang = Platform.environment['_CARGOKIT_NDK_LINK_CLANG'];
if (clang == null) {
throw Exception(
"cargo-ndk rustc linker: didn't find _CARGOKIT_NDK_LINK_CLANG env var");
}
final target = Platform.environment['_CARGOKIT_NDK_LINK_TARGET'];
if (target == null) {
throw Exception(
"cargo-ndk rustc linker: didn't find _CARGOKIT_NDK_LINK_TARGET env var");
}
runCommand(clang, [
target,
...args,
]);
}
/// Full path to Android SDK.
final String sdkPath;
/// Full version of Android NDK.
final String ndkVersion;
/// Minimum supported SDK version.
final int minSdkVersion;
/// Target directory for build artifacts.
final String targetTempDir;
/// Target being built.
final Target target;
bool ndkIsInstalled() {
final ndkPath = path.join(sdkPath, 'ndk', ndkVersion);
final ndkPackageXml = File(path.join(ndkPath, 'package.xml'));
return ndkPackageXml.existsSync();
}
void installNdk({
required String javaHome,
}) {
final sdkManagerExtension = Platform.isWindows ? '.bat' : '';
final sdkManager = path.join(
sdkPath,
'cmdline-tools',
'latest',
'bin',
'sdkmanager$sdkManagerExtension',
);
log.info('Installing NDK $ndkVersion');
runCommand(sdkManager, [
'--install',
'ndk;$ndkVersion',
], environment: {
'JAVA_HOME': javaHome,
});
}
Future<Map<String, String>> buildEnvironment() async {
final hostArch = Platform.isMacOS
? "darwin-x86_64"
: (Platform.isLinux ? "linux-x86_64" : "windows-x86_64");
final ndkPath = path.join(sdkPath, 'ndk', ndkVersion);
final toolchainPath = path.join(
ndkPath,
'toolchains',
'llvm',
'prebuilt',
hostArch,
'bin',
);
final minSdkVersion =
math.max(target.androidMinSdkVersion!, this.minSdkVersion);
final exe = Platform.isWindows ? '.exe' : '';
final arKey = 'AR_${target.rust}';
final arValue = ['${target.rust}-ar', 'llvm-ar', 'llvm-ar.exe']
.map((e) => path.join(toolchainPath, e))
.firstWhereOrNull((element) => File(element).existsSync());
if (arValue == null) {
throw Exception('Failed to find ar for $target in $toolchainPath');
}
final targetArg = '--target=${target.rust}$minSdkVersion';
final ccKey = 'CC_${target.rust}';
final ccValue = path.join(toolchainPath, 'clang$exe');
final cfFlagsKey = 'CFLAGS_${target.rust}';
final cFlagsValue = targetArg;
final cxxKey = 'CXX_${target.rust}';
final cxxValue = path.join(toolchainPath, 'clang++$exe');
final cxxFlagsKey = 'CXXFLAGS_${target.rust}';
final cxxFlagsValue = targetArg;
final linkerKey =
'cargo_target_${target.rust.replaceAll('-', '_')}_linker'.toUpperCase();
final ranlibKey = 'RANLIB_${target.rust}';
final ranlibValue = path.join(toolchainPath, 'llvm-ranlib$exe');
final ndkVersionParsed = Version.parse(ndkVersion);
final rustFlagsKey = 'CARGO_ENCODED_RUSTFLAGS';
final rustFlagsValue = _libGccWorkaround(targetTempDir, ndkVersionParsed);
final runRustTool =
Platform.isWindows ? 'run_build_tool.cmd' : 'run_build_tool.sh';
final packagePath = (await Isolate.resolvePackageUri(
Uri.parse('package:build_tool/buildtool.dart')))!
.toFilePath();
final selfPath = path.canonicalize(path.join(
packagePath,
'..',
'..',
'..',
runRustTool,
));
// Make sure that run_build_tool is working properly even initially launched directly
// through dart run.
final toolTempDir =
Platform.environment['CARGOKIT_TOOL_TEMP_DIR'] ?? targetTempDir;
return {
arKey: arValue,
ccKey: ccValue,
cfFlagsKey: cFlagsValue,
cxxKey: cxxValue,
cxxFlagsKey: cxxFlagsValue,
ranlibKey: ranlibValue,
rustFlagsKey: rustFlagsValue,
linkerKey: selfPath,
// Recognized by main() so we know when we're acting as a wrapper
'_CARGOKIT_NDK_LINK_TARGET': targetArg,
'_CARGOKIT_NDK_LINK_CLANG': ccValue,
'CARGOKIT_TOOL_TEMP_DIR': toolTempDir,
};
}
// Workaround for libgcc missing in NDK23, inspired by cargo-ndk
String _libGccWorkaround(String buildDir, Version ndkVersion) {
final workaroundDir = path.join(
buildDir,
'cargokit',
'libgcc_workaround',
'${ndkVersion.major}',
);
Directory(workaroundDir).createSync(recursive: true);
if (ndkVersion.major >= 23) {
File(path.join(workaroundDir, 'libgcc.a'))
.writeAsStringSync('INPUT(-lunwind)');
} else {
// Other way around, untested, forward libgcc.a from libunwind once Rust
// gets updated for NDK23+.
File(path.join(workaroundDir, 'libunwind.a'))
.writeAsStringSync('INPUT(-lgcc)');
}
var rustFlags = Platform.environment['CARGO_ENCODED_RUSTFLAGS'] ?? '';
if (rustFlags.isNotEmpty) {
rustFlags = '$rustFlags\x1f';
}
rustFlags = '$rustFlags-L\x1f$workaroundDir';
return rustFlags;
}
}
@@ -0,0 +1,266 @@
/// This is copied from Cargokit (which is the official way to use it currently)
/// Details: https://fzyzcjy.github.io/flutter_rust_bridge/manual/integrate/builtin
import 'dart:io';
import 'package:ed25519_edwards/ed25519_edwards.dart';
import 'package:http/http.dart';
import 'package:logging/logging.dart';
import 'package:path/path.dart' as path;
import 'builder.dart';
import 'crate_hash.dart';
import 'options.dart';
import 'precompile_binaries.dart';
import 'rustup.dart';
import 'target.dart';
class Artifact {
/// File system location of the artifact.
final String path;
/// Actual file name that the artifact should have in destination folder.
final String finalFileName;
AritifactType get type {
if (finalFileName.endsWith('.dll') ||
finalFileName.endsWith('.dll.lib') ||
finalFileName.endsWith('.pdb') ||
finalFileName.endsWith('.so') ||
finalFileName.endsWith('.dylib')) {
return AritifactType.dylib;
} else if (finalFileName.endsWith('.lib') || finalFileName.endsWith('.a')) {
return AritifactType.staticlib;
} else {
throw Exception('Unknown artifact type for $finalFileName');
}
}
Artifact({
required this.path,
required this.finalFileName,
});
}
final _log = Logger('artifacts_provider');
class ArtifactProvider {
ArtifactProvider({
required this.environment,
required this.userOptions,
});
final BuildEnvironment environment;
final CargokitUserOptions userOptions;
Future<Map<Target, List<Artifact>>> getArtifacts(List<Target> targets) async {
final result = await _getPrecompiledArtifacts(targets);
final pendingTargets = List.of(targets);
pendingTargets.removeWhere((element) => result.containsKey(element));
if (pendingTargets.isEmpty) {
return result;
}
final rustup = Rustup();
for (final target in targets) {
final builder = RustBuilder(target: target, environment: environment);
builder.prepare(rustup);
_log.info('Building ${environment.crateInfo.packageName} for $target');
final targetDir = await builder.build();
// For local build accept both static and dynamic libraries.
final artifactNames = <String>{
...getArtifactNames(
target: target,
libraryName: environment.crateInfo.packageName,
aritifactType: AritifactType.dylib,
remote: false,
),
...getArtifactNames(
target: target,
libraryName: environment.crateInfo.packageName,
aritifactType: AritifactType.staticlib,
remote: false,
)
};
final artifacts = artifactNames
.map((artifactName) => Artifact(
path: path.join(targetDir, artifactName),
finalFileName: artifactName,
))
.where((element) => File(element.path).existsSync())
.toList();
result[target] = artifacts;
}
return result;
}
Future<Map<Target, List<Artifact>>> _getPrecompiledArtifacts(
List<Target> targets) async {
if (userOptions.usePrecompiledBinaries == false) {
_log.info('Precompiled binaries are disabled');
return {};
}
if (environment.crateOptions.precompiledBinaries == null) {
_log.fine('Precompiled binaries not enabled for this crate');
return {};
}
final start = Stopwatch()..start();
final crateHash = CrateHash.compute(environment.manifestDir,
tempStorage: environment.targetTempDir);
_log.fine(
'Computed crate hash $crateHash in ${start.elapsedMilliseconds}ms');
final downloadedArtifactsDir =
path.join(environment.targetTempDir, 'precompiled', crateHash);
Directory(downloadedArtifactsDir).createSync(recursive: true);
final res = <Target, List<Artifact>>{};
for (final target in targets) {
final requiredArtifacts = getArtifactNames(
target: target,
libraryName: environment.crateInfo.packageName,
remote: true,
);
final artifactsForTarget = <Artifact>[];
for (final artifact in requiredArtifacts) {
final fileName = PrecompileBinaries.fileName(target, artifact);
final downloadedPath = path.join(downloadedArtifactsDir, fileName);
if (!File(downloadedPath).existsSync()) {
final signatureFileName =
PrecompileBinaries.signatureFileName(target, artifact);
await _tryDownloadArtifacts(
crateHash: crateHash,
fileName: fileName,
signatureFileName: signatureFileName,
finalPath: downloadedPath,
);
}
if (File(downloadedPath).existsSync()) {
artifactsForTarget.add(Artifact(
path: downloadedPath,
finalFileName: artifact,
));
} else {
break;
}
}
// Only provide complete set of artifacts.
if (artifactsForTarget.length == requiredArtifacts.length) {
_log.fine('Found precompiled artifacts for $target');
res[target] = artifactsForTarget;
}
}
return res;
}
static Future<Response> _get(Uri url, {Map<String, String>? headers}) async {
int attempt = 0;
const maxAttempts = 10;
while (true) {
try {
return await get(url, headers: headers);
} on SocketException catch (e) {
// Try to detect reset by peer error and retry.
if (attempt++ < maxAttempts &&
(e.osError?.errorCode == 54 || e.osError?.errorCode == 10054)) {
_log.severe(
'Failed to download $url: $e, attempt $attempt of $maxAttempts, will retry...');
await Future.delayed(Duration(seconds: 1));
continue;
} else {
rethrow;
}
}
}
}
Future<void> _tryDownloadArtifacts({
required String crateHash,
required String fileName,
required String signatureFileName,
required String finalPath,
}) async {
final precompiledBinaries = environment.crateOptions.precompiledBinaries!;
final prefix = precompiledBinaries.uriPrefix;
final url = Uri.parse('$prefix$crateHash/$fileName');
final signatureUrl = Uri.parse('$prefix$crateHash/$signatureFileName');
_log.fine('Downloading signature from $signatureUrl');
final signature = await _get(signatureUrl);
if (signature.statusCode == 404) {
_log.warning(
'Precompiled binaries not available for crate hash $crateHash ($fileName)');
return;
}
if (signature.statusCode != 200) {
_log.severe(
'Failed to download signature $signatureUrl: status ${signature.statusCode}');
return;
}
_log.fine('Downloading binary from $url');
final res = await _get(url);
if (res.statusCode != 200) {
_log.severe('Failed to download binary $url: status ${res.statusCode}');
return;
}
if (verify(
precompiledBinaries.publicKey, res.bodyBytes, signature.bodyBytes)) {
File(finalPath).writeAsBytesSync(res.bodyBytes);
} else {
_log.shout('Signature verification failed! Ignoring binary.');
}
}
}
enum AritifactType {
staticlib,
dylib,
}
AritifactType artifactTypeForTarget(Target target) {
if (target.darwinPlatform != null) {
return AritifactType.staticlib;
} else {
return AritifactType.dylib;
}
}
List<String> getArtifactNames({
required Target target,
required String libraryName,
required bool remote,
AritifactType? aritifactType,
}) {
aritifactType ??= artifactTypeForTarget(target);
if (target.darwinArch != null) {
if (aritifactType == AritifactType.staticlib) {
return ['lib$libraryName.a'];
} else {
return ['lib$libraryName.dylib'];
}
} else if (target.rust.contains('-windows-')) {
if (aritifactType == AritifactType.staticlib) {
return ['$libraryName.lib'];
} else {
return [
'$libraryName.dll',
'$libraryName.dll.lib',
if (!remote) '$libraryName.pdb'
];
}
} else if (target.rust.contains('-linux-')) {
if (aritifactType == AritifactType.staticlib) {
return ['lib$libraryName.a'];
} else {
return ['lib$libraryName.so'];
}
} else {
throw Exception("Unsupported target: ${target.rust}");
}
}

Some files were not shown because too many files have changed in this diff Show More