Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
80ab0fd9e1 | ||
|
|
29895f4079 | ||
|
|
41fa32d1b4 | ||
|
|
63ce12d745 | ||
|
|
7245dbb669 | ||
|
|
e124e0839a | ||
|
|
1a31b47c6b | ||
|
|
a5cd50af13 | ||
|
|
66119f6b56 | ||
|
|
484f73a33b | ||
|
|
67204a12f8 | ||
|
|
b4a62fe9e5 | ||
|
|
638b288b7e | ||
|
|
357cc6827f | ||
|
|
682ec4ba7d | ||
|
|
03cf1d2119 | ||
|
|
2309a2edda | ||
|
|
ac1ac11075 | ||
|
|
aafa9f97eb | ||
|
|
7d3fe5ac58 | ||
|
|
b9c01ce865 | ||
|
|
679aa6be5f | ||
|
|
c44a17efbd | ||
|
|
27a2baefcc | ||
|
|
e23c5748ce | ||
|
|
c9781b2ff2 | ||
|
|
b9485d3296 | ||
|
|
c222fff82e | ||
|
|
f839031340 | ||
|
|
41b8743b29 | ||
|
|
056f4c1286 | ||
|
|
cfed450521 | ||
|
|
438603c97b | ||
|
|
c92c438efe | ||
|
|
7d5efcfb75 | ||
|
|
bb942ef72c | ||
|
|
b28a529ffc | ||
|
|
1ff3a6ec16 | ||
|
|
f1b679d912 | ||
|
|
8b514337a6 | ||
|
|
d7f6803f1f | ||
|
|
d91a40c41d | ||
|
|
0ed49789d0 | ||
|
|
a41b99644c | ||
|
|
286eae3812 | ||
|
|
e1e8057be5 | ||
|
|
a1ce50bcd4 | ||
|
|
e9d245c986 | ||
|
|
9836f8f246 | ||
|
|
88e1483152 | ||
|
|
0e7ff326cf | ||
|
|
188e26784b | ||
|
|
989b710cd8 | ||
|
|
01ea5be578 | ||
|
|
07c0a7be89 | ||
|
|
1f629d45b3 | ||
|
|
fa5fd7d98e | ||
|
|
e4f60010f7 | ||
|
|
5d54cb957c | ||
|
|
b930f3c201 | ||
|
|
830c42da93 | ||
|
|
e28c68a0ae | ||
|
|
f59090cec8 | ||
|
|
2a092fc9f8 | ||
|
|
5fa8583cdf | ||
|
|
9ab78e0ab8 | ||
|
|
d13b5e8782 | ||
|
|
76931f7b1d | ||
|
|
115843ce51 | ||
|
|
713f13a46d | ||
|
|
560d6fc683 | ||
|
|
c0f8b166ea | ||
|
|
6b68367c0d | ||
|
|
d80ed4caa7 | ||
|
|
a627ed39f2 | ||
|
|
5d00213d95 | ||
|
|
2e3bb5dcc3 | ||
|
|
749d44650d | ||
|
|
3ff2d6ef84 | ||
|
|
18c72ebf1d | ||
|
|
f9a70bf1ed | ||
|
|
22434245f2 | ||
|
|
fb76bde5b7 | ||
|
|
365047e73c | ||
|
|
c374c042c7 | ||
|
|
0f7e89a687 | ||
|
|
73582c4a69 | ||
|
|
3e9fea34eb | ||
|
|
348ef1d987 | ||
|
|
fc72bb33b2 | ||
|
|
7a9ce65e55 | ||
|
|
604dd65266 | ||
|
|
42f59e877f | ||
|
|
8b0e0dcd15 | ||
|
|
0ac2a8b3ad | ||
|
|
a97d7d45bc | ||
|
|
bf2c4771dd | ||
|
|
5821ea2be5 | ||
|
|
36b900113f | ||
|
|
91078729aa | ||
|
|
61293d991a | ||
|
|
4cb37a8de2 | ||
|
|
1b5045b5ef | ||
|
|
7a1cb21d35 | ||
|
|
b8649348c8 | ||
|
|
d9de23b425 | ||
|
|
28d95fdae0 | ||
|
|
3b72367ff1 | ||
|
|
a8251c433c | ||
|
|
812f28d3b9 | ||
|
|
0465034a2b | ||
|
|
9abb2c03e1 | ||
|
|
f842561864 | ||
|
|
5fe1ec5c90 | ||
|
|
336f4316aa | ||
|
|
7f902f7f75 | ||
|
|
bb07a8f2da | ||
|
|
cb33924a75 | ||
|
|
390af394b9 | ||
|
|
64430191f4 | ||
|
|
0952bda73b | ||
|
|
7da8181022 | ||
|
|
13de1456c8 | ||
|
|
b1777f32a2 | ||
|
|
d9144e74d1 | ||
|
|
bfca3da6e5 | ||
|
|
235dc29b08 | ||
|
|
3840929d5e | ||
|
|
705916d42c | ||
|
|
6189af6200 | ||
|
|
c4e35e93fe | ||
|
|
369c634217 | ||
|
|
69ae41b201 | ||
|
|
bacdff8a32 | ||
|
|
35d3338030 | ||
|
|
b2c365b8f1 | ||
|
|
339da91e20 | ||
|
|
13a442696e | ||
|
|
5b255e3b88 | ||
|
|
07567fea59 | ||
|
|
54cb785310 | ||
|
|
d4e5e1ce70 | ||
|
|
9b3d72fae3 | ||
|
|
3e86de397d | ||
|
|
8bd95dd9a4 | ||
|
|
20c513b5fc | ||
|
|
aeb26158ef | ||
|
|
796f488c15 | ||
|
|
a64a09adeb | ||
|
|
905981c3c3 | ||
|
|
a4a1b929a0 | ||
|
|
c6209b9c9f | ||
|
|
73a4afe3c2 | ||
|
|
c650ec2159 | ||
|
|
72691dbafb | ||
|
|
bdafde7512 | ||
|
|
b9b24de611 | ||
|
|
10114445cd | ||
|
|
aef0eb414e | ||
|
|
9bf4aa1e81 | ||
|
|
ccbc143eb9 | ||
|
|
b33270a719 | ||
|
|
ae06d83cea | ||
|
|
9de89fe9c1 | ||
|
|
7ee12dc044 | ||
|
|
66abf3b3c2 | ||
|
|
9ec585d9a2 | ||
|
|
a25fb9d7c1 | ||
|
|
9e3622435b | ||
|
|
b6c5b42cf2 | ||
|
|
835f5205c7 | ||
|
|
0629c00c90 | ||
|
|
f49e6a9c59 | ||
|
|
a0821429dd | ||
|
|
2e0f360ff0 | ||
|
|
cbddc5aff0 | ||
|
|
9fee4c7ef4 | ||
|
|
7035e2ef00 | ||
|
|
989fd19dc8 | ||
|
|
5dff3bcf24 | ||
|
|
f30d6bac2e |
@@ -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
|
||||
@@ -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,4 +1,7 @@
|
||||
# ElCaju 🥜
|
||||
## ElCaju 🥜
|
||||
|
||||
[](https://deepwiki.com/Forte11Cuba/elcaju)
|
||||
*Ask questions about this project using DeepWiki AI*
|
||||
|
||||
<p align="center">
|
||||
<img src="assets/img/elcajucubano.png" alt="ElCaju Logo" width="200"/>
|
||||
@@ -25,7 +28,7 @@
|
||||
|
||||
---
|
||||
|
||||
## 📱 About ElCaju
|
||||
## About ElCaju
|
||||
|
||||
**ElCaju** is a [Cashu](https://cashu.space) wallet (ecash on Bitcoin) with Cuban identity, brother of [LaChispa](https://github.com/lachispame/) (Lightning). Designed to offer privacy, offline transactions, and a warm tropical experience.
|
||||
|
||||
@@ -38,23 +41,31 @@ Cashu is an ecash protocol that enables Bitcoin transactions with maximum privac
|
||||
|
||||
---
|
||||
|
||||
## ✨ Features
|
||||
## Features
|
||||
|
||||
| Feature | Description |
|
||||
|---------|-------------|
|
||||
| 🔐 **Total Privacy** | Fungible ecash tokens, no traceable history |
|
||||
| 📴 **Works Offline** | Store and share tokens without internet |
|
||||
| ⚡ **Lightning Bridge** | Deposit and withdraw sats via Lightning Network |
|
||||
| 🏦 **Multi-Mint** | Connect to multiple Cashu mints simultaneously |
|
||||
| 💱 **Multi-Unit** | Support for sat, USD, EUR and more |
|
||||
| 🔑 **Your Seed, Your Money** | Backup with 12 words (BIP39) |
|
||||
| 🔒 **Optional PIN** | Protect access to your wallet |
|
||||
| 🌐 **Bilingual** | Spanish and English |
|
||||
| 🎉 **Friendly UX** | Visual effects and confetti when receiving funds |
|
||||
| **Total Privacy** | Fungible ecash tokens, no traceable history |
|
||||
| **Works Offline** | Store, create, and share tokens without internet |
|
||||
| **Lightning Bridge** | Deposit and withdraw sats via Lightning Network |
|
||||
| **Multi-Mint** | Connect to unlimited Cashu mints simultaneously |
|
||||
| **Multi-Unit** | Support for sat, USD, EUR, and any unit offered by mints |
|
||||
| **P2PK (NUT-10)** | Lock tokens to a Nostr public key — only the recipient can claim |
|
||||
| **Token Recovery (NUT-13)** | Restore tokens from any mint using your seed phrase |
|
||||
| **NFC Transfers** | Share tokens via NFC — includes Host Card Emulation (HCE) |
|
||||
| **Animated QR** | UR-format multi-frame QR codes for large tokens |
|
||||
| **Peanut Emoji** | Encode tokens as emoji (compatible with cashu.me) |
|
||||
| **Transaction History** | Full history with filters: Ecash, Lightning, Pending |
|
||||
| **Pending Tokens** | Save tokens to claim later (up to 50, 30-day expiry) |
|
||||
| **BTC Price** | Live price via Yadio API with fiat conversion |
|
||||
| **Your Seed, Your Money** | Backup with 12 words (BIP39) |
|
||||
| **Optional PIN** | 4-digit PIN to protect wallet access |
|
||||
| **11 Languages** | ES, EN, PT, FR, RU, DE, IT, KO, ZH, JA, SW |
|
||||
| **Friendly UX** | Confetti celebrations when receiving funds |
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Screenshots
|
||||
## Screenshots
|
||||
|
||||
<p align="center">
|
||||
<img src="assets/img/Captura1.png" width="180"/>
|
||||
@@ -66,35 +77,42 @@ Cashu is an ecash protocol that enables Bitcoin transactions with maximum privac
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Technologies
|
||||
## Technologies
|
||||
|
||||
### Frontend
|
||||
- **Flutter 3.27+**: Cross-platform framework
|
||||
- **Dart**: Programming language
|
||||
- **Dart 3.11+**: Programming language
|
||||
- **Provider**: State management
|
||||
- **QR Flutter**: QR code generation
|
||||
- **QR Flutter + Mobile Scanner**: QR generation and scanning
|
||||
|
||||
### Backend & Core
|
||||
- **[cdk-flutter](https://github.com/cashubtc/cdk_flutter)**: Cashu Development Kit (Rust FFI)
|
||||
- **elcaju_core**: Internal Rust library via [flutter_rust_bridge](https://github.com/fzyzcjy/flutter_rust_bridge) v2.11.1 (FFI)
|
||||
- **[CDK](https://github.com/cashubtc/cdk) 0.15.1**: Cashu Development Kit (wallet, SQLite, HTTP client, signatory)
|
||||
- **SQLite**: Local persistence via cdk-sqlite
|
||||
- **Flutter Secure Storage**: Secure storage for seed and PIN
|
||||
- **Flutter Secure Storage**: Encrypted storage for seed and PIN
|
||||
|
||||
### Protocols
|
||||
- **Cashu**: NUT-00 to NUT-13 (ecash protocol)
|
||||
- **Cashu**: NUT-00, 04, 05, 06, 07, 09, 10 (P2PK), 13 (restore)
|
||||
- **Lightning Network**: Deposits and withdrawals via BOLT11
|
||||
- **BIP39**: Seed phrase generation
|
||||
- **BIP39 / BIP32**: Seed phrase generation and key derivation
|
||||
- **NIP-06**: Nostr key derivation from mnemonic for P2PK
|
||||
|
||||
### Native
|
||||
- **NFC Manager + HCE**: Phone-to-phone token transfers
|
||||
- **Android NDK 27.0**: Multi-architecture native compilation
|
||||
- **Cargokit**: Automated Rust build integration
|
||||
|
||||
---
|
||||
|
||||
## 📦 Installation
|
||||
## Installation
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Flutter SDK (>=3.27.0)
|
||||
- Dart SDK (>=3.6.0)
|
||||
- Dart SDK (>=3.11.0)
|
||||
- Android Studio with Android SDK
|
||||
- Android NDK 26.3+ (install via Android Studio > SDK Manager > SDK Tools)
|
||||
- Rust toolchain (>=1.85.0)
|
||||
- Android NDK 27.0+ (install via Android Studio > SDK Manager > SDK Tools)
|
||||
- Rust toolchain (edition 2024; rustc >= 1.85.0)
|
||||
|
||||
### Clone the Repository
|
||||
|
||||
@@ -115,7 +133,7 @@ flutter pub get
|
||||
# Install Rust if not already installed
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
|
||||
|
||||
# Update to latest stable (requires >= 1.85.0)
|
||||
# Update to latest stable
|
||||
rustup update stable
|
||||
|
||||
# Install Android targets
|
||||
@@ -134,61 +152,39 @@ linker = "gcc"
|
||||
rustflags = ["-C", "link-arg=-fuse-ld=bfd"]
|
||||
|
||||
[target.aarch64-linux-android]
|
||||
linker = "/path/to/Android/Sdk/ndk/26.3.11579264/toolchains/llvm/prebuilt/linux-x86_64/bin/aarch64-linux-android24-clang"
|
||||
linker = "/path/to/Android/Sdk/ndk/27.0.12077973/toolchains/llvm/prebuilt/linux-x86_64/bin/aarch64-linux-android24-clang"
|
||||
|
||||
[target.armv7-linux-androideabi]
|
||||
linker = "/path/to/Android/Sdk/ndk/26.3.11579264/toolchains/llvm/prebuilt/linux-x86_64/bin/armv7a-linux-androideabi24-clang"
|
||||
linker = "/path/to/Android/Sdk/ndk/27.0.12077973/toolchains/llvm/prebuilt/linux-x86_64/bin/armv7a-linux-androideabi24-clang"
|
||||
|
||||
[target.x86_64-linux-android]
|
||||
linker = "/path/to/Android/Sdk/ndk/26.3.11579264/toolchains/llvm/prebuilt/linux-x86_64/bin/x86_64-linux-android24-clang"
|
||||
linker = "/path/to/Android/Sdk/ndk/27.0.12077973/toolchains/llvm/prebuilt/linux-x86_64/bin/x86_64-linux-android24-clang"
|
||||
|
||||
[env]
|
||||
CC_armv7-linux-androideabi = "/path/to/Android/Sdk/ndk/26.3.11579264/toolchains/llvm/prebuilt/linux-x86_64/bin/armv7a-linux-androideabi24-clang"
|
||||
AR_armv7-linux-androideabi = "/path/to/Android/Sdk/ndk/26.3.11579264/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-ar"
|
||||
CC_x86_64-linux-android = "/path/to/Android/Sdk/ndk/26.3.11579264/toolchains/llvm/prebuilt/linux-x86_64/bin/x86_64-linux-android24-clang"
|
||||
AR_x86_64-linux-android = "/path/to/Android/Sdk/ndk/26.3.11579264/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-ar"
|
||||
CC_aarch64-linux-android = "/path/to/Android/Sdk/ndk/26.3.11579264/toolchains/llvm/prebuilt/linux-x86_64/bin/aarch64-linux-android24-clang"
|
||||
AR_aarch64-linux-android = "/path/to/Android/Sdk/ndk/26.3.11579264/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-ar"
|
||||
CC_armv7-linux-androideabi = "/path/to/Android/Sdk/ndk/27.0.12077973/toolchains/llvm/prebuilt/linux-x86_64/bin/armv7a-linux-androideabi24-clang"
|
||||
AR_armv7-linux-androideabi = "/path/to/Android/Sdk/ndk/27.0.12077973/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-ar"
|
||||
CC_x86_64-linux-android = "/path/to/Android/Sdk/ndk/27.0.12077973/toolchains/llvm/prebuilt/linux-x86_64/bin/x86_64-linux-android24-clang"
|
||||
AR_x86_64-linux-android = "/path/to/Android/Sdk/ndk/27.0.12077973/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-ar"
|
||||
CC_aarch64-linux-android = "/path/to/Android/Sdk/ndk/27.0.12077973/toolchains/llvm/prebuilt/linux-x86_64/bin/aarch64-linux-android24-clang"
|
||||
AR_aarch64-linux-android = "/path/to/Android/Sdk/ndk/27.0.12077973/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-ar"
|
||||
```
|
||||
|
||||
> **Note**: Replace `/path/to/Android/Sdk` with your actual Android SDK path (usually `~/Android/Sdk` on Linux or `~/Library/Android/sdk` on macOS).
|
||||
|
||||
### Compile Native Libraries
|
||||
|
||||
The cdk_flutter native libraries must be compiled manually:
|
||||
|
||||
```bash
|
||||
# Navigate to cdk_flutter rust directory
|
||||
cd ~/.pub-cache/git/cdk_flutter-*/rust
|
||||
|
||||
# Build for each architecture
|
||||
cargo build --release --target aarch64-linux-android
|
||||
cargo build --release --target armv7-linux-androideabi
|
||||
cargo build --release --target x86_64-linux-android
|
||||
|
||||
# Copy libraries to project
|
||||
mkdir -p /path/to/elcaju/android/app/src/main/jniLibs/{arm64-v8a,armeabi-v7a,x86_64}
|
||||
|
||||
cp target/aarch64-linux-android/release/libcdk_flutter.so \
|
||||
/path/to/elcaju/android/app/src/main/jniLibs/arm64-v8a/
|
||||
|
||||
cp target/armv7-linux-androideabi/release/libcdk_flutter.so \
|
||||
/path/to/elcaju/android/app/src/main/jniLibs/armeabi-v7a/
|
||||
|
||||
cp target/x86_64-linux-android/release/libcdk_flutter.so \
|
||||
/path/to/elcaju/android/app/src/main/jniLibs/x86_64/
|
||||
```
|
||||
|
||||
### Run in Development
|
||||
|
||||
The Rust library compiles automatically via Cargokit during `flutter run`:
|
||||
|
||||
```bash
|
||||
flutter run
|
||||
```
|
||||
|
||||
> **Note**: The first build will take several minutes while Rust compiles. Subsequent builds are incremental.
|
||||
|
||||
### Build for Production
|
||||
|
||||
```bash
|
||||
# Android APK (includes all architectures, ~55MB)
|
||||
# Android APK (includes all architectures)
|
||||
flutter build apk --release
|
||||
|
||||
# Android App Bundle (recommended for Play Store)
|
||||
@@ -200,100 +196,123 @@ flutter build appbundle --release
|
||||
For faster builds targeting only modern devices:
|
||||
|
||||
```bash
|
||||
# Build only arm64 library
|
||||
cargo build --release --target aarch64-linux-android
|
||||
|
||||
# Copy to project
|
||||
cp target/aarch64-linux-android/release/libcdk_flutter.so \
|
||||
android/app/src/main/jniLibs/arm64-v8a/
|
||||
|
||||
# Build APK (~40MB)
|
||||
flutter build apk --release
|
||||
flutter build apk --release --target-platform android-arm64
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🏗️ Architecture
|
||||
## Architecture
|
||||
|
||||
```
|
||||
```text
|
||||
┌─────────────────────────────────────────┐
|
||||
│ Flutter UI (Dart) │
|
||||
│ Provider + Streams + Intl │
|
||||
│ Flutter UI (Dart 3.11+) │
|
||||
│ Provider + Streams + Intl (11 langs) │
|
||||
├─────────────────────────────────────────┤
|
||||
│ WalletProvider / SettingsProvider │
|
||||
│ (Multi-unit: Map<mintUrl:unit>) │
|
||||
│ 4 Providers: Wallet, Settings, │
|
||||
│ Price (Yadio), P2PK (NIP-06) │
|
||||
├─────────────────────────────────────────┤
|
||||
│ cdk-flutter (FFI) │
|
||||
│ flutter_rust_bridge v2.11.1 │
|
||||
│ flutter_rust_bridge v2.11.1 (FFI) │
|
||||
├─────────────────────────────────────────┤
|
||||
│ Rust Core (CDK) │
|
||||
│ Cashu 0.13.4 + SQLite + Bitcoin │
|
||||
│ elcaju_core (Rust interno) │
|
||||
│ CDK 0.15.1 + SQLite + BIP39 + Tokio │
|
||||
└─────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Project Structure
|
||||
|
||||
```
|
||||
```text
|
||||
elcaju/
|
||||
├── lib/
|
||||
│ ├── main.dart # Entry point
|
||||
│ ├── main.dart # Entry point (RustLib.init + Providers)
|
||||
│ ├── core/
|
||||
│ │ ├── constants/ # Colors, dimensions
|
||||
│ │ ├── theme/ # App theme
|
||||
│ │ └── utils/ # Formatters, validators
|
||||
│ │ ├── constants/ # Colors, dimensions
|
||||
│ │ ├── models/ # Proof model
|
||||
│ │ ├── services/ # LNURL, NFC, price, proofs
|
||||
│ │ ├── theme/ # Material 3 dark theme
|
||||
│ │ └── utils/ # Formatters, parsers, nostr, p2pk, peanut codec
|
||||
│ ├── data/ # Pending tokens, transaction metadata storage
|
||||
│ ├── models/ # P2PK key model (npub/nsec)
|
||||
│ ├── providers/
|
||||
│ │ ├── wallet_provider.dart # Wallet logic
|
||||
│ │ └── settings_provider.dart# Preferences and PIN
|
||||
│ │ ├── wallet_provider.dart # Multi-mint, multi-unit wallet logic
|
||||
│ │ ├── settings_provider.dart # Preferences, PIN, seed
|
||||
│ │ ├── price_provider.dart # BTC price cache (Yadio API)
|
||||
│ │ └── p2pk_provider.dart # Nostr key management (NIP-06)
|
||||
│ ├── screens/
|
||||
│ │ ├── 1_splash/ # Loading screen
|
||||
│ │ ├── 2_onboarding/ # Create/restore wallet
|
||||
│ │ ├── 3_home/ # Main screen
|
||||
│ │ ├── 4_receive/ # Receive tokens
|
||||
│ │ ├── 5_send/ # Send tokens
|
||||
│ │ ├── 6_mint/ # Deposit via Lightning
|
||||
│ │ ├── 7_melt/ # Withdraw to Lightning
|
||||
│ │ └── 8_settings/ # Settings
|
||||
│ │ ├── 1_splash/ # Loading & initialization
|
||||
│ │ ├── 2_onboarding/ # Create / restore / backup seed
|
||||
│ │ ├── 3_home/ # Main dashboard
|
||||
│ │ ├── 4_receive/ # Receive tokens (paste, QR, NFC)
|
||||
│ │ ├── 5_send/ # Send tokens + offline mode + share
|
||||
│ │ ├── 6_mint/ # Lightning deposit
|
||||
│ │ ├── 7_melt/ # Lightning withdrawal
|
||||
│ │ ├── 8_settings/ # Settings, mints, P2PK keys, language
|
||||
│ │ ├── 9_history/ # Transaction history with filters
|
||||
│ │ └── 10_scanner/ # QR code scanner
|
||||
│ ├── src/rust/ # FFI bridge (auto-generated + API bindings)
|
||||
│ ├── widgets/
|
||||
│ │ ├── common/ # Buttons, cards, etc.
|
||||
│ │ └── effects/ # Confetti and animations
|
||||
│ └── l10n/ # Translations ES/EN
|
||||
└── assets/
|
||||
└── img/ # Logo and assets
|
||||
│ │ ├── common/ # Buttons, cards, numpad, backgrounds
|
||||
│ │ ├── effects/ # Confetti animation
|
||||
│ │ ├── proof/ # Proof selector (offline send)
|
||||
│ │ └── scanner/ # QR scanner widget
|
||||
│ └── l10n/ # 11 languages (354 translation keys)
|
||||
├── rust/
|
||||
│ ├── Cargo.toml # elcaju_core: CDK 0.15.1, flutter_rust_bridge 2.11.1
|
||||
│ └── src/api/ # wallet, token, keys, mint_info, error
|
||||
├── rust_builder/ # Cargokit integration (auto Rust compilation)
|
||||
├── android/ # Android config (NDK 27.0, NFC HCE service)
|
||||
└── assets/img/ # Logo and screenshots
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔐 Security
|
||||
## Cashu NUT Support
|
||||
|
||||
| NUT | Name | Status |
|
||||
|-----|------|--------|
|
||||
| NUT-00 | Cashu Protocol | Supported |
|
||||
| NUT-04 | Mint (Lightning deposit) | Supported |
|
||||
| NUT-05 | Melt (Lightning withdrawal) | Supported |
|
||||
| NUT-06 | Token swap | Supported |
|
||||
| NUT-07 | Spend conditions (witness) | Supported |
|
||||
| NUT-09 | Token restore | Supported |
|
||||
| NUT-10 | P2PK (locked tokens) | Supported |
|
||||
| NUT-13 | Deterministic secrets (recovery) | Supported |
|
||||
|
||||
---
|
||||
|
||||
## Security
|
||||
|
||||
| Feature | Implementation |
|
||||
|---------|----------------|
|
||||
| **Seed Phrase** | Stored with Flutter Secure Storage (native encryption) |
|
||||
| **PIN** | Secure hash, local verification |
|
||||
| **Local Data** | SQLite with proofs encrypted by Cashu protocol |
|
||||
| **Seed Phrase** | BIP39 12 words, stored with Flutter Secure Storage (native encryption) |
|
||||
| **Key Derivation** | BIP32 + NIP-06 (m/44'/1237'/0'/0/0) for P2PK keys |
|
||||
| **PIN** | 4-digit PIN with secure hash, local verification |
|
||||
| **Local Data** | SQLite with proofs managed by CDK |
|
||||
| **P2PK Keys** | Primary (from seed) + up to 10 imported keys, all encrypted |
|
||||
| **No Tracking** | We don't collect user data |
|
||||
| **Open Source** | 100% auditable |
|
||||
|
||||
### Important
|
||||
|
||||
> ⚠️ **Backup your seed phrase (12 words)**. Without it, you won't be able to recover your funds if you lose your device.
|
||||
> **Backup your seed phrase (12 words)**. Without it, you won't be able to recover your funds if you lose your device.
|
||||
|
||||
---
|
||||
|
||||
## 📱 Compatibility
|
||||
## Compatibility
|
||||
|
||||
| Platform | Status | Minimum Version |
|
||||
|----------|--------|-----------------|
|
||||
| ✅ Android | Supported | API 24 (Android 7.0) |
|
||||
| 🚧 iOS | Coming soon | iOS 12.0+ |
|
||||
| 🚧 Web | Coming soon | - |
|
||||
| Android | Supported | API 24 (Android 7.0) |
|
||||
| iOS | Coming soon | iOS 11.0+ |
|
||||
| Web | Coming soon | - |
|
||||
|
||||
---
|
||||
|
||||
## 🌐 Default Mint
|
||||
## Default Mint
|
||||
|
||||
ElCaju comes preconfigured with the Cuba Bitcoin mint:
|
||||
|
||||
```
|
||||
```text
|
||||
https://mint.cubabitcoin.org
|
||||
```
|
||||
|
||||
@@ -301,7 +320,16 @@ You can add other Cashu mints from the settings.
|
||||
|
||||
---
|
||||
|
||||
## 🤝 Contributing
|
||||
## CI/CD
|
||||
|
||||
Automated releases via GitHub Actions (`.github/workflows/release.yml`):
|
||||
- Triggers on Git tags (`v*`)
|
||||
- Builds multi-architecture APKs (arm64, armv7, x86_64) + universal APK
|
||||
- Creates GitHub releases automatically
|
||||
|
||||
---
|
||||
|
||||
## Contributing
|
||||
|
||||
Contributions are welcome!
|
||||
|
||||
@@ -321,16 +349,17 @@ Open an [issue](https://github.com/Forte11Cuba/elcaju/issues) with:
|
||||
|
||||
---
|
||||
|
||||
## 📄 License
|
||||
## License
|
||||
|
||||
This project is under the MIT License - see the [LICENSE](LICENSE) file for more details.
|
||||
|
||||
---
|
||||
|
||||
## 🙏 Credits
|
||||
## Credits
|
||||
|
||||
- **[Cashu](https://cashu.space)** - Ecash protocol
|
||||
- **[cdk-flutter](https://github.com/cashubtc/cdk_flutter)** - Cashu Development Kit
|
||||
- **[CDK](https://github.com/cashubtc/cdk)** - Cashu Development Kit
|
||||
- **[flutter_rust_bridge](https://github.com/fzyzcjy/flutter_rust_bridge)** - Rust-Dart FFI
|
||||
- **[Cashu4Community](https://cashu4community.xyz)** - Global Cashu community
|
||||
- **[Cuba Bitcoin](https://cubabitcoin.org)** - Cuban Bitcoin community
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -2,6 +2,14 @@
|
||||
<!-- Permisos de red requeridos para conectar con mints Cashu -->
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
|
||||
<!-- Permiso de cámara para escanear QR codes -->
|
||||
<uses-permission android:name="android.permission.CAMERA"/>
|
||||
<!-- 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"
|
||||
@@ -29,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
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,3 @@
|
||||
rust_input: crate::api
|
||||
rust_root: rust/
|
||||
dart_output: lib/src/rust
|
||||
@@ -22,6 +22,7 @@ class AppColors {
|
||||
// === ACCENT COLORS ===
|
||||
static const Color primaryAction = Color(0xFFE35D33); // Naranja vibrante
|
||||
static const Color secondaryAction = Color(0xFFFFB74D); // Amarillo pulpa/Bitcoin
|
||||
static const Color bitcoinOrange = Color(0xFFF7931A); // Bitcoin brand orange
|
||||
static const Color success = Color(0xFF00E676); // Verde neón
|
||||
static const Color error = Color(0xFFFF5252); // Rojo suave
|
||||
static const Color warning = Color(0xFFFFB74D); // Amarillo
|
||||
|
||||
@@ -0,0 +1,318 @@
|
||||
import 'dart:convert';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:bech32/bech32.dart';
|
||||
|
||||
/// Tipo de input detectado
|
||||
enum LnInputType {
|
||||
bolt11Invoice, // lnbc..., lntb..., lnbcrt...
|
||||
lnurl, // lnurl1...
|
||||
lightningAddress, // user@domain.com
|
||||
unknown,
|
||||
}
|
||||
|
||||
/// Parámetros de LNURL-pay
|
||||
class LnurlPayParams {
|
||||
final String callback;
|
||||
final BigInt minSendable; // millisats
|
||||
final BigInt maxSendable; // millisats
|
||||
final String? metadata;
|
||||
final String? description;
|
||||
final String? domain;
|
||||
|
||||
LnurlPayParams({
|
||||
required this.callback,
|
||||
required this.minSendable,
|
||||
required this.maxSendable,
|
||||
this.metadata,
|
||||
this.description,
|
||||
this.domain,
|
||||
});
|
||||
|
||||
/// Monto mínimo en sats
|
||||
BigInt get minSats => minSendable ~/ BigInt.from(1000);
|
||||
|
||||
/// Monto máximo en sats
|
||||
BigInt get maxSats => maxSendable ~/ BigInt.from(1000);
|
||||
|
||||
/// Verifica si un monto (en sats) está dentro del rango permitido
|
||||
bool isAmountValid(BigInt amountSats) {
|
||||
final amountMsats = amountSats * BigInt.from(1000);
|
||||
return amountMsats >= minSendable && amountMsats <= maxSendable;
|
||||
}
|
||||
}
|
||||
|
||||
/// Resultado de obtener invoice desde LNURL
|
||||
class LnurlInvoiceResult {
|
||||
final String invoice;
|
||||
final String? successAction;
|
||||
|
||||
LnurlInvoiceResult({
|
||||
required this.invoice,
|
||||
this.successAction,
|
||||
});
|
||||
}
|
||||
|
||||
/// Servicio para resolver LNURL y Lightning Address
|
||||
class LnurlService {
|
||||
static const _timeout = Duration(seconds: 10);
|
||||
|
||||
/// Detecta el tipo de input
|
||||
static LnInputType detectType(String input) {
|
||||
final trimmed = input.trim();
|
||||
final lower = trimmed.toLowerCase();
|
||||
|
||||
// Invoice BOLT11
|
||||
if (lower.startsWith('lnbc') ||
|
||||
lower.startsWith('lntb') ||
|
||||
lower.startsWith('lnbcrt') ||
|
||||
lower.startsWith('lightning:lnbc') ||
|
||||
lower.startsWith('lightning:lntb') ||
|
||||
lower.startsWith('lightning:lnbcrt')) {
|
||||
return LnInputType.bolt11Invoice;
|
||||
}
|
||||
|
||||
// LNURL
|
||||
if (lower.startsWith('lnurl1') ||
|
||||
lower.startsWith('lightning:lnurl1')) {
|
||||
return LnInputType.lnurl;
|
||||
}
|
||||
|
||||
// Lightning Address (user@domain.com)
|
||||
if (_isLightningAddress(trimmed)) {
|
||||
return LnInputType.lightningAddress;
|
||||
}
|
||||
|
||||
return LnInputType.unknown;
|
||||
}
|
||||
|
||||
/// Verifica si es una Lightning Address válida
|
||||
static bool _isLightningAddress(String input) {
|
||||
// Formato: user@domain.com
|
||||
if (!input.contains('@')) return false;
|
||||
|
||||
final parts = input.split('@');
|
||||
if (parts.length != 2) return false;
|
||||
|
||||
final user = parts[0];
|
||||
final domain = parts[1];
|
||||
|
||||
// Validación básica
|
||||
if (user.isEmpty || domain.isEmpty) return false;
|
||||
if (!domain.contains('.')) return false;
|
||||
|
||||
// No debe tener espacios
|
||||
if (user.contains(' ') || domain.contains(' ')) return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Limpia el input removiendo prefijos
|
||||
static String cleanInput(String input) {
|
||||
var cleaned = input.trim();
|
||||
|
||||
// Remover prefijo lightning:
|
||||
if (cleaned.toLowerCase().startsWith('lightning:')) {
|
||||
cleaned = cleaned.substring(10);
|
||||
}
|
||||
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
/// Decodifica LNURL (bech32) a URL
|
||||
static String? decodeLnurl(String lnurl) {
|
||||
try {
|
||||
final cleaned = cleanInput(lnurl).toLowerCase();
|
||||
final decoded = const Bech32Codec().decode(cleaned);
|
||||
|
||||
if (decoded.hrp != 'lnurl') return null;
|
||||
|
||||
// Convertir de 5-bit a 8-bit
|
||||
final bytes = _convertBits(decoded.data, 5, 8, false);
|
||||
return utf8.decode(bytes);
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Convierte bits (usado para decodificar 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);
|
||||
} else if (!pad && (bits >= fromBits || ((acc << (toBits - bits)) & maxv) != 0)) {
|
||||
throw FormatException('Invalid padding in bech32 conversion');
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// Resuelve LNURL a parámetros de pago
|
||||
static Future<LnurlPayParams> resolveLnurl(String lnurl) async {
|
||||
final url = decodeLnurl(lnurl);
|
||||
if (url == null) {
|
||||
throw Exception('LNURL inválido');
|
||||
}
|
||||
|
||||
return _fetchLnurlPayParams(url);
|
||||
}
|
||||
|
||||
/// Resuelve Lightning Address a parámetros de pago
|
||||
static Future<LnurlPayParams> resolveLightningAddress(String address) async {
|
||||
final cleaned = cleanInput(address);
|
||||
final parts = cleaned.split('@');
|
||||
|
||||
if (parts.length != 2) {
|
||||
throw Exception('Lightning Address inválida');
|
||||
}
|
||||
|
||||
final user = parts[0];
|
||||
final domain = parts[1];
|
||||
|
||||
// Construir URL .well-known/lnurlp
|
||||
final url = 'https://$domain/.well-known/lnurlp/$user';
|
||||
|
||||
return _fetchLnurlPayParams(url, domain: domain);
|
||||
}
|
||||
|
||||
/// Obtiene parámetros LNURL-pay desde URL
|
||||
static Future<LnurlPayParams> _fetchLnurlPayParams(String url, {String? domain}) async {
|
||||
try {
|
||||
final response = await http.get(
|
||||
Uri.parse(url),
|
||||
headers: {'Accept': 'application/json'},
|
||||
).timeout(_timeout);
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
throw Exception('Error HTTP: ${response.statusCode}');
|
||||
}
|
||||
|
||||
final json = jsonDecode(response.body) as Map<String, dynamic>;
|
||||
|
||||
// Verificar errores
|
||||
if (json.containsKey('status') && json['status'] == 'ERROR') {
|
||||
throw Exception(json['reason'] ?? 'Error desconocido');
|
||||
}
|
||||
|
||||
// Verificar que es LNURL-pay (tag = payRequest)
|
||||
final tag = json['tag'] as String?;
|
||||
if (tag != null && tag != 'payRequest') {
|
||||
throw Exception('No es LNURL-pay (tag: $tag)');
|
||||
}
|
||||
|
||||
// Extraer parámetros
|
||||
final callback = json['callback'] as String?;
|
||||
if (callback == null || callback.isEmpty) {
|
||||
throw Exception('Respuesta LNURL-pay inválida: falta callback');
|
||||
}
|
||||
|
||||
final minRaw = json['minSendable'] as num?;
|
||||
final maxRaw = json['maxSendable'] as num?;
|
||||
if (minRaw == null || maxRaw == null) {
|
||||
throw Exception('Respuesta LNURL-pay inválida: falta minSendable/maxSendable');
|
||||
}
|
||||
final minSendable = BigInt.from(minRaw.toInt());
|
||||
final maxSendable = BigInt.from(maxRaw.toInt());
|
||||
final metadata = json['metadata'] as String?;
|
||||
|
||||
// Extraer descripción del metadata
|
||||
String? description;
|
||||
if (metadata != null) {
|
||||
try {
|
||||
final metadataList = jsonDecode(metadata) as List;
|
||||
for (final item in metadataList) {
|
||||
if (item is List && item.length >= 2 && item[0] == 'text/plain') {
|
||||
description = item[1] as String;
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
// Extraer dominio del callback si no se proporcionó
|
||||
final callbackUri = Uri.parse(callback);
|
||||
final effectiveDomain = domain ?? callbackUri.host;
|
||||
|
||||
return LnurlPayParams(
|
||||
callback: callback,
|
||||
minSendable: minSendable,
|
||||
maxSendable: maxSendable,
|
||||
metadata: metadata,
|
||||
description: description,
|
||||
domain: effectiveDomain,
|
||||
);
|
||||
} catch (e) {
|
||||
if (e is Exception) rethrow;
|
||||
throw Exception('Error resolviendo LNURL: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Obtiene invoice BOLT11 desde callback LNURL-pay
|
||||
static Future<LnurlInvoiceResult> fetchInvoice(
|
||||
String callback,
|
||||
BigInt amountSats,
|
||||
) async {
|
||||
try {
|
||||
// Convertir a millisats
|
||||
final amountMsats = amountSats * BigInt.from(1000);
|
||||
|
||||
// Construir URL con parámetros
|
||||
final uri = Uri.parse(callback);
|
||||
final queryParams = Map<String, String>.from(uri.queryParameters);
|
||||
queryParams['amount'] = amountMsats.toString();
|
||||
|
||||
final requestUri = uri.replace(queryParameters: queryParams);
|
||||
|
||||
final response = await http.get(
|
||||
requestUri,
|
||||
headers: {'Accept': 'application/json'},
|
||||
).timeout(_timeout);
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
throw Exception('Error HTTP: ${response.statusCode}');
|
||||
}
|
||||
|
||||
final json = jsonDecode(response.body) as Map<String, dynamic>;
|
||||
|
||||
// Verificar errores
|
||||
if (json.containsKey('status') && json['status'] == 'ERROR') {
|
||||
throw Exception(json['reason'] ?? 'Error obteniendo invoice');
|
||||
}
|
||||
|
||||
// Extraer invoice
|
||||
final pr = json['pr'] as String?;
|
||||
if (pr == null || pr.isEmpty) {
|
||||
throw Exception('No se recibió invoice');
|
||||
}
|
||||
|
||||
// Success action (opcional)
|
||||
String? successAction;
|
||||
if (json.containsKey('successAction')) {
|
||||
final sa = json['successAction'] as Map<String, dynamic>?;
|
||||
if (sa != null && sa['message'] != null) {
|
||||
successAction = sa['message'] as String;
|
||||
}
|
||||
}
|
||||
|
||||
return LnurlInvoiceResult(
|
||||
invoice: pr,
|
||||
successAction: successAction,
|
||||
);
|
||||
} catch (e) {
|
||||
if (e is Exception) rethrow;
|
||||
throw Exception('Error obteniendo invoice: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,485 @@
|
||||
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 or payment request.
|
||||
static bool _isCashuToken(String text) {
|
||||
final lower = text.toLowerCase().trim();
|
||||
return lower.startsWith('cashua') ||
|
||||
lower.startsWith('cashub') ||
|
||||
lower.startsWith('creqa') ||
|
||||
lower.startsWith('creqb') ||
|
||||
lower.startsWith('bitcoin:');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import 'dart:convert';
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
/// Servicio para obtener precios de Bitcoin usando Yadio API
|
||||
class PriceService {
|
||||
static const _baseUrl = 'https://api.yadio.io';
|
||||
static const _timeout = Duration(seconds: 10);
|
||||
|
||||
/// Obtiene el precio de BTC en una moneda fiat (USD, EUR, etc.)
|
||||
/// Retorna el precio de 1 BTC en la moneda especificada
|
||||
static Future<double> getBtcPrice(String currency) async {
|
||||
try {
|
||||
// /rate/{quote}/{base} - retorna cuánto del quote por 1 base
|
||||
// /rate/USD/BTC retorna cuántos USD por 1 BTC
|
||||
final response = await http.get(
|
||||
Uri.parse('$_baseUrl/rate/${currency.toUpperCase()}/BTC'),
|
||||
).timeout(_timeout);
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
throw Exception('Error HTTP: ${response.statusCode}');
|
||||
}
|
||||
|
||||
final json = jsonDecode(response.body) as Map<String, dynamic>;
|
||||
final rate = json['rate'];
|
||||
|
||||
if (rate == null) {
|
||||
throw Exception('Precio no disponible');
|
||||
}
|
||||
|
||||
return (rate as num).toDouble();
|
||||
} catch (e) {
|
||||
throw Exception('Error obteniendo precio BTC: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Convierte una cantidad de una moneda a otra
|
||||
/// Ejemplo: convert(2.50, 'USD', 'BTC') -> 0.0000244
|
||||
static Future<double> convert(double amount, String from, String to) async {
|
||||
try {
|
||||
final response = await http.get(
|
||||
Uri.parse('$_baseUrl/convert/$amount/${from.toUpperCase()}/${to.toUpperCase()}'),
|
||||
).timeout(_timeout);
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
throw Exception('Error HTTP: ${response.statusCode}');
|
||||
}
|
||||
|
||||
final json = jsonDecode(response.body) as Map<String, dynamic>;
|
||||
final result = json['result'];
|
||||
|
||||
if (result == null) {
|
||||
throw Exception('Conversión no disponible');
|
||||
}
|
||||
|
||||
return (result as num).toDouble();
|
||||
} catch (e) {
|
||||
throw Exception('Error en conversión: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Convierte cantidad en unidad fiat (centavos) a sats
|
||||
/// Ejemplo: 250 cents USD -> X sats
|
||||
static Future<BigInt> fiatCentsToSats(BigInt cents, String fiatCurrency) async {
|
||||
// Convertir centavos a unidad mayor (ej: 250 cents -> 2.50 USD)
|
||||
final fiatAmount = cents.toDouble() / 100;
|
||||
|
||||
// Obtener precio BTC en fiat
|
||||
final btcPrice = await getBtcPrice(fiatCurrency);
|
||||
|
||||
// Calcular BTC y luego sats
|
||||
final btcAmount = fiatAmount / btcPrice;
|
||||
final sats = (btcAmount * 100000000).round();
|
||||
|
||||
return BigInt.from(sats);
|
||||
}
|
||||
|
||||
/// Convierte sats a cantidad en unidad fiat (centavos)
|
||||
static Future<BigInt> satsToFiatCents(BigInt sats, String fiatCurrency) async {
|
||||
// Obtener precio BTC en fiat
|
||||
final btcPrice = await getBtcPrice(fiatCurrency);
|
||||
|
||||
// Calcular fiat amount
|
||||
final btcAmount = sats.toDouble() / 100000000;
|
||||
final fiatAmount = btcAmount * btcPrice;
|
||||
|
||||
// Retornar en centavos
|
||||
return BigInt.from((fiatAmount * 100).round());
|
||||
}
|
||||
}
|
||||
@@ -33,7 +33,7 @@ class ProofService {
|
||||
final dir = await getApplicationDocumentsDirectory();
|
||||
final dbPath = '${dir.path}/elcaju_wallet.sqlite';
|
||||
|
||||
_db = await openDatabase(dbPath);
|
||||
_db = await openDatabase(dbPath, singleInstance: false);
|
||||
return _db!;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
/// Builds a BIP-321 unified payment URI.
|
||||
///
|
||||
/// Format: BITCOIN:?LIGHTNING=LNBC...&CREQ=CREQB1...
|
||||
/// All uppercase for optimal QR encoding (alphanumeric mode).
|
||||
///
|
||||
/// Ref: NUT-26 "BIP-321 Integration" section.
|
||||
String buildUnifiedUri({
|
||||
required String creqB,
|
||||
String? bolt11,
|
||||
}) {
|
||||
final buffer = StringBuffer('bitcoin:?');
|
||||
if (bolt11 != null) {
|
||||
buffer.write('lightning=');
|
||||
buffer.write(bolt11.toUpperCase());
|
||||
buffer.write('&');
|
||||
}
|
||||
buffer.write('creq=');
|
||||
buffer.write(creqB.toUpperCase());
|
||||
return buffer.toString();
|
||||
}
|
||||
@@ -111,6 +111,42 @@ class UnitFormatter {
|
||||
}
|
||||
}
|
||||
|
||||
/// Formatea dígitos crudos para display con decimales fijos (estilo POS).
|
||||
/// Para USD/EUR (2 decimales): "1" → "0.01", "15" → "0.15", "150" → "1.50"
|
||||
/// Para SAT (0 decimales): "1" → "1", "15" → "15"
|
||||
static String formatRawDigitsForDisplay(String rawDigits, String unit) {
|
||||
final multiplier = getMultiplier(unit);
|
||||
|
||||
if (rawDigits.isEmpty) {
|
||||
return multiplier == 100 ? '0.00' : '0';
|
||||
}
|
||||
|
||||
if (multiplier == 100) {
|
||||
// USD/EUR: 2 decimales fijos
|
||||
final digits = rawDigits.padLeft(3, '0');
|
||||
final intPart = digits.substring(0, digits.length - 2);
|
||||
final decPart = digits.substring(digits.length - 2);
|
||||
|
||||
// Formatear parte entera con separador de miles
|
||||
final intValue = int.tryParse(intPart) ?? 0;
|
||||
final formattedInt = NumberFormat('#,##0').format(intValue);
|
||||
|
||||
return '$formattedInt.$decPart';
|
||||
} else {
|
||||
// SAT: sin decimales, con separador de miles
|
||||
final value = int.tryParse(rawDigits) ?? 0;
|
||||
return NumberFormat('#,###').format(value);
|
||||
}
|
||||
}
|
||||
|
||||
/// Convierte dígitos crudos del numpad a BigInt (centavos para USD/EUR, sats para SAT).
|
||||
/// "150" USD → BigInt(150) (ya son centavos)
|
||||
/// "150" SAT → BigInt(150)
|
||||
static BigInt parseRawDigits(String rawDigits, String unit) {
|
||||
if (rawDigits.isEmpty) return BigInt.zero;
|
||||
return BigInt.tryParse(rawDigits) ?? BigInt.zero;
|
||||
}
|
||||
|
||||
/// Obtiene el nombre del host de un mint URL.
|
||||
/// Ejemplo: 'https://mint.cubabitcoin.org' → 'cubabitcoin.org'
|
||||
static String getMintDisplayName(String mintUrl) {
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
// Parser para detectar el tipo de dato entrante (QR, clipboard, etc.)
|
||||
// Soporta: tokens Cashu (A/B), invoices Lightning, URLs de mint, payment requests, peanut emoji
|
||||
|
||||
import 'peanut_codec.dart';
|
||||
|
||||
/// Modo de escaneo
|
||||
enum ScanMode {
|
||||
any, // Desde HomeScreen - detecta y navega automáticamente
|
||||
cashuOnly, // Desde ReceiveScreen - solo acepta tokens Cashu
|
||||
invoiceOnly, // Desde MeltScreen - solo acepta invoices Lightning
|
||||
}
|
||||
|
||||
/// Tipo de dato detectado
|
||||
enum IncomingDataType {
|
||||
cashuToken, // cashuA... / cashuB...
|
||||
lightningInvoice, // lnbc... / lntb... / lnbcrt...
|
||||
mintUrl, // https://...
|
||||
paymentRequest, // creqA/creqB/bitcoin:?creq= (NUT-18/26 payment request)
|
||||
unknown,
|
||||
}
|
||||
|
||||
/// Información de un token parseado (para preview)
|
||||
class TokenInfo {
|
||||
final BigInt amount;
|
||||
final String mintUrl;
|
||||
final String? unit;
|
||||
final String? memo;
|
||||
|
||||
TokenInfo({
|
||||
required this.amount,
|
||||
required this.mintUrl,
|
||||
this.unit,
|
||||
this.memo,
|
||||
});
|
||||
}
|
||||
|
||||
/// Resultado del parsing
|
||||
class ParsedData {
|
||||
final IncomingDataType type;
|
||||
final String raw;
|
||||
final TokenInfo? tokenInfo;
|
||||
final String? invoiceBolt11;
|
||||
final String? mintUrl;
|
||||
|
||||
ParsedData({
|
||||
required this.type,
|
||||
required this.raw,
|
||||
this.tokenInfo,
|
||||
this.invoiceBolt11,
|
||||
this.mintUrl,
|
||||
});
|
||||
|
||||
/// True si el tipo es conocido y puede ser procesado
|
||||
bool get isValid => type != IncomingDataType.unknown;
|
||||
}
|
||||
|
||||
/// Parser estático para detectar tipo de dato
|
||||
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...)
|
||||
if (lower.startsWith('cashua') || lower.startsWith('cashub')) {
|
||||
return ParsedData(
|
||||
type: IncomingDataType.cashuToken,
|
||||
raw: trimmed,
|
||||
);
|
||||
}
|
||||
|
||||
// UR encoded token (ur:cashu/...)
|
||||
if (lower.startsWith('ur:cashu')) {
|
||||
return ParsedData(
|
||||
type: IncomingDataType.cashuToken,
|
||||
raw: trimmed,
|
||||
);
|
||||
}
|
||||
|
||||
// Invoice Lightning (lnbcrt..., lnbc..., lntb...)
|
||||
// Nota: lnbcrt debe ir primero porque lnbc es prefijo de lnbcrt
|
||||
if (lower.startsWith('lnbcrt') ||
|
||||
lower.startsWith('lnbc') ||
|
||||
lower.startsWith('lntb') ||
|
||||
lower.startsWith('lightning:')) {
|
||||
// Remover prefijo lightning: si existe
|
||||
final invoice = lower.startsWith('lightning:')
|
||||
? trimmed.substring(10)
|
||||
: trimmed;
|
||||
return ParsedData(
|
||||
type: IncomingDataType.lightningInvoice,
|
||||
raw: trimmed,
|
||||
invoiceBolt11: invoice,
|
||||
);
|
||||
}
|
||||
|
||||
// BIP-321 unified URI: bitcoin:?lightning=...&creq=...
|
||||
// Detect as paymentRequest but also extract the lightning invoice
|
||||
if (lower.startsWith('bitcoin:')) {
|
||||
final bolt11 = _extractBip321Param(trimmed, 'lightning');
|
||||
final creq = _extractBip321Param(trimmed, 'creq');
|
||||
|
||||
if (creq != null) {
|
||||
return ParsedData(
|
||||
type: IncomingDataType.paymentRequest,
|
||||
raw: trimmed,
|
||||
invoiceBolt11: bolt11,
|
||||
);
|
||||
}
|
||||
// bitcoin: URI with only lightning= (no creq)
|
||||
if (bolt11 != null) {
|
||||
return ParsedData(
|
||||
type: IncomingDataType.lightningInvoice,
|
||||
raw: trimmed,
|
||||
invoiceBolt11: bolt11,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Payment Request: creqA (NUT-18), creqB/CREQB1 (NUT-26)
|
||||
if (lower.startsWith('creqa') || lower.startsWith('creqb')) {
|
||||
return ParsedData(
|
||||
type: IncomingDataType.paymentRequest,
|
||||
raw: trimmed,
|
||||
);
|
||||
}
|
||||
|
||||
// URL potencial de mint (https://...)
|
||||
if (lower.startsWith('https://')) {
|
||||
try {
|
||||
final uri = Uri.parse(trimmed);
|
||||
final path = uri.path.toLowerCase();
|
||||
|
||||
// Solo clasificar como mint si tiene endpoints conocidos de Cashu
|
||||
// o es una URL limpia (sin path o solo /)
|
||||
final isMintEndpoint = path.contains('/v1/info') ||
|
||||
path.contains('/v1/keys') ||
|
||||
path.contains('/v1/mint') ||
|
||||
path.contains('/v1/melt');
|
||||
final isCleanUrl = path.isEmpty || path == '/';
|
||||
|
||||
if (isMintEndpoint || isCleanUrl) {
|
||||
// Normalizar: quitar trailing slash y paths de API
|
||||
String mintUrl = '${uri.scheme}://${uri.host}';
|
||||
if (uri.port != 443) mintUrl += ':${uri.port}';
|
||||
|
||||
return ParsedData(
|
||||
type: IncomingDataType.mintUrl,
|
||||
raw: trimmed,
|
||||
mintUrl: mintUrl,
|
||||
);
|
||||
}
|
||||
// URLs con paths no-mint (ej: /about, /login) → unknown
|
||||
} catch (_) {
|
||||
// URL malformada → unknown
|
||||
}
|
||||
}
|
||||
|
||||
// Tipo desconocido
|
||||
return ParsedData(
|
||||
type: IncomingDataType.unknown,
|
||||
raw: trimmed,
|
||||
);
|
||||
}
|
||||
|
||||
/// Verifica si el dato es un fragmento UR
|
||||
/// Detectamos cualquier UR (ur:bytes/, ur:cashu/, etc.)
|
||||
/// cashu.me y minibits usan ur:bytes/ para tokens Cashu
|
||||
static bool isUrFragment(String data) {
|
||||
return data.toLowerCase().startsWith('ur:');
|
||||
}
|
||||
|
||||
/// Extrae información del header UR (índice y total)
|
||||
/// Formato: ur:cashu/1-5/payload...
|
||||
/// Retorna (currentIndex, totalFragments) o null si no es válido
|
||||
static (int, int)? parseUrHeader(String data) {
|
||||
final lower = data.toLowerCase();
|
||||
if (!lower.startsWith('ur:')) return null;
|
||||
|
||||
// Buscar el patrón X-Y después del tipo
|
||||
final parts = data.split('/');
|
||||
if (parts.length < 2) return null;
|
||||
|
||||
// El segundo segmento debería ser "index-total" o solo el index si es único
|
||||
final indexPart = parts[1];
|
||||
|
||||
// Verificar si es formato multipart (X-Y)
|
||||
if (indexPart.contains('-')) {
|
||||
final indices = indexPart.split('-');
|
||||
if (indices.length == 2) {
|
||||
final current = int.tryParse(indices[0]);
|
||||
final total = int.tryParse(indices[1]);
|
||||
if (current != null && total != null) {
|
||||
return (current, total);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Verifica si un dato es válido para un modo específico
|
||||
static bool isValidForMode(ParsedData data, ScanMode mode) {
|
||||
switch (mode) {
|
||||
case ScanMode.any:
|
||||
return data.type != IncomingDataType.unknown;
|
||||
case ScanMode.cashuOnly:
|
||||
return data.type == IncomingDataType.cashuToken;
|
||||
case ScanMode.invoiceOnly:
|
||||
// Accept pure invoices and BIP-321 URIs that contain a lightning invoice
|
||||
return data.type == IncomingDataType.lightningInvoice ||
|
||||
(data.type == IncomingDataType.paymentRequest &&
|
||||
data.invoiceBolt11 != null);
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract a query parameter value from a BIP-321 URI.
|
||||
/// Case-insensitive key matching, handles percent-encoding.
|
||||
static String? _extractBip321Param(String uri, String key) {
|
||||
final qIndex = uri.indexOf('?');
|
||||
if (qIndex < 0) return null;
|
||||
final query = uri.substring(qIndex + 1);
|
||||
for (final param in query.split('&')) {
|
||||
final eqIndex = param.indexOf('=');
|
||||
if (eqIndex < 0) continue;
|
||||
final k = param.substring(0, eqIndex);
|
||||
if (k.toLowerCase() == key.toLowerCase()) {
|
||||
final rawValue = param.substring(eqIndex + 1);
|
||||
if (rawValue.isEmpty) continue;
|
||||
try {
|
||||
final value = Uri.decodeComponent(rawValue);
|
||||
if (value.isEmpty) continue;
|
||||
return value;
|
||||
} on ArgumentError {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
/// Modelo de datos para tokens Cashu pendientes de reclamar.
|
||||
/// Almacena tokens que el usuario quiere guardar para reclamar después.
|
||||
class PendingToken {
|
||||
/// UUID único del token pendiente
|
||||
final String id;
|
||||
|
||||
/// Token codificado completo (cashuA... o cashuB...)
|
||||
final String encoded;
|
||||
|
||||
/// Monto del token en la unidad base
|
||||
final BigInt amount;
|
||||
|
||||
/// URL del mint que emitió el token
|
||||
final String mintUrl;
|
||||
|
||||
/// Unidad detectada del token (sat, usd, eur, etc.)
|
||||
final String? unit;
|
||||
|
||||
/// Fecha en que se guardó el token
|
||||
final DateTime savedAt;
|
||||
|
||||
/// Memo opcional del token
|
||||
final String? memo;
|
||||
|
||||
/// Número de intentos fallidos de reclamo
|
||||
final int retryCount;
|
||||
|
||||
/// Último error al intentar reclamar
|
||||
final String? lastError;
|
||||
|
||||
/// Fecha del último intento de reclamo
|
||||
final DateTime? lastAttempt;
|
||||
|
||||
PendingToken({
|
||||
required this.id,
|
||||
required this.encoded,
|
||||
required this.amount,
|
||||
required this.mintUrl,
|
||||
this.unit,
|
||||
required this.savedAt,
|
||||
this.memo,
|
||||
this.retryCount = 0,
|
||||
this.lastError,
|
||||
this.lastAttempt,
|
||||
});
|
||||
|
||||
/// Verifica si el token ha expirado (30 días)
|
||||
bool get isExpired =>
|
||||
savedAt.add(const Duration(days: 30)).isBefore(DateTime.now());
|
||||
|
||||
/// Días restantes antes de expirar
|
||||
int get daysRemaining {
|
||||
final expiresAt = savedAt.add(const Duration(days: 30));
|
||||
final remaining = expiresAt.difference(DateTime.now()).inDays;
|
||||
return remaining < 0 ? 0 : remaining;
|
||||
}
|
||||
|
||||
/// Verifica si el token tiene errores de reclamo
|
||||
bool get hasError => lastError != null && lastError!.isNotEmpty;
|
||||
|
||||
/// Crea una copia con campos actualizados
|
||||
PendingToken copyWith({
|
||||
String? id,
|
||||
String? encoded,
|
||||
BigInt? amount,
|
||||
String? mintUrl,
|
||||
String? unit,
|
||||
DateTime? savedAt,
|
||||
String? memo,
|
||||
int? retryCount,
|
||||
String? lastError,
|
||||
DateTime? lastAttempt,
|
||||
}) {
|
||||
return PendingToken(
|
||||
id: id ?? this.id,
|
||||
encoded: encoded ?? this.encoded,
|
||||
amount: amount ?? this.amount,
|
||||
mintUrl: mintUrl ?? this.mintUrl,
|
||||
unit: unit ?? this.unit,
|
||||
savedAt: savedAt ?? this.savedAt,
|
||||
memo: memo ?? this.memo,
|
||||
retryCount: retryCount ?? this.retryCount,
|
||||
lastError: lastError ?? this.lastError,
|
||||
lastAttempt: lastAttempt ?? this.lastAttempt,
|
||||
);
|
||||
}
|
||||
|
||||
/// Convierte a Map para persistencia SQLite
|
||||
Map<String, dynamic> toMap() {
|
||||
return {
|
||||
'id': id,
|
||||
'encoded': encoded,
|
||||
'amount': amount.toString(),
|
||||
'mint_url': mintUrl,
|
||||
'unit': unit,
|
||||
'saved_at': savedAt.millisecondsSinceEpoch,
|
||||
'memo': memo,
|
||||
'retry_count': retryCount,
|
||||
'last_error': lastError,
|
||||
'last_attempt': lastAttempt?.millisecondsSinceEpoch,
|
||||
};
|
||||
}
|
||||
|
||||
/// Crea desde Map de SQLite
|
||||
factory PendingToken.fromMap(Map<String, dynamic> map) {
|
||||
return PendingToken(
|
||||
id: map['id'] as String,
|
||||
encoded: map['encoded'] as String,
|
||||
amount: BigInt.parse(map['amount'] as String),
|
||||
mintUrl: map['mint_url'] as String,
|
||||
unit: map['unit'] as String?,
|
||||
savedAt: DateTime.fromMillisecondsSinceEpoch(map['saved_at'] as int),
|
||||
memo: map['memo'] as String?,
|
||||
retryCount: map['retry_count'] as int? ?? 0,
|
||||
lastError: map['last_error'] as String?,
|
||||
lastAttempt: map['last_attempt'] != null
|
||||
? DateTime.fromMillisecondsSinceEpoch(map['last_attempt'] as int)
|
||||
: null,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'PendingToken(id: $id, amount: $amount, unit: $unit, mintUrl: $mintUrl, daysRemaining: $daysRemaining)';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
|
||||
import 'pending_token.dart';
|
||||
|
||||
/// Storage para tokens Cashu pendientes de reclamar.
|
||||
/// Usa SQLite para persistencia robusta con soporte para consultas complejas.
|
||||
///
|
||||
/// Características:
|
||||
/// - Límite máximo de 50 tokens
|
||||
/// - Expiración automática a 30 días
|
||||
/// - Cache en memoria para acceso rápido
|
||||
/// - Singleton para acceso global
|
||||
class PendingTokenStorage {
|
||||
static const _dbName = 'pending_tokens.db';
|
||||
static const _tableName = 'pending_tokens';
|
||||
static const _maxTokens = 50;
|
||||
static const _expirationDays = 30;
|
||||
|
||||
Database? _db;
|
||||
final Map<String, PendingToken> _cache = {};
|
||||
bool _isInitialized = false;
|
||||
|
||||
/// Stream controller para notificar cambios
|
||||
StreamController<void> _changesController = StreamController<void>.broadcast();
|
||||
|
||||
/// Stream de cambios para que la UI pueda reaccionar
|
||||
Stream<void> get changes => _changesController.stream;
|
||||
|
||||
/// Singleton
|
||||
static final PendingTokenStorage _instance = PendingTokenStorage._internal();
|
||||
factory PendingTokenStorage() => _instance;
|
||||
PendingTokenStorage._internal();
|
||||
|
||||
/// Verifica si está inicializado
|
||||
bool get isInitialized => _isInitialized;
|
||||
|
||||
/// Cantidad de tokens pendientes
|
||||
int get count => _cache.length;
|
||||
|
||||
/// Verifica si hay tokens pendientes
|
||||
bool get hasPendingTokens => _cache.isNotEmpty;
|
||||
|
||||
/// Inicializa el storage. Llamar antes de usar.
|
||||
Future<void> init() async {
|
||||
if (_isInitialized) return;
|
||||
|
||||
// Inicializar FFI para Linux/Windows
|
||||
if (Platform.isLinux || Platform.isWindows) {
|
||||
sqfliteFfiInit();
|
||||
databaseFactory = databaseFactoryFfi;
|
||||
}
|
||||
|
||||
final dir = await getApplicationDocumentsDirectory();
|
||||
final dbPath = '${dir.path}/$_dbName';
|
||||
|
||||
_db = await openDatabase(
|
||||
dbPath,
|
||||
version: 1,
|
||||
onCreate: (db, version) async {
|
||||
await db.execute('''
|
||||
CREATE TABLE $_tableName (
|
||||
id TEXT PRIMARY KEY,
|
||||
encoded TEXT NOT NULL,
|
||||
amount TEXT NOT NULL,
|
||||
mint_url TEXT NOT NULL,
|
||||
unit TEXT,
|
||||
saved_at INTEGER NOT NULL,
|
||||
memo TEXT,
|
||||
retry_count INTEGER DEFAULT 0,
|
||||
last_error TEXT,
|
||||
last_attempt INTEGER
|
||||
)
|
||||
''');
|
||||
|
||||
// Índices para consultas eficientes
|
||||
await db.execute(
|
||||
'CREATE INDEX idx_mint_url ON $_tableName(mint_url)',
|
||||
);
|
||||
await db.execute(
|
||||
'CREATE INDEX idx_saved_at ON $_tableName(saved_at)',
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
await _loadFromDb();
|
||||
await _cleanExpired();
|
||||
|
||||
_isInitialized = true;
|
||||
debugPrint('PendingTokenStorage inicializado con ${_cache.length} tokens');
|
||||
}
|
||||
|
||||
/// Carga todos los tokens desde la base de datos al cache
|
||||
Future<void> _loadFromDb() async {
|
||||
if (_db == null) return;
|
||||
|
||||
final results = await _db!.query(
|
||||
_tableName,
|
||||
orderBy: 'saved_at DESC',
|
||||
);
|
||||
|
||||
_cache.clear();
|
||||
for (final row in results) {
|
||||
final token = PendingToken.fromMap(row);
|
||||
_cache[token.id] = token;
|
||||
}
|
||||
}
|
||||
|
||||
/// Agrega un token pendiente.
|
||||
/// Retorna el PendingToken creado o null si se alcanzó el límite.
|
||||
Future<PendingToken?> add({
|
||||
required String id,
|
||||
required String encoded,
|
||||
required BigInt amount,
|
||||
required String mintUrl,
|
||||
String? unit,
|
||||
String? memo,
|
||||
}) async {
|
||||
if (_db == null) {
|
||||
throw StateError('PendingTokenStorage no inicializado');
|
||||
}
|
||||
|
||||
// Verificar límite
|
||||
if (_cache.length >= _maxTokens) {
|
||||
debugPrint('Límite de $_maxTokens tokens pendientes alcanzado');
|
||||
return null;
|
||||
}
|
||||
|
||||
// Verificar si ya existe (mismo encoded)
|
||||
for (final existing in _cache.values) {
|
||||
if (existing.encoded == encoded) {
|
||||
debugPrint('Token ya existe en pending: ${existing.id}');
|
||||
return existing;
|
||||
}
|
||||
}
|
||||
|
||||
final token = PendingToken(
|
||||
id: id,
|
||||
encoded: encoded,
|
||||
amount: amount,
|
||||
mintUrl: mintUrl,
|
||||
unit: unit,
|
||||
savedAt: DateTime.now(),
|
||||
memo: memo,
|
||||
);
|
||||
|
||||
await _db!.insert(_tableName, token.toMap());
|
||||
_cache[id] = token;
|
||||
_notifyChanges();
|
||||
|
||||
debugPrint('Token pendiente guardado: $id');
|
||||
return token;
|
||||
}
|
||||
|
||||
/// Obtiene un token por ID
|
||||
PendingToken? get(String id) {
|
||||
return _cache[id];
|
||||
}
|
||||
|
||||
/// Lista todos los tokens pendientes (ordenados por fecha, más reciente primero)
|
||||
List<PendingToken> listAll() {
|
||||
final tokens = _cache.values.toList();
|
||||
tokens.sort((a, b) => b.savedAt.compareTo(a.savedAt));
|
||||
return tokens;
|
||||
}
|
||||
|
||||
/// Lista solo tokens válidos (no expirados)
|
||||
List<PendingToken> listValid() {
|
||||
return listAll().where((t) => !t.isExpired).toList();
|
||||
}
|
||||
|
||||
/// Lista tokens por mint
|
||||
List<PendingToken> listByMint(String mintUrl) {
|
||||
return listAll().where((t) => t.mintUrl == mintUrl).toList();
|
||||
}
|
||||
|
||||
/// Actualiza un token (para reintentos, errores, etc.)
|
||||
Future<void> update(PendingToken token) async {
|
||||
if (_db == null) return;
|
||||
if (!_cache.containsKey(token.id)) return;
|
||||
|
||||
await _db!.update(
|
||||
_tableName,
|
||||
token.toMap(),
|
||||
where: 'id = ?',
|
||||
whereArgs: [token.id],
|
||||
);
|
||||
|
||||
_cache[token.id] = token;
|
||||
_notifyChanges();
|
||||
}
|
||||
|
||||
/// Registra un intento fallido de reclamo
|
||||
Future<void> recordFailedAttempt(String id, String error) async {
|
||||
final token = _cache[id];
|
||||
if (token == null) return;
|
||||
|
||||
final updated = token.copyWith(
|
||||
retryCount: token.retryCount + 1,
|
||||
lastError: error,
|
||||
lastAttempt: DateTime.now(),
|
||||
);
|
||||
|
||||
await update(updated);
|
||||
}
|
||||
|
||||
/// Elimina un token por ID
|
||||
Future<void> remove(String id) async {
|
||||
if (_db == null) return;
|
||||
|
||||
await _db!.delete(
|
||||
_tableName,
|
||||
where: 'id = ?',
|
||||
whereArgs: [id],
|
||||
);
|
||||
|
||||
_cache.remove(id);
|
||||
_notifyChanges();
|
||||
|
||||
debugPrint('Token pendiente eliminado: $id');
|
||||
}
|
||||
|
||||
/// Limpia tokens expirados
|
||||
Future<int> cleanExpired() async {
|
||||
return await _cleanExpired();
|
||||
}
|
||||
|
||||
Future<int> _cleanExpired() async {
|
||||
if (_db == null) return 0;
|
||||
|
||||
final cutoff = DateTime.now()
|
||||
.subtract(const Duration(days: _expirationDays))
|
||||
.millisecondsSinceEpoch;
|
||||
|
||||
final deleted = await _db!.delete(
|
||||
_tableName,
|
||||
where: 'saved_at < ?',
|
||||
whereArgs: [cutoff],
|
||||
);
|
||||
|
||||
if (deleted > 0) {
|
||||
await _loadFromDb();
|
||||
_notifyChanges();
|
||||
debugPrint('Limpiados $deleted tokens expirados');
|
||||
}
|
||||
|
||||
return deleted;
|
||||
}
|
||||
|
||||
/// Elimina todos los tokens pendientes
|
||||
Future<void> clear() async {
|
||||
if (_db == null) return;
|
||||
|
||||
await _db!.delete(_tableName);
|
||||
_cache.clear();
|
||||
_notifyChanges();
|
||||
|
||||
debugPrint('Todos los tokens pendientes eliminados');
|
||||
}
|
||||
|
||||
/// Notifica a los listeners de cambios
|
||||
void _notifyChanges() {
|
||||
if (!_changesController.isClosed) {
|
||||
_changesController.add(null);
|
||||
}
|
||||
}
|
||||
|
||||
/// Cierra la base de datos (llamar al cerrar la app si es necesario)
|
||||
Future<void> close() async {
|
||||
await _db?.close();
|
||||
_db = null;
|
||||
_isInitialized = false;
|
||||
await _changesController.close();
|
||||
// Recrear el controller para permitir re-inicialización del singleton
|
||||
_changesController = StreamController<void>.broadcast();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,563 @@
|
||||
{
|
||||
"@@locale": "de",
|
||||
|
||||
"appName": "ElCaju",
|
||||
"appTagline": "Deine private ecash Wallet",
|
||||
"loadingMessage1": "Verschlüsselung deiner Münzen...",
|
||||
"loadingMessage2": "Vorbereitung deiner E-Tokens...",
|
||||
"loadingMessage3": "Verbindung zum Mint...",
|
||||
"loadingMessage4": "Privatsphäre standardmäßig.",
|
||||
"loadingMessage5": "Blind signierte Tokens...",
|
||||
"loadingMessage6": "Go full Calle...",
|
||||
"loadingMessage7": "Cashu + Bitchat = Privatsphäre + Freiheit",
|
||||
"aboutTagline": "Privatsphäre ohne Grenzen.",
|
||||
|
||||
"welcomeTitle": "Willkommen bei ElCaju",
|
||||
"welcomeSubtitle": "Cashu für die Welt. Made in Cuba.",
|
||||
|
||||
"createWallet": "Neue Wallet erstellen",
|
||||
"restoreWallet": "Wallet wiederherstellen",
|
||||
|
||||
"createWalletTitle": "Wallet erstellen",
|
||||
"creatingWallet": "Erstelle deine Wallet...",
|
||||
"generatingSeed": "Sichere Generierung deiner Seed-Phrase",
|
||||
"createWalletDescription": "Eine 12-Wort Seed-Phrase wird generiert.\nBewahre sie an einem sicheren Ort auf.",
|
||||
"generateWallet": "Wallet generieren",
|
||||
|
||||
"walletCreated": "Wallet erstellt!",
|
||||
"walletCreatedDescription": "Deine Wallet ist bereit. Wir empfehlen, jetzt ein Backup deiner Seed-Phrase zu erstellen.",
|
||||
"backupWarning": "Ohne Backup verlierst du den Zugang zu deinen Mitteln, wenn du das Gerät verlierst.",
|
||||
"backupNow": "Jetzt sichern",
|
||||
"backupLater": "Später machen",
|
||||
|
||||
"backupTitle": "Backup",
|
||||
"seedPhraseTitle": "Deine Seed-Phrase",
|
||||
"seedPhraseDescription": "Speichere diese 12 Wörter in der richtigen Reihenfolge. Sie sind der einzige Weg, deine Wallet wiederherzustellen.",
|
||||
"revealSeedPhrase": "Seed-Phrase anzeigen",
|
||||
"tapToReveal": "Tippe auf den Button, um\ndeine Seed-Phrase anzuzeigen",
|
||||
"copyToClipboard": "In Zwischenablage kopieren",
|
||||
"seedCopied": "Phrase in Zwischenablage kopiert",
|
||||
"neverShareSeed": "Teile deine Seed-Phrase niemals mit anderen.",
|
||||
"confirmBackup": "Ich habe meine Seed-Phrase an einem sicheren Ort gespeichert",
|
||||
"continue_": "Weiter",
|
||||
|
||||
"restoreTitle": "Wallet wiederherstellen",
|
||||
"enterSeedPhrase": "Gib deine Seed-Phrase ein",
|
||||
"enterSeedDescription": "Gib die 12 oder 24 Wörter durch Leerzeichen getrennt ein.",
|
||||
"seedPlaceholder": "wort1 wort2 wort3 ...",
|
||||
"wordCount": "{count} Wörter",
|
||||
"@wordCount": {
|
||||
"placeholders": {
|
||||
"count": { "type": "int" }
|
||||
}
|
||||
},
|
||||
"needWords": "(du brauchst 12 oder 24)",
|
||||
"restoreScanningMint": "Mint wird nach vorhandenen Token durchsucht...",
|
||||
"restoreError": "Wiederherstellungsfehler: {error}",
|
||||
"@restoreError": {
|
||||
"placeholders": {
|
||||
"error": { "type": "String" }
|
||||
}
|
||||
},
|
||||
|
||||
"homeTitle": "Start",
|
||||
"receive": "Empfangen",
|
||||
"send": "Senden",
|
||||
"sendAction": "Senden ↗",
|
||||
"receiveAction": "↘ Empfangen",
|
||||
"deposit": "Einzahlen",
|
||||
"withdraw": "Abheben",
|
||||
"lightning": "Lightning",
|
||||
"cashu": "Cashu",
|
||||
"ecash": "Ecash",
|
||||
"history": "Verlauf",
|
||||
"noTransactions": "Noch keine Transaktionen",
|
||||
"depositOrReceive": "Zahle ein oder empfange Sats zum Starten",
|
||||
"noMint": "Kein Mint",
|
||||
|
||||
"balance": "Guthaben",
|
||||
"sats": "sats",
|
||||
|
||||
"pasteEcashToken": "Ecash Token einfügen",
|
||||
"generateInvoiceToDeposit": "Rechnung zum Einzahlen erstellen",
|
||||
"createEcashToken": "Ecash Token erstellen",
|
||||
"payLightningInvoice": "Lightning Rechnung bezahlen",
|
||||
|
||||
"receiveCashu": "Cashu empfangen",
|
||||
"pasteTheCashuToken": "Füge den Cashu Token ein:",
|
||||
"pasteFromClipboard": "Aus Zwischenablage einfügen",
|
||||
"validToken": "Token gültig",
|
||||
"invalidToken": "Ungültiger oder fehlerhafter Token",
|
||||
"amount": "Betrag:",
|
||||
"mint": "Mint:",
|
||||
"claiming": "Einlösen...",
|
||||
"claimTokens": "Tokens einlösen",
|
||||
"tokensReceived": "Tokens empfangen",
|
||||
"backToHome": "Zurück zur Startseite",
|
||||
"tokenAlreadyClaimed": "Dieser Token wurde bereits eingelöst",
|
||||
"unknownMint": "Token von unbekanntem Mint",
|
||||
"claimError": "Einlösungsfehler: {error}",
|
||||
"@claimError": {
|
||||
"placeholders": {
|
||||
"error": { "type": "String" }
|
||||
}
|
||||
},
|
||||
|
||||
"sendCashu": "Cashu senden",
|
||||
"selectNotesManually": "Notizen manuell auswählen",
|
||||
"amountToSend": "Zu sendender Betrag:",
|
||||
"available": "Verfügbar:",
|
||||
"max": "(Max)",
|
||||
"memoOptional": "Memo (optional):",
|
||||
"memoPlaceholder": "Wofür ist diese Zahlung?",
|
||||
"creatingToken": "Token wird erstellt...",
|
||||
"createToken": "Token erstellen",
|
||||
"noActiveMint": "Kein aktiver Mint",
|
||||
"offlineModeMessage": "Keine Verbindung. Offline-Modus...",
|
||||
"confirmSend": "Senden bestätigen",
|
||||
"confirm": "Bestätigen",
|
||||
"cancel": "Abbrechen",
|
||||
"insufficientBalance": "Unzureichendes Guthaben",
|
||||
"tokenCreationError": "Fehler beim Erstellen des Tokens: {error}",
|
||||
"@tokenCreationError": {
|
||||
"placeholders": {
|
||||
"error": { "type": "String" }
|
||||
}
|
||||
},
|
||||
|
||||
"tokenCreated": "Token erstellt",
|
||||
"copy": "Kopieren",
|
||||
"share": "Teilen",
|
||||
"tokenCashu": "Cashu Token",
|
||||
"tokenCashuAnimatedQr": "Cashu Token (animierter QR - {fragments} UR-Fragmente)",
|
||||
"@tokenCashuAnimatedQr": {
|
||||
"placeholders": {
|
||||
"fragments": { "type": "int" }
|
||||
}
|
||||
},
|
||||
"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):",
|
||||
"depositPlaceholder": "Wofür ist diese Einzahlung?",
|
||||
"generating": "Generiere...",
|
||||
"generateInvoice": "Rechnung erstellen",
|
||||
"depositLightning": "Lightning einzahlen",
|
||||
|
||||
"payInvoiceTitle": "Rechnung bezahlen",
|
||||
"generatingInvoice": "Rechnung wird erstellt...",
|
||||
"waitingForPayment": "Warte auf Zahlung...",
|
||||
"paymentReceived": "Zahlung empfangen",
|
||||
"tokensIssued": "Tokens ausgegeben!",
|
||||
"error": "Fehler",
|
||||
"unknownError": "Unbekannter Fehler",
|
||||
"back": "Zurück",
|
||||
"copyInvoice": "Rechnung kopieren",
|
||||
"description": "Beschreibung:",
|
||||
"invoiceCopiedToClipboard": "Rechnung in Zwischenablage kopiert",
|
||||
"deposited": "{amount} {unit} eingezahlt",
|
||||
"@deposited": {
|
||||
"placeholders": {
|
||||
"amount": { "type": "String" },
|
||||
"unit": { "type": "String" }
|
||||
}
|
||||
},
|
||||
|
||||
"pasteLightningInvoice": "Füge die Lightning Rechnung ein:",
|
||||
"gettingQuote": "Angebot wird geholt...",
|
||||
"validInvoice": "Rechnung gültig",
|
||||
"invalidInvoice": "Ungültige Rechnung",
|
||||
"invalidInvoiceMalformed": "Ungültige oder fehlerhafte Rechnung",
|
||||
"feeReserved": "Reservierte Gebühr:",
|
||||
"total": "Gesamt:",
|
||||
"paying": "Bezahle...",
|
||||
"payInvoice": "Rechnung bezahlen",
|
||||
"confirmPayment": "Zahlung bestätigen",
|
||||
"pay": "Bezahlen",
|
||||
"fee": "Gebühr",
|
||||
"invoiceExpired": "Rechnung abgelaufen",
|
||||
"amountOutOfRange": "Betrag außerhalb des erlaubten Bereichs",
|
||||
"resolvingType": "Löse {type} auf...",
|
||||
"@resolvingType": {
|
||||
"placeholders": {
|
||||
"type": { "type": "String" }
|
||||
}
|
||||
},
|
||||
"invoiceAlreadyPaid": "Rechnung bereits bezahlt",
|
||||
"paymentError": "Zahlungsfehler: {error}",
|
||||
"@paymentError": {
|
||||
"placeholders": {
|
||||
"error": { "type": "String" }
|
||||
}
|
||||
},
|
||||
"sent": "{amount} {unit} gesendet",
|
||||
"@sent": {
|
||||
"placeholders": {
|
||||
"amount": { "type": "String" },
|
||||
"unit": { "type": "String" }
|
||||
}
|
||||
},
|
||||
|
||||
"filterAll": "Alle",
|
||||
"filterPending": "Ausstehend",
|
||||
"filterEcash": "Ecash",
|
||||
"filterLightning": "Lightning",
|
||||
"receiveTokensToStart": "Empfange Cashu Tokens zum Starten",
|
||||
"noPendingTransactions": "Keine ausstehenden Transaktionen",
|
||||
"allTransactionsCompleted": "Alle deine Transaktionen sind abgeschlossen",
|
||||
"noEcashTransactions": "Keine Ecash Transaktionen",
|
||||
"sendOrReceiveTokens": "Sende oder empfange Cashu Tokens",
|
||||
"noLightningTransactions": "Keine Lightning Transaktionen",
|
||||
"depositOrWithdrawLightning": "Zahle ein oder hebe ab via Lightning",
|
||||
"pendingStatus": "Ausstehend",
|
||||
"receivedStatus": "Empfangen",
|
||||
"sentStatus": "Gesendet",
|
||||
"now": "Jetzt",
|
||||
"agoMinutes": "Vor {minutes} Min",
|
||||
"@agoMinutes": {
|
||||
"placeholders": {
|
||||
"minutes": { "type": "int" }
|
||||
}
|
||||
},
|
||||
"agoHours": "Vor {hours} Std",
|
||||
"@agoHours": {
|
||||
"placeholders": {
|
||||
"hours": { "type": "int" }
|
||||
}
|
||||
},
|
||||
"agoDays": "Vor {days} Tagen",
|
||||
"@agoDays": {
|
||||
"placeholders": {
|
||||
"days": { "type": "int" }
|
||||
}
|
||||
},
|
||||
"lightningInvoice": "Lightning Rechnung",
|
||||
"receivedEcash": "Ecash empfangen",
|
||||
"sentEcash": "Ecash gesendet",
|
||||
"outgoingLightningPayment": "Ausgehende Lightning Zahlung",
|
||||
"invoiceNotAvailable": "Rechnung nicht verfügbar",
|
||||
"tokenNotAvailable": "Token nicht verfügbar",
|
||||
"unit": "Einheit",
|
||||
"status": "Status",
|
||||
"pending": "Ausstehend",
|
||||
"memo": "Memo",
|
||||
"copyInvoiceButton": "RECHNUNG KOPIEREN",
|
||||
"copyButton": "KOPIEREN",
|
||||
"invoiceCopied": "Rechnung kopiert",
|
||||
"tokenCopied": "Token kopiert",
|
||||
"speed": "GESCHWINDIGKEIT:",
|
||||
|
||||
"settings": "Einstellungen",
|
||||
"walletSection": "WALLET",
|
||||
"backupSeedPhrase": "Seed-Phrase sichern",
|
||||
"viewRecoveryWords": "Deine Wiederherstellungswörter anzeigen",
|
||||
"connectedMints": "Verbundene Mints",
|
||||
"manageCashuMints": "Verwalte deine Cashu Mints",
|
||||
"pinAccess": "PIN-Zugang",
|
||||
"pinEnabled": "Aktiviert",
|
||||
"protectWithPin": "App mit PIN schützen",
|
||||
"recoverTokens": "Tokens wiederherstellen",
|
||||
"scanMintsWithSeed": "Mints mit Seed-Phrase scannen",
|
||||
"appearanceSection": "SPRACHE",
|
||||
"language": "Sprache",
|
||||
"informationSection": "INFORMATION",
|
||||
"version": "Version",
|
||||
"about": "Über",
|
||||
"deleteWallet": "Wallet löschen",
|
||||
"spanish": "Español",
|
||||
"english": "English",
|
||||
"portuguese": "Português",
|
||||
"french": "Français",
|
||||
"russian": "Русский",
|
||||
"german": "Deutsch",
|
||||
"italian": "Italiano",
|
||||
"korean": "한국어",
|
||||
"chinese": "中文",
|
||||
"japanese": "日本語",
|
||||
"swahili": "Kiswahili",
|
||||
|
||||
"mnemonicNotFound": "Mnemonic nicht gefunden",
|
||||
"createPin": "PIN erstellen",
|
||||
"enterPinDigits": "Gib eine 4-stellige PIN ein",
|
||||
"confirmPin": "PIN bestätigen",
|
||||
"enterPinAgain": "PIN erneut eingeben",
|
||||
"pinMismatch": "PINs stimmen nicht überein",
|
||||
"pinActivated": "PIN aktiviert",
|
||||
"pinDeactivated": "PIN deaktiviert",
|
||||
"verifyPin": "PIN überprüfen",
|
||||
"enterCurrentPin": "Gib deine aktuelle PIN ein",
|
||||
"incorrectPin": "Falsche PIN",
|
||||
"selectLanguage": "Sprache auswählen",
|
||||
"languageChanged": "Sprache geändert zu {language}",
|
||||
"@languageChanged": {
|
||||
"placeholders": {
|
||||
"language": { "type": "String" }
|
||||
}
|
||||
},
|
||||
"close": "Schließen",
|
||||
"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?",
|
||||
"actionIrreversible": "Diese Aktion ist unwiderruflich",
|
||||
"deleteWalletWarning": "Alle Daten werden gelöscht, einschließlich deiner Seed-Phrase und Tokens. Stelle sicher, dass du ein Backup hast.",
|
||||
"typeDeleteToConfirm": "Gib \"LÖSCHEN\" zur Bestätigung ein:",
|
||||
"deleteConfirmWord": "LÖSCHEN",
|
||||
"deleteError": "Löschfehler: {error}",
|
||||
"@deleteError": {
|
||||
"placeholders": {
|
||||
"error": { "type": "String" }
|
||||
}
|
||||
},
|
||||
|
||||
"recoverTokensTitle": "Tokens wiederherstellen",
|
||||
"recoverTokensDescription": "Mints scannen, um Tokens wiederherzustellen, die mit deiner Seed-Phrase verknüpft sind (NUT-13)",
|
||||
"useCurrentSeedPhrase": "Meine aktuelle Seed-Phrase verwenden",
|
||||
"scanWithSavedWords": "Mints mit den gespeicherten 12 Wörtern scannen",
|
||||
"useOtherSeedPhrase": "Andere Seed-Phrase verwenden",
|
||||
"recoverFromOtherWords": "Tokens von anderen 12 Wörtern wiederherstellen",
|
||||
"mintsToScan": "Zu scannende Mints:",
|
||||
"allMints": "Alle Mints ({count})",
|
||||
"@allMints": {
|
||||
"placeholders": {
|
||||
"count": { "type": "int" }
|
||||
}
|
||||
},
|
||||
"specificMint": "Ein bestimmter Mint",
|
||||
"enterMnemonicWords": "Gib die 12 Wörter durch Leerzeichen getrennt ein...",
|
||||
"scanMints": "Mints scannen",
|
||||
"selectMintToScan": "Wähle einen Mint zum Scannen",
|
||||
"mnemonicMustHaveWords": "Mnemonic muss 12 oder 24 Wörter haben",
|
||||
"noConnectedMintsToScan": "Keine verbundenen Mints zum Scannen",
|
||||
"recoveredTokens": "{tokens} von {mints} Mint(s) wiederhergestellt!",
|
||||
"@recoveredTokens": {
|
||||
"placeholders": {
|
||||
"tokens": { "type": "String" },
|
||||
"mints": { "type": "int" }
|
||||
}
|
||||
},
|
||||
"scanCompleteNoTokens": "Scan abgeschlossen. Keine neuen Tokens gefunden.",
|
||||
"mintsWithError": "({count} Mint(s) mit Fehler)",
|
||||
"@mintsWithError": {
|
||||
"placeholders": {
|
||||
"count": { "type": "int" }
|
||||
}
|
||||
},
|
||||
"recoveredFromMint": "{tokens} von {mint} wiederhergestellt!",
|
||||
"@recoveredFromMint": {
|
||||
"placeholders": {
|
||||
"tokens": { "type": "String" },
|
||||
"mint": { "type": "String" }
|
||||
}
|
||||
},
|
||||
"noTokensFoundInMint": "Keine Tokens in {mint} gefunden.",
|
||||
"@noTokensFoundInMint": {
|
||||
"placeholders": {
|
||||
"mint": { "type": "String" }
|
||||
}
|
||||
},
|
||||
"recoveredAndTransferred": "{amount} {unit} wiederhergestellt und in deine Wallet übertragen!",
|
||||
"@recoveredAndTransferred": {
|
||||
"placeholders": {
|
||||
"amount": { "type": "String" },
|
||||
"unit": { "type": "String" }
|
||||
}
|
||||
},
|
||||
"noTokensForMnemonic": "Keine Tokens mit diesem Mnemonic verknüpft gefunden.",
|
||||
|
||||
"noConnectedMints": "Keine verbundenen Mints",
|
||||
"addMintToStart": "Füge einen Mint hinzu, um zu starten",
|
||||
"addMint": "Mint hinzufügen",
|
||||
"mintDeleted": "Mint gelöscht",
|
||||
"activeMintUpdated": "Aktiver Mint aktualisiert",
|
||||
"mintUrl": "Mint URL:",
|
||||
"mintUrlPlaceholder": "https://mint.example.com",
|
||||
"urlMustStartWithHttps": "URL muss mit https:// beginnen",
|
||||
"connectingToMint": "Verbinde mit Mint...",
|
||||
"mintAddedSuccessfully": "Mint erfolgreich hinzugefügt",
|
||||
"couldNotConnectToMint": "Verbindung zum Mint fehlgeschlagen",
|
||||
"add": "Hinzufügen",
|
||||
|
||||
"success": "Erfolg",
|
||||
"loading": "Laden...",
|
||||
"retry": "Erneut versuchen",
|
||||
|
||||
"activeMint": "Aktiver Mint",
|
||||
"mintMessage": "Mint Nachricht",
|
||||
"url": "URL",
|
||||
"currency": "Währung",
|
||||
"unknown": "Unbekannt",
|
||||
"useThisMint": "Diesen Mint verwenden",
|
||||
"copyMintUrl": "Mint URL kopieren",
|
||||
"deleteMint": "Mint löschen",
|
||||
"copied": "{label} kopiert",
|
||||
"@copied": {
|
||||
"placeholders": {
|
||||
"label": { "type": "String" }
|
||||
}
|
||||
},
|
||||
"deleteMintConfirmTitle": "Mint löschen",
|
||||
"deleteMintConfirmMessage": "Wenn du Guthaben auf diesem Mint hast, geht es verloren. Bist du sicher?",
|
||||
"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",
|
||||
"@notesSelected": {
|
||||
"placeholders": {
|
||||
"count": { "type": "int" }
|
||||
}
|
||||
},
|
||||
"loadingProofsError": "Fehler beim Laden der Beweise: {error}",
|
||||
"@loadingProofsError": {
|
||||
"placeholders": {
|
||||
"error": { "type": "String" }
|
||||
}
|
||||
},
|
||||
"creatingTokenError": "Fehler beim Erstellen des Tokens: {error}",
|
||||
"@creatingTokenError": {
|
||||
"placeholders": {
|
||||
"error": { "type": "String" }
|
||||
}
|
||||
},
|
||||
|
||||
"unknownState": "Unbekannter Zustand",
|
||||
"depositAmountTitle": "{amount} {unit} einzahlen",
|
||||
"@depositAmountTitle": {
|
||||
"placeholders": {
|
||||
"amount": { "type": "String" },
|
||||
"unit": { "type": "String" }
|
||||
}
|
||||
},
|
||||
|
||||
"receiveNow": "Jetzt empfangen",
|
||||
"receiveLater": "Später empfangen",
|
||||
"tokenSavedForLater": "Token zum späteren Einlösen gespeichert",
|
||||
"noConnectionTokenSaved": "Keine Verbindung. Token zum späteren Einlösen gespeichert.",
|
||||
"unknownMintOffline": "Dieser Token stammt von einem unbekannten Mint. Verbinde dich mit dem Internet, um ihn hinzuzufügen und den Token einzulösen.",
|
||||
"noConnectionTryLater": "Keine Verbindung zum Mint. Versuche es später erneut.",
|
||||
"saveTokenError": "Fehler beim Speichern des Tokens. Bitte erneut versuchen.",
|
||||
"pendingTokenLimitReached": "Limit für ausstehende Tokens erreicht (max 50)",
|
||||
"filterToReceive": "Zu empfangen",
|
||||
"noPendingTokens": "Keine ausstehenden Tokens",
|
||||
"noPendingTokensHint": "Speichere Tokens zum späteren Einlösen",
|
||||
"pendingBadge": "AUSSTEHEND",
|
||||
"expiresInDays": "{days, plural, =1{Läuft in 1 Tag ab} other{Läuft in {days} Tagen ab}}",
|
||||
"@expiresInDays": {
|
||||
"placeholders": {
|
||||
"days": { "type": "int" }
|
||||
}
|
||||
},
|
||||
"retryCount": "{count} Versuche",
|
||||
"@retryCount": {
|
||||
"placeholders": {
|
||||
"count": { "type": "int" }
|
||||
}
|
||||
},
|
||||
"claimNow": "Jetzt einlösen",
|
||||
"pendingTokenClaimedSuccess": "{amount} {unit} eingelöst",
|
||||
"@pendingTokenClaimedSuccess": {
|
||||
"placeholders": {
|
||||
"amount": { "type": "String" },
|
||||
"unit": { "type": "String" }
|
||||
}
|
||||
},
|
||||
"pendingTokensClaimed": "{count, plural, =1{1 Token eingelöst ({amount} {unit})} other{{count} Tokens eingelöst ({amount} {unit})}}",
|
||||
"@pendingTokensClaimed": {
|
||||
"placeholders": {
|
||||
"count": { "type": "int" },
|
||||
"amount": { "type": "String" },
|
||||
"unit": { "type": "String" }
|
||||
}
|
||||
},
|
||||
|
||||
"scan": "Scannen",
|
||||
"scanQrCode": "QR scannen",
|
||||
"scanCashuToken": "Cashu Token scannen",
|
||||
"scanLightningInvoice": "Rechnung scannen",
|
||||
"scanningAnimatedQr": "Animierten QR scannen...",
|
||||
"pointCameraAtQr": "Richte die Kamera auf den QR-Code",
|
||||
"pointCameraAtCashuQr": "Richte die Kamera auf den Cashu Token QR",
|
||||
"pointCameraAtInvoiceQr": "Richte die Kamera auf den Rechnungs-QR",
|
||||
"unrecognizedQrCode": "Nicht erkannter QR-Code",
|
||||
"scanCashuTokenHint": "Scanne einen Cashu Token (cashuA... oder cashuB...)",
|
||||
"scanLightningInvoiceHint": "Scanne eine Lightning Rechnung (lnbc...)",
|
||||
"addMintQuestion": "Diesen Mint hinzufügen?",
|
||||
"cameraPermissionDenied": "Kamera-Berechtigung verweigert",
|
||||
"paymentRequestTitle": "Zahlungsanfrage",
|
||||
"paymentRequestFrom": "Anfrage von",
|
||||
"paymentRequestAmount": "Angeforderter Betrag",
|
||||
"paymentRequestDescription": "Beschreibung",
|
||||
"paymentRequestMints": "Akzeptierte Mints",
|
||||
"paymentRequestAnyMint": "Jeder Mint",
|
||||
"paymentRequestPay": "Bezahlen",
|
||||
"paymentRequestPaying": "Bezahle...",
|
||||
"paymentRequestSuccess": "Zahlung erfolgreich gesendet",
|
||||
"paymentRequestNoTransport": "Diese Anfrage hat keine konfigurierte Zustellmethode",
|
||||
"paymentRequestTransport": "Transport",
|
||||
"paymentRequestMintNotAccepted": "Dein aktiver Mint ist nicht in der Liste der akzeptierten Mints",
|
||||
"paymentRequestUnitMismatch": "Inkompatible Einheit: Anfrage erfordert {unit}",
|
||||
"paymentRequestInsufficientBalance": "Unzureichendes Guthaben",
|
||||
"paymentRequestErrorParsing": "Fehler beim Lesen der Zahlungsanfrage",
|
||||
|
||||
"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",
|
||||
|
||||
"request": "Anfordern",
|
||||
"requestPayment": "Zahlung anfordern",
|
||||
"requestPaymentDescription": "Einheitliche Zahlungsanforderung erstellen",
|
||||
"generateRequest": "Anforderung erstellen",
|
||||
"generatingRequest": "Anforderung wird erstellt...",
|
||||
"requestPaymentReceived": "Zahlung erhalten",
|
||||
"requestDescriptionHint": "Beschreibung (optional)",
|
||||
"universal": "Universal",
|
||||
"copiedToClipboard": "In die Zwischenablage kopiert"
|
||||
}
|
||||
+389
-7
@@ -3,9 +3,17 @@
|
||||
|
||||
"appName": "ElCaju",
|
||||
"appTagline": "Your private ecash wallet",
|
||||
"loadingMessage1": "Encrypting your coins...",
|
||||
"loadingMessage2": "Preparing your e-tokens...",
|
||||
"loadingMessage3": "Connecting to the Mint...",
|
||||
"loadingMessage4": "Privacy by default.",
|
||||
"loadingMessage5": "Blind signing tokens...",
|
||||
"loadingMessage6": "Go full Calle...",
|
||||
"loadingMessage7": "Cashu + Bitchat = Privacy + Freedom",
|
||||
"aboutTagline": "Privacy without borders.",
|
||||
|
||||
"welcomeTitle": "Welcome to ElCaju",
|
||||
"welcomeSubtitle": "Your private ecash wallet.\nSimple, secure and non-custodial.",
|
||||
"welcomeSubtitle": "Cashu for the world. Made in Cuba.",
|
||||
|
||||
"createWallet": "Create new wallet",
|
||||
"restoreWallet": "Restore wallet",
|
||||
@@ -39,36 +47,410 @@
|
||||
"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",
|
||||
"receive": "Receive",
|
||||
"send": "Send",
|
||||
"sendAction": "Send ↗",
|
||||
"receiveAction": "↘ Receive",
|
||||
"deposit": "Deposit",
|
||||
"withdraw": "Withdraw",
|
||||
"lightning": "Lightning",
|
||||
"cashu": "Cashu",
|
||||
"ecash": "Ecash",
|
||||
"history": "History",
|
||||
"noTransactions": "No transactions yet",
|
||||
"depositOrReceive": "Deposit or receive sats to start",
|
||||
"noMint": "No mint",
|
||||
|
||||
"balance": "Balance",
|
||||
"sats": "sats",
|
||||
|
||||
"pasteEcashToken": "Paste ecash token",
|
||||
"generateInvoiceToDeposit": "Generate invoice to deposit",
|
||||
"createEcashToken": "Create ecash token",
|
||||
"payLightningInvoice": "Pay Lightning invoice",
|
||||
|
||||
"receiveCashu": "Receive Cashu",
|
||||
"pasteTheCashuToken": "Paste the Cashu token:",
|
||||
"pasteFromClipboard": "Paste from clipboard",
|
||||
"validToken": "Valid token",
|
||||
"invalidToken": "Invalid or malformed token",
|
||||
"amount": "Amount:",
|
||||
"mint": "Mint:",
|
||||
"claiming": "Claiming...",
|
||||
"claimTokens": "Claim tokens",
|
||||
"tokensReceived": "Tokens received",
|
||||
"backToHome": "Back to home",
|
||||
"tokenAlreadyClaimed": "This token was already claimed",
|
||||
"unknownMint": "Token from unknown mint",
|
||||
"claimError": "Claim error: {error}",
|
||||
|
||||
"sendCashu": "Send Cashu",
|
||||
"selectNotesManually": "Select notes manually",
|
||||
"amountToSend": "Amount to send:",
|
||||
"available": "Available:",
|
||||
"max": "(Max)",
|
||||
"memoOptional": "Memo (optional):",
|
||||
"memoPlaceholder": "What is this payment for?",
|
||||
"creatingToken": "Creating token...",
|
||||
"createToken": "Create token",
|
||||
"noActiveMint": "No active mint",
|
||||
"offlineModeMessage": "No connection. Using offline mode...",
|
||||
"confirmSend": "Confirm send",
|
||||
"confirm": "Confirm",
|
||||
"cancel": "Cancel",
|
||||
"insufficientBalance": "Insufficient balance",
|
||||
"tokenCreationError": "Error creating token: {error}",
|
||||
|
||||
"tokenCreated": "Token created",
|
||||
"copy": "Copy",
|
||||
"share": "Share",
|
||||
"tokenCashu": "Cashu Token",
|
||||
"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):",
|
||||
"depositPlaceholder": "What is this deposit for?",
|
||||
"generating": "Generating...",
|
||||
"generateInvoice": "Generate invoice",
|
||||
"depositLightning": "Deposit Lightning",
|
||||
|
||||
"payInvoiceTitle": "Pay invoice",
|
||||
"generatingInvoice": "Generating invoice...",
|
||||
"waitingForPayment": "Waiting for payment...",
|
||||
"paymentReceived": "Payment received",
|
||||
"tokensIssued": "Tokens issued!",
|
||||
"error": "Error",
|
||||
"unknownError": "Unknown error",
|
||||
"back": "Back",
|
||||
"copyInvoice": "Copy invoice",
|
||||
"description": "Description:",
|
||||
"invoiceCopiedToClipboard": "Invoice copied to clipboard",
|
||||
"deposited": "{amount} {unit} deposited",
|
||||
|
||||
"pasteLightningInvoice": "Paste the Lightning invoice:",
|
||||
"gettingQuote": "Getting quote...",
|
||||
"validInvoice": "Valid invoice",
|
||||
"invalidInvoice": "Invalid invoice",
|
||||
"invalidInvoiceMalformed": "Invalid or malformed invoice",
|
||||
"feeReserved": "Fee reserved:",
|
||||
"total": "Total:",
|
||||
"paying": "Paying...",
|
||||
"payInvoice": "Pay invoice",
|
||||
"confirmPayment": "Confirm payment",
|
||||
"pay": "Pay",
|
||||
"fee": "fee",
|
||||
"invoiceExpired": "Invoice expired",
|
||||
"amountOutOfRange": "Amount out of allowed range",
|
||||
"resolvingType": "Resolving {type}...",
|
||||
"@resolvingType": {
|
||||
"placeholders": {
|
||||
"type": { "type": "String" }
|
||||
}
|
||||
},
|
||||
"invoiceAlreadyPaid": "Invoice already paid",
|
||||
"paymentError": "Payment error: {error}",
|
||||
"sent": "{amount} {unit} sent",
|
||||
|
||||
"filterAll": "All",
|
||||
"filterPending": "Pending",
|
||||
"filterEcash": "Ecash",
|
||||
"filterLightning": "Lightning",
|
||||
"receiveTokensToStart": "Receive Cashu tokens to start",
|
||||
"noPendingTransactions": "No pending transactions",
|
||||
"allTransactionsCompleted": "All your transactions are completed",
|
||||
"noEcashTransactions": "No Ecash transactions",
|
||||
"sendOrReceiveTokens": "Send or receive Cashu tokens",
|
||||
"noLightningTransactions": "No Lightning transactions",
|
||||
"depositOrWithdrawLightning": "Deposit or withdraw via Lightning",
|
||||
"pendingStatus": "Pending",
|
||||
"receivedStatus": "Received",
|
||||
"sentStatus": "Sent",
|
||||
"now": "Now",
|
||||
"agoMinutes": "{minutes} min ago",
|
||||
"agoHours": "{hours} h ago",
|
||||
"agoDays": "{days} days ago",
|
||||
"lightningInvoice": "Lightning Invoice",
|
||||
"receivedEcash": "Received Ecash",
|
||||
"sentEcash": "Sent Ecash",
|
||||
"outgoingLightningPayment": "Outgoing Lightning Payment",
|
||||
"invoiceNotAvailable": "Invoice not available",
|
||||
"tokenNotAvailable": "Token not available",
|
||||
"unit": "Unit",
|
||||
"status": "Status",
|
||||
"pending": "Pending",
|
||||
"memo": "Memo",
|
||||
"copyInvoiceButton": "COPY INVOICE",
|
||||
"copyButton": "COPY",
|
||||
"invoiceCopied": "Invoice copied",
|
||||
"tokenCopied": "Token copied",
|
||||
"speed": "SPEED:",
|
||||
|
||||
"settings": "Settings",
|
||||
"walletSection": "WALLET",
|
||||
"backupSeedPhrase": "Backup seed phrase",
|
||||
"viewRecoveryWords": "View your recovery words",
|
||||
"connectedMints": "Connected mints",
|
||||
"manageCashuMints": "Manage your Cashu mints",
|
||||
"pinAccess": "PIN access",
|
||||
"pinEnabled": "Enabled",
|
||||
"protectWithPin": "Protect the app with PIN",
|
||||
"recoverTokens": "Recover tokens",
|
||||
"scanMintsWithSeed": "Scan mints with seed phrase",
|
||||
"appearanceSection": "LANGUAGE",
|
||||
"language": "Language",
|
||||
"spanish": "Español",
|
||||
"english": "English",
|
||||
"informationSection": "INFORMATION",
|
||||
"version": "Version",
|
||||
"about": "About",
|
||||
"deleteWallet": "Delete wallet",
|
||||
"spanish": "Español",
|
||||
"english": "English",
|
||||
"portuguese": "Português",
|
||||
"french": "Français",
|
||||
"russian": "Русский",
|
||||
"german": "Deutsch",
|
||||
"italian": "Italiano",
|
||||
"korean": "한국어",
|
||||
"chinese": "中文",
|
||||
"japanese": "日本語",
|
||||
"swahili": "Kiswahili",
|
||||
|
||||
"mnemonicNotFound": "Mnemonic not found",
|
||||
"createPin": "Create PIN",
|
||||
"enterPinDigits": "Enter a 4-digit PIN",
|
||||
"confirmPin": "Confirm PIN",
|
||||
"enterPinAgain": "Enter the PIN again",
|
||||
"pinMismatch": "PINs do not match",
|
||||
"pinActivated": "PIN activated",
|
||||
"pinDeactivated": "PIN deactivated",
|
||||
"verifyPin": "Verify PIN",
|
||||
"enterCurrentPin": "Enter your current PIN",
|
||||
"incorrectPin": "Incorrect PIN",
|
||||
"selectLanguage": "Select language",
|
||||
"languageChanged": "Language changed to {language}",
|
||||
"close": "Close",
|
||||
"aboutDescription": "A Cashu wallet with Cuban DNA for the entire world. Brother of LaChispa.",
|
||||
"couldNotOpenLink": "Could not open link",
|
||||
|
||||
"deleteWalletQuestion": "Delete wallet?",
|
||||
"actionIrreversible": "This action is irreversible",
|
||||
"deleteWalletWarning": "All data will be deleted including your seed phrase and tokens. Make sure you have a backup.",
|
||||
"typeDeleteToConfirm": "Type \"DELETE\" to confirm:",
|
||||
"deleteConfirmWord": "DELETE",
|
||||
"deleteError": "Delete error: {error}",
|
||||
|
||||
"recoverTokensTitle": "Recover tokens",
|
||||
"recoverTokensDescription": "Scan mints to recover tokens associated with your seed phrase (NUT-13)",
|
||||
"useCurrentSeedPhrase": "Use my current seed phrase",
|
||||
"scanWithSavedWords": "Scan mints with the 12 saved words",
|
||||
"useOtherSeedPhrase": "Use another seed phrase",
|
||||
"recoverFromOtherWords": "Recover tokens from other 12 words",
|
||||
"mintsToScan": "Mints to scan:",
|
||||
"allMints": "All mints ({count})",
|
||||
"specificMint": "A specific mint",
|
||||
"enterMnemonicWords": "Enter the 12 words separated by spaces...",
|
||||
"scanMints": "Scan mints",
|
||||
"selectMintToScan": "Select a mint to scan",
|
||||
"mnemonicMustHaveWords": "Mnemonic must have 12 or 24 words",
|
||||
"noConnectedMintsToScan": "No connected mints to scan",
|
||||
"recoveredTokens": "Recovered {tokens} from {mints} mint(s)!",
|
||||
"scanCompleteNoTokens": "Scan complete. No new tokens found.",
|
||||
"mintsWithError": "({count} mint(s) with error)",
|
||||
"recoveredFromMint": "Recovered {tokens} from {mint}!",
|
||||
"noTokensFoundInMint": "No tokens found in {mint}.",
|
||||
"recoveredAndTransferred": "Recovered and transferred {amount} {unit} to your wallet!",
|
||||
"noTokensForMnemonic": "No tokens found associated with that mnemonic.",
|
||||
|
||||
"noConnectedMints": "No connected mints",
|
||||
"addMintToStart": "Add a mint to start",
|
||||
"addMint": "Add mint",
|
||||
"mintDeleted": "Mint deleted",
|
||||
"activeMintUpdated": "Active mint updated",
|
||||
"mintUrl": "Mint URL:",
|
||||
"mintUrlPlaceholder": "https://mint.example.com",
|
||||
"urlMustStartWithHttps": "URL must start with https://",
|
||||
"connectingToMint": "Connecting to mint...",
|
||||
"mintAddedSuccessfully": "Mint added successfully",
|
||||
"couldNotConnectToMint": "Could not connect to mint",
|
||||
"add": "Add",
|
||||
|
||||
"confirm": "Confirm",
|
||||
"cancel": "Cancel",
|
||||
"error": "Error",
|
||||
"success": "Success",
|
||||
"loading": "Loading...",
|
||||
"retry": "Retry"
|
||||
"retry": "Retry",
|
||||
|
||||
"activeMint": "Active mint",
|
||||
"mintMessage": "Mint Message",
|
||||
"url": "URL",
|
||||
"currency": "Currency",
|
||||
"unknown": "Unknown",
|
||||
"useThisMint": "Use this mint",
|
||||
"copyMintUrl": "Copy mint URL",
|
||||
"deleteMint": "Delete mint",
|
||||
"copied": "{label} copied",
|
||||
"deleteMintConfirmTitle": "Delete mint",
|
||||
"deleteMintConfirmMessage": "If you have balance in this mint, it will be lost. Are you sure?",
|
||||
"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",
|
||||
"loadingProofsError": "Error loading proofs: {error}",
|
||||
"creatingTokenError": "Error creating token: {error}",
|
||||
|
||||
"unknownState": "Unknown state",
|
||||
"depositAmountTitle": "Deposit {amount} {unit}",
|
||||
"@depositAmountTitle": {
|
||||
"placeholders": {
|
||||
"amount": { "type": "String" },
|
||||
"unit": { "type": "String" }
|
||||
}
|
||||
},
|
||||
|
||||
"receiveNow": "Receive now",
|
||||
"receiveLater": "Receive later",
|
||||
"tokenSavedForLater": "Token saved to claim later",
|
||||
"noConnectionTokenSaved": "No connection. Token saved to claim later.",
|
||||
"unknownMintOffline": "This token is from an unknown mint. Connect to the internet to add it and claim the token.",
|
||||
"noConnectionTryLater": "No connection to mint. Try again later.",
|
||||
"saveTokenError": "Error saving token. Please try again.",
|
||||
"pendingTokenLimitReached": "Pending tokens limit reached (max 50)",
|
||||
"filterToReceive": "To receive",
|
||||
"noPendingTokens": "No pending tokens",
|
||||
"noPendingTokensHint": "Save tokens to claim later",
|
||||
"pendingBadge": "PENDING",
|
||||
"expiresInDays": "{days, plural, =1{Expires in 1 day} other{Expires in {days} days}}",
|
||||
"@expiresInDays": {
|
||||
"placeholders": {
|
||||
"days": { "type": "int" }
|
||||
}
|
||||
},
|
||||
"retryCount": "{count} retries",
|
||||
"@retryCount": {
|
||||
"placeholders": {
|
||||
"count": { "type": "int" }
|
||||
}
|
||||
},
|
||||
"claimNow": "Claim now",
|
||||
"pendingTokenClaimedSuccess": "Claimed {amount} {unit}",
|
||||
"@pendingTokenClaimedSuccess": {
|
||||
"placeholders": {
|
||||
"amount": { "type": "String" },
|
||||
"unit": { "type": "String" }
|
||||
}
|
||||
},
|
||||
"pendingTokensClaimed": "{count, plural, =1{Claimed 1 token ({amount} {unit})} other{Claimed {count} tokens ({amount} {unit})}}",
|
||||
"@pendingTokensClaimed": {
|
||||
"placeholders": {
|
||||
"count": { "type": "int" },
|
||||
"amount": { "type": "String" },
|
||||
"unit": { "type": "String" }
|
||||
}
|
||||
},
|
||||
|
||||
"scan": "Scan",
|
||||
"scanQrCode": "Scan QR Code",
|
||||
"scanCashuToken": "Scan Cashu Token",
|
||||
"scanLightningInvoice": "Scan Invoice",
|
||||
"scanningAnimatedQr": "Scanning animated QR...",
|
||||
"pointCameraAtQr": "Point the camera at the QR code",
|
||||
"pointCameraAtCashuQr": "Point the camera at the Cashu token QR",
|
||||
"pointCameraAtInvoiceQr": "Point the camera at the invoice QR",
|
||||
"unrecognizedQrCode": "Unrecognized QR code",
|
||||
"scanCashuTokenHint": "Scan a Cashu token (cashuA... or cashuB...)",
|
||||
"scanLightningInvoiceHint": "Scan a Lightning invoice (lnbc...)",
|
||||
"addMintQuestion": "Add this mint?",
|
||||
"cameraPermissionDenied": "Camera permission denied",
|
||||
"paymentRequestTitle": "Payment Request",
|
||||
"paymentRequestFrom": "Request from",
|
||||
"paymentRequestAmount": "Requested amount",
|
||||
"paymentRequestDescription": "Description",
|
||||
"paymentRequestMints": "Accepted mints",
|
||||
"paymentRequestAnyMint": "Any mint",
|
||||
"paymentRequestPay": "Pay",
|
||||
"paymentRequestPaying": "Paying...",
|
||||
"paymentRequestSuccess": "Payment sent successfully",
|
||||
"paymentRequestNoTransport": "This request has no delivery method configured",
|
||||
"paymentRequestTransport": "Transport",
|
||||
"paymentRequestMintNotAccepted": "Your active mint is not in the list of accepted mints",
|
||||
"paymentRequestUnitMismatch": "Incompatible unit: request requires {unit}",
|
||||
"@paymentRequestUnitMismatch": {
|
||||
"placeholders": {
|
||||
"unit": { "type": "String" }
|
||||
}
|
||||
},
|
||||
"paymentRequestInsufficientBalance": "Insufficient balance",
|
||||
"paymentRequestErrorParsing": "Error reading payment request",
|
||||
|
||||
"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",
|
||||
|
||||
"request": "Request",
|
||||
"requestPayment": "Request Payment",
|
||||
"requestPaymentDescription": "Generate unified payment request",
|
||||
"generateRequest": "Generate Request",
|
||||
"generatingRequest": "Generating request...",
|
||||
"requestPaymentReceived": "Payment received",
|
||||
"requestDescriptionHint": "Description (optional)",
|
||||
"universal": "Universal",
|
||||
"copiedToClipboard": "Copied to clipboard"
|
||||
}
|
||||
|
||||
+501
-13
@@ -3,9 +3,17 @@
|
||||
|
||||
"appName": "ElCaju",
|
||||
"appTagline": "Tu wallet de ecash privado",
|
||||
"loadingMessage1": "Cifrando tus monedas...",
|
||||
"loadingMessage2": "Preparando tus e-tokens...",
|
||||
"loadingMessage3": "Conectando con el Mint...",
|
||||
"loadingMessage4": "Privacidad por defecto.",
|
||||
"loadingMessage5": "Firmando tokens ciegamente...",
|
||||
"loadingMessage6": "Go full Calle...",
|
||||
"loadingMessage7": "Cashu + Bitchat = Privacidad + Libertad",
|
||||
"aboutTagline": "Privacidad sin fronteras.",
|
||||
|
||||
"welcomeTitle": "Bienvenido a ElCaju",
|
||||
"welcomeSubtitle": "Tu wallet de ecash privado.\nSimple, seguro y sin custodia.",
|
||||
"welcomeSubtitle": "Cashu para el mundo. Hecho en Cuba.",
|
||||
|
||||
"createWallet": "Crear nueva wallet",
|
||||
"restoreWallet": "Restaurar wallet",
|
||||
@@ -40,49 +48,529 @@
|
||||
"wordCount": "{count} palabras",
|
||||
"@wordCount": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
"count": { "type": "int" }
|
||||
}
|
||||
},
|
||||
"needWords": "(necesitas 12 o 24)",
|
||||
"restoreScanningMint": "Escaneando mint en busca de tokens...",
|
||||
"restoreError": "Error al restaurar: {error}",
|
||||
"@restoreError": {
|
||||
"placeholders": {
|
||||
"error": {
|
||||
"type": "String"
|
||||
}
|
||||
"error": { "type": "String" }
|
||||
}
|
||||
},
|
||||
|
||||
"homeTitle": "Inicio",
|
||||
"receive": "Recibir",
|
||||
"send": "Enviar",
|
||||
"sendAction": "Enviar ↗",
|
||||
"receiveAction": "↘ Recibir",
|
||||
"deposit": "Depositar",
|
||||
"withdraw": "Retirar",
|
||||
"lightning": "Lightning",
|
||||
"cashu": "Cashu",
|
||||
"ecash": "Ecash",
|
||||
"history": "Historial",
|
||||
"noTransactions": "Sin transacciones aún",
|
||||
"depositOrReceive": "Deposita o recibe sats para empezar",
|
||||
"noMint": "Sin mint",
|
||||
|
||||
"balance": "Balance",
|
||||
"sats": "sats",
|
||||
|
||||
"pasteEcashToken": "Pegar token ecash",
|
||||
"generateInvoiceToDeposit": "Generar invoice para depositar",
|
||||
"createEcashToken": "Crear token ecash",
|
||||
"payLightningInvoice": "Pagar invoice Lightning",
|
||||
|
||||
"receiveCashu": "Recibir Cashu",
|
||||
"pasteTheCashuToken": "Pega el token Cashu:",
|
||||
"pasteFromClipboard": "Pegar del portapapeles",
|
||||
"validToken": "Token válido",
|
||||
"invalidToken": "Token inválido o malformado",
|
||||
"amount": "Monto:",
|
||||
"mint": "Mint:",
|
||||
"claiming": "Reclamando...",
|
||||
"claimTokens": "Reclamar tokens",
|
||||
"tokensReceived": "Tokens recibidos",
|
||||
"backToHome": "Volver al inicio",
|
||||
"tokenAlreadyClaimed": "Este token ya fue reclamado",
|
||||
"unknownMint": "Token de un mint desconocido",
|
||||
"claimError": "Error al reclamar: {error}",
|
||||
"@claimError": {
|
||||
"placeholders": {
|
||||
"error": { "type": "String" }
|
||||
}
|
||||
},
|
||||
|
||||
"sendCashu": "Enviar Cashu",
|
||||
"selectNotesManually": "Seleccionar notas manualmente",
|
||||
"amountToSend": "Monto a enviar:",
|
||||
"available": "Disponible:",
|
||||
"max": "(Max)",
|
||||
"memoOptional": "Memo (opcional):",
|
||||
"memoPlaceholder": "¿Para qué es este pago?",
|
||||
"creatingToken": "Creando token...",
|
||||
"createToken": "Crear token",
|
||||
"noActiveMint": "No hay mint activo",
|
||||
"offlineModeMessage": "Sin conexión. Usando modo offline...",
|
||||
"confirmSend": "Confirmar envío",
|
||||
"confirm": "Confirmar",
|
||||
"cancel": "Cancelar",
|
||||
"insufficientBalance": "Balance insuficiente",
|
||||
"tokenCreationError": "Error al crear token: {error}",
|
||||
"@tokenCreationError": {
|
||||
"placeholders": {
|
||||
"error": { "type": "String" }
|
||||
}
|
||||
},
|
||||
|
||||
"tokenCreated": "Token creado",
|
||||
"copy": "Copiar",
|
||||
"share": "Compartir",
|
||||
"tokenCashu": "Token Cashu",
|
||||
"tokenCashuAnimatedQr": "Token Cashu (QR animado - {fragments} fragmentos UR)",
|
||||
"@tokenCashuAnimatedQr": {
|
||||
"placeholders": {
|
||||
"fragments": { "type": "int" }
|
||||
}
|
||||
},
|
||||
"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):",
|
||||
"depositPlaceholder": "¿Para qué es esta recarga?",
|
||||
"generating": "Generando...",
|
||||
"generateInvoice": "Generar invoice",
|
||||
"depositLightning": "Depositar Lightning",
|
||||
|
||||
"payInvoiceTitle": "Pagar invoice",
|
||||
"generatingInvoice": "Generando invoice...",
|
||||
"waitingForPayment": "Esperando pago...",
|
||||
"paymentReceived": "Pago recibido",
|
||||
"tokensIssued": "Tokens emitidos!",
|
||||
"error": "Error",
|
||||
"unknownError": "Error desconocido",
|
||||
"back": "Volver",
|
||||
"copyInvoice": "Copiar invoice",
|
||||
"description": "Descripción:",
|
||||
"invoiceCopiedToClipboard": "Invoice copiado al portapapeles",
|
||||
"deposited": "{amount} {unit} depositados",
|
||||
"@deposited": {
|
||||
"placeholders": {
|
||||
"amount": { "type": "String" },
|
||||
"unit": { "type": "String" }
|
||||
}
|
||||
},
|
||||
|
||||
"pasteLightningInvoice": "Pega el invoice Lightning:",
|
||||
"gettingQuote": "Obteniendo quote...",
|
||||
"validInvoice": "Invoice válido",
|
||||
"invalidInvoice": "Invoice inválido",
|
||||
"invalidInvoiceMalformed": "Invoice inválido o malformado",
|
||||
"feeReserved": "Fee reservado:",
|
||||
"total": "Total:",
|
||||
"paying": "Pagando...",
|
||||
"payInvoice": "Pagar invoice",
|
||||
"confirmPayment": "Confirmar pago",
|
||||
"pay": "Pagar",
|
||||
"fee": "fee",
|
||||
"invoiceExpired": "Invoice expirado",
|
||||
"amountOutOfRange": "Monto fuera del rango permitido",
|
||||
"resolvingType": "Resolviendo {type}...",
|
||||
"@resolvingType": {
|
||||
"placeholders": {
|
||||
"type": { "type": "String" }
|
||||
}
|
||||
},
|
||||
"invoiceAlreadyPaid": "Invoice ya fue pagado",
|
||||
"paymentError": "Error al pagar: {error}",
|
||||
"@paymentError": {
|
||||
"placeholders": {
|
||||
"error": { "type": "String" }
|
||||
}
|
||||
},
|
||||
"sent": "{amount} {unit} enviados",
|
||||
"@sent": {
|
||||
"placeholders": {
|
||||
"amount": { "type": "String" },
|
||||
"unit": { "type": "String" }
|
||||
}
|
||||
},
|
||||
|
||||
"filterAll": "Todos",
|
||||
"filterPending": "Pendientes",
|
||||
"filterEcash": "Ecash",
|
||||
"filterLightning": "Lightning",
|
||||
"receiveTokensToStart": "Recibe tokens Cashu para empezar",
|
||||
"noPendingTransactions": "Sin transacciones pendientes",
|
||||
"allTransactionsCompleted": "Todas tus transacciones están completadas",
|
||||
"noEcashTransactions": "Sin transacciones Ecash",
|
||||
"sendOrReceiveTokens": "Envía o recibe tokens Cashu",
|
||||
"noLightningTransactions": "Sin transacciones Lightning",
|
||||
"depositOrWithdrawLightning": "Deposita o retira via Lightning",
|
||||
"pendingStatus": "Pendiente",
|
||||
"receivedStatus": "Recibido",
|
||||
"sentStatus": "Enviado",
|
||||
"now": "Ahora",
|
||||
"agoMinutes": "Hace {minutes} min",
|
||||
"@agoMinutes": {
|
||||
"placeholders": {
|
||||
"minutes": { "type": "int" }
|
||||
}
|
||||
},
|
||||
"agoHours": "Hace {hours} h",
|
||||
"@agoHours": {
|
||||
"placeholders": {
|
||||
"hours": { "type": "int" }
|
||||
}
|
||||
},
|
||||
"agoDays": "Hace {days} días",
|
||||
"@agoDays": {
|
||||
"placeholders": {
|
||||
"days": { "type": "int" }
|
||||
}
|
||||
},
|
||||
"lightningInvoice": "Invoice Lightning",
|
||||
"receivedEcash": "Ecash Recibido",
|
||||
"sentEcash": "Ecash Enviado",
|
||||
"outgoingLightningPayment": "Pago Lightning Saliente",
|
||||
"invoiceNotAvailable": "Invoice no disponible",
|
||||
"tokenNotAvailable": "Token no disponible",
|
||||
"unit": "Unidad",
|
||||
"status": "Estado",
|
||||
"pending": "Pendiente",
|
||||
"memo": "Memo",
|
||||
"copyInvoiceButton": "COPIAR INVOICE",
|
||||
"copyButton": "COPIAR",
|
||||
"invoiceCopied": "Invoice copiado",
|
||||
"tokenCopied": "Token copiado",
|
||||
"speed": "VELOCIDAD:",
|
||||
|
||||
"settings": "Configuración",
|
||||
"walletSection": "WALLET",
|
||||
"backupSeedPhrase": "Backup seed phrase",
|
||||
"viewRecoveryWords": "Ver tus palabras de recuperación",
|
||||
"connectedMints": "Mints conectados",
|
||||
"manageCashuMints": "Gestionar tus mints Cashu",
|
||||
"pinAccess": "PIN de acceso",
|
||||
"pinEnabled": "Activado",
|
||||
"protectWithPin": "Proteger la app con PIN",
|
||||
"recoverTokens": "Recuperar tokens",
|
||||
"scanMintsWithSeed": "Escanear mints con seed phrase",
|
||||
"appearanceSection": "IDIOMA",
|
||||
"language": "Idioma",
|
||||
"spanish": "Español",
|
||||
"english": "English",
|
||||
"informationSection": "INFORMACIÓN",
|
||||
"version": "Versión",
|
||||
"about": "Acerca de",
|
||||
"deleteWallet": "Borrar wallet",
|
||||
"spanish": "Español",
|
||||
"english": "English",
|
||||
"portuguese": "Português",
|
||||
"french": "Français",
|
||||
"russian": "Русский",
|
||||
"german": "Deutsch",
|
||||
"italian": "Italiano",
|
||||
"korean": "한국어",
|
||||
"chinese": "中文",
|
||||
"japanese": "日本語",
|
||||
"swahili": "Kiswahili",
|
||||
|
||||
"mnemonicNotFound": "No se encontró el mnemonic",
|
||||
"createPin": "Crear PIN",
|
||||
"enterPinDigits": "Ingresa un PIN de 4 dígitos",
|
||||
"confirmPin": "Confirmar PIN",
|
||||
"enterPinAgain": "Ingresa el PIN nuevamente",
|
||||
"pinMismatch": "Los PIN no coinciden",
|
||||
"pinActivated": "PIN activado",
|
||||
"pinDeactivated": "PIN desactivado",
|
||||
"verifyPin": "Verificar PIN",
|
||||
"enterCurrentPin": "Ingresa tu PIN actual",
|
||||
"incorrectPin": "PIN incorrecto",
|
||||
"selectLanguage": "Seleccionar idioma",
|
||||
"languageChanged": "Idioma cambiado a {language}",
|
||||
"@languageChanged": {
|
||||
"placeholders": {
|
||||
"language": { "type": "String" }
|
||||
}
|
||||
},
|
||||
"close": "Cerrar",
|
||||
"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?",
|
||||
"actionIrreversible": "Esta acción es irreversible",
|
||||
"deleteWalletWarning": "Se eliminarán todos los datos incluyendo tu seed phrase y tokens. Asegúrate de tener un backup.",
|
||||
"typeDeleteToConfirm": "Escribe \"BORRAR\" para confirmar:",
|
||||
"deleteConfirmWord": "BORRAR",
|
||||
"deleteError": "Error al borrar: {error}",
|
||||
"@deleteError": {
|
||||
"placeholders": {
|
||||
"error": { "type": "String" }
|
||||
}
|
||||
},
|
||||
|
||||
"recoverTokensTitle": "Recuperar tokens",
|
||||
"recoverTokensDescription": "Escanea los mints para recuperar tokens asociados a tu seed phrase (NUT-13)",
|
||||
"useCurrentSeedPhrase": "Usar mi seed phrase actual",
|
||||
"scanWithSavedWords": "Escanear mints con las 12 palabras guardadas",
|
||||
"useOtherSeedPhrase": "Usar otra seed phrase",
|
||||
"recoverFromOtherWords": "Recuperar tokens de otras 12 palabras",
|
||||
"mintsToScan": "Mints a escanear:",
|
||||
"allMints": "Todos los mints ({count})",
|
||||
"@allMints": {
|
||||
"placeholders": {
|
||||
"count": { "type": "int" }
|
||||
}
|
||||
},
|
||||
"specificMint": "Un mint específico",
|
||||
"enterMnemonicWords": "Ingresa las 12 palabras separadas por espacios...",
|
||||
"scanMints": "Escanear mints",
|
||||
"selectMintToScan": "Selecciona un mint para escanear",
|
||||
"mnemonicMustHaveWords": "El mnemonic debe tener 12 o 24 palabras",
|
||||
"noConnectedMintsToScan": "No hay mints conectados para escanear",
|
||||
"recoveredTokens": "¡Recuperados {tokens} de {mints} mint(s)!",
|
||||
"@recoveredTokens": {
|
||||
"placeholders": {
|
||||
"tokens": { "type": "String" },
|
||||
"mints": { "type": "int" }
|
||||
}
|
||||
},
|
||||
"scanCompleteNoTokens": "Escaneo completado. No se encontraron tokens nuevos.",
|
||||
"mintsWithError": "({count} mint(s) con error)",
|
||||
"@mintsWithError": {
|
||||
"placeholders": {
|
||||
"count": { "type": "int" }
|
||||
}
|
||||
},
|
||||
"recoveredFromMint": "¡Recuperados {tokens} de {mint}!",
|
||||
"@recoveredFromMint": {
|
||||
"placeholders": {
|
||||
"tokens": { "type": "String" },
|
||||
"mint": { "type": "String" }
|
||||
}
|
||||
},
|
||||
"noTokensFoundInMint": "No se encontraron tokens en {mint}.",
|
||||
"@noTokensFoundInMint": {
|
||||
"placeholders": {
|
||||
"mint": { "type": "String" }
|
||||
}
|
||||
},
|
||||
"recoveredAndTransferred": "¡Recuperados y transferidos {amount} {unit} a tu wallet!",
|
||||
"@recoveredAndTransferred": {
|
||||
"placeholders": {
|
||||
"amount": { "type": "String" },
|
||||
"unit": { "type": "String" }
|
||||
}
|
||||
},
|
||||
"noTokensForMnemonic": "No se encontraron tokens asociados a ese mnemonic.",
|
||||
|
||||
"noConnectedMints": "No hay mints conectados",
|
||||
"addMintToStart": "Agrega un mint para comenzar",
|
||||
"addMint": "Agregar mint",
|
||||
"mintDeleted": "Mint eliminado",
|
||||
"activeMintUpdated": "Mint activo actualizado",
|
||||
"mintUrl": "URL del mint:",
|
||||
"mintUrlPlaceholder": "https://mint.example.com",
|
||||
"urlMustStartWithHttps": "La URL debe comenzar con https://",
|
||||
"connectingToMint": "Conectando al mint...",
|
||||
"mintAddedSuccessfully": "Mint agregado correctamente",
|
||||
"couldNotConnectToMint": "No se pudo conectar al mint",
|
||||
"add": "Agregar",
|
||||
|
||||
"confirm": "Confirmar",
|
||||
"cancel": "Cancelar",
|
||||
"error": "Error",
|
||||
"success": "Éxito",
|
||||
"loading": "Cargando...",
|
||||
"retry": "Reintentar"
|
||||
"retry": "Reintentar",
|
||||
|
||||
"activeMint": "Mint activo",
|
||||
"mintMessage": "Mensaje del Mint",
|
||||
"url": "URL",
|
||||
"currency": "Moneda",
|
||||
"unknown": "Desconocido",
|
||||
"useThisMint": "Usar este mint",
|
||||
"copyMintUrl": "Copiar URL del mint",
|
||||
"deleteMint": "Eliminar mint",
|
||||
"copied": "{label} copiado",
|
||||
"@copied": {
|
||||
"placeholders": {
|
||||
"label": { "type": "String" }
|
||||
}
|
||||
},
|
||||
"deleteMintConfirmTitle": "Eliminar mint",
|
||||
"deleteMintConfirmMessage": "Si tienes balance en este mint, se perderá. ¿Estás seguro?",
|
||||
"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",
|
||||
"@notesSelected": {
|
||||
"placeholders": {
|
||||
"count": { "type": "int" }
|
||||
}
|
||||
},
|
||||
"loadingProofsError": "Error cargando proofs: {error}",
|
||||
"@loadingProofsError": {
|
||||
"placeholders": {
|
||||
"error": { "type": "String" }
|
||||
}
|
||||
},
|
||||
"creatingTokenError": "Error creando token: {error}",
|
||||
"@creatingTokenError": {
|
||||
"placeholders": {
|
||||
"error": { "type": "String" }
|
||||
}
|
||||
},
|
||||
|
||||
"unknownState": "Estado desconocido",
|
||||
"depositAmountTitle": "Depositar {amount} {unit}",
|
||||
"@depositAmountTitle": {
|
||||
"placeholders": {
|
||||
"amount": { "type": "String" },
|
||||
"unit": { "type": "String" }
|
||||
}
|
||||
},
|
||||
|
||||
"receiveNow": "Recibir ahora",
|
||||
"receiveLater": "Recibir después",
|
||||
"tokenSavedForLater": "Token guardado para reclamar después",
|
||||
"noConnectionTokenSaved": "Sin conexión. Token guardado para reclamar después.",
|
||||
"unknownMintOffline": "Este token es de un mint desconocido. Conéctate a internet para agregarlo y reclamar el token.",
|
||||
"noConnectionTryLater": "Sin conexión al mint. Intenta más tarde.",
|
||||
"saveTokenError": "Error al guardar el token. Intenta de nuevo.",
|
||||
"pendingTokenLimitReached": "Límite de tokens pendientes alcanzado (max 50)",
|
||||
"filterToReceive": "Para recibir",
|
||||
"noPendingTokens": "Sin tokens pendientes",
|
||||
"noPendingTokensHint": "Guarda tokens para reclamar después",
|
||||
"pendingBadge": "PENDIENTE",
|
||||
"expiresInDays": "{days, plural, =1{Expira en 1 día} other{Expira en {days} días}}",
|
||||
"@expiresInDays": {
|
||||
"placeholders": {
|
||||
"days": { "type": "int" }
|
||||
}
|
||||
},
|
||||
"retryCount": "{count} reintentos",
|
||||
"@retryCount": {
|
||||
"placeholders": {
|
||||
"count": { "type": "int" }
|
||||
}
|
||||
},
|
||||
"claimNow": "Reclamar ahora",
|
||||
"pendingTokenClaimedSuccess": "Reclamados {amount} {unit}",
|
||||
"@pendingTokenClaimedSuccess": {
|
||||
"placeholders": {
|
||||
"amount": { "type": "String" },
|
||||
"unit": { "type": "String" }
|
||||
}
|
||||
},
|
||||
"pendingTokensClaimed": "{count, plural, =1{Reclamado 1 token ({amount} {unit})} other{Reclamados {count} tokens ({amount} {unit})}}",
|
||||
"@pendingTokensClaimed": {
|
||||
"placeholders": {
|
||||
"count": { "type": "int" },
|
||||
"amount": { "type": "String" },
|
||||
"unit": { "type": "String" }
|
||||
}
|
||||
},
|
||||
|
||||
"scan": "Escanear",
|
||||
"scanQrCode": "Escanear QR",
|
||||
"scanCashuToken": "Escanear token Cashu",
|
||||
"scanLightningInvoice": "Escanear invoice",
|
||||
"scanningAnimatedQr": "Escaneando QR animado...",
|
||||
"pointCameraAtQr": "Apunta la cámara al código QR",
|
||||
"pointCameraAtCashuQr": "Apunta la cámara al QR del token Cashu",
|
||||
"pointCameraAtInvoiceQr": "Apunta la cámara al QR del invoice",
|
||||
"unrecognizedQrCode": "Código QR no reconocido",
|
||||
"scanCashuTokenHint": "Escanea un token Cashu (cashuA... o cashuB...)",
|
||||
"scanLightningInvoiceHint": "Escanea un invoice Lightning (lnbc...)",
|
||||
"addMintQuestion": "¿Agregar este mint?",
|
||||
"cameraPermissionDenied": "Permiso de cámara denegado",
|
||||
"paymentRequestTitle": "Solicitud de pago",
|
||||
"paymentRequestFrom": "Solicitud de",
|
||||
"paymentRequestAmount": "Monto solicitado",
|
||||
"paymentRequestDescription": "Descripción",
|
||||
"paymentRequestMints": "Mints aceptados",
|
||||
"paymentRequestAnyMint": "Cualquier mint",
|
||||
"paymentRequestPay": "Pagar",
|
||||
"paymentRequestPaying": "Pagando...",
|
||||
"paymentRequestSuccess": "Pago enviado correctamente",
|
||||
"paymentRequestNoTransport": "Esta solicitud no tiene método de entrega configurado",
|
||||
"paymentRequestTransport": "Transporte",
|
||||
"paymentRequestMintNotAccepted": "Tu mint activo no está en la lista de mints aceptados",
|
||||
"paymentRequestUnitMismatch": "Unidad incompatible: la solicitud requiere {unit}",
|
||||
"@paymentRequestUnitMismatch": {
|
||||
"placeholders": {
|
||||
"unit": { "type": "String" }
|
||||
}
|
||||
},
|
||||
"paymentRequestInsufficientBalance": "Balance insuficiente",
|
||||
"paymentRequestErrorParsing": "Error al leer la solicitud de pago",
|
||||
|
||||
"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",
|
||||
|
||||
"request": "Solicitar",
|
||||
"requestPayment": "Solicitar pago",
|
||||
"requestPaymentDescription": "Generar solicitud de pago unificada",
|
||||
"generateRequest": "Generar solicitud",
|
||||
"generatingRequest": "Generando solicitud...",
|
||||
"requestPaymentReceived": "Pago recibido",
|
||||
"requestDescriptionHint": "Descripción (opcional)",
|
||||
"universal": "Universal",
|
||||
"copiedToClipboard": "Copiado al portapapeles"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,563 @@
|
||||
{
|
||||
"@@locale": "fr",
|
||||
|
||||
"appName": "ElCaju",
|
||||
"appTagline": "Votre portefeuille ecash privé",
|
||||
"loadingMessage1": "Chiffrement de vos pièces...",
|
||||
"loadingMessage2": "Préparation de vos e-tokens...",
|
||||
"loadingMessage3": "Connexion au Mint...",
|
||||
"loadingMessage4": "Confidentialité par défaut.",
|
||||
"loadingMessage5": "Signature aveugle des tokens...",
|
||||
"loadingMessage6": "Go full Calle...",
|
||||
"loadingMessage7": "Cashu + Bitchat = Confidentialité + Liberté",
|
||||
"aboutTagline": "Confidentialité sans frontières.",
|
||||
|
||||
"welcomeTitle": "Bienvenue sur ElCaju",
|
||||
"welcomeSubtitle": "Cashu pour le monde. Fait à Cuba.",
|
||||
|
||||
"createWallet": "Créer un nouveau portefeuille",
|
||||
"restoreWallet": "Restaurer le portefeuille",
|
||||
|
||||
"createWalletTitle": "Créer un portefeuille",
|
||||
"creatingWallet": "Création de votre portefeuille...",
|
||||
"generatingSeed": "Génération sécurisée de votre phrase de récupération",
|
||||
"createWalletDescription": "Une phrase de récupération de 12 mots sera générée.\nConservez-la dans un endroit sûr.",
|
||||
"generateWallet": "Générer le portefeuille",
|
||||
|
||||
"walletCreated": "Portefeuille créé !",
|
||||
"walletCreatedDescription": "Votre portefeuille est prêt. Nous vous recommandons de sauvegarder votre phrase de récupération maintenant.",
|
||||
"backupWarning": "Sans sauvegarde, vous perdrez l'accès à vos fonds si vous perdez l'appareil.",
|
||||
"backupNow": "Sauvegarder maintenant",
|
||||
"backupLater": "Plus tard",
|
||||
|
||||
"backupTitle": "Sauvegarde",
|
||||
"seedPhraseTitle": "Votre phrase de récupération",
|
||||
"seedPhraseDescription": "Conservez ces 12 mots dans l'ordre. C'est le seul moyen de récupérer votre portefeuille.",
|
||||
"revealSeedPhrase": "Révéler la phrase de récupération",
|
||||
"tapToReveal": "Appuyez sur le bouton pour révéler\nvotre phrase de récupération",
|
||||
"copyToClipboard": "Copier dans le presse-papiers",
|
||||
"seedCopied": "Phrase copiée dans le presse-papiers",
|
||||
"neverShareSeed": "Ne partagez jamais votre phrase de récupération avec personne.",
|
||||
"confirmBackup": "J'ai sauvegardé ma phrase de récupération dans un endroit sûr",
|
||||
"continue_": "Continuer",
|
||||
|
||||
"restoreTitle": "Restaurer le portefeuille",
|
||||
"enterSeedPhrase": "Entrez votre phrase de récupération",
|
||||
"enterSeedDescription": "Tapez les 12 ou 24 mots séparés par des espaces.",
|
||||
"seedPlaceholder": "mot1 mot2 mot3 ...",
|
||||
"wordCount": "{count} mots",
|
||||
"@wordCount": {
|
||||
"placeholders": {
|
||||
"count": { "type": "int" }
|
||||
}
|
||||
},
|
||||
"needWords": "(vous avez besoin de 12 ou 24)",
|
||||
"restoreScanningMint": "Recherche de tokens sur le mint...",
|
||||
"restoreError": "Erreur de restauration : {error}",
|
||||
"@restoreError": {
|
||||
"placeholders": {
|
||||
"error": { "type": "String" }
|
||||
}
|
||||
},
|
||||
|
||||
"homeTitle": "Accueil",
|
||||
"receive": "Recevoir",
|
||||
"send": "Envoyer",
|
||||
"sendAction": "Envoyer ↗",
|
||||
"receiveAction": "↘ Recevoir",
|
||||
"deposit": "Déposer",
|
||||
"withdraw": "Retirer",
|
||||
"lightning": "Lightning",
|
||||
"cashu": "Cashu",
|
||||
"ecash": "Ecash",
|
||||
"history": "Historique",
|
||||
"noTransactions": "Aucune transaction",
|
||||
"depositOrReceive": "Déposez ou recevez des sats pour commencer",
|
||||
"noMint": "Aucun mint",
|
||||
|
||||
"balance": "Solde",
|
||||
"sats": "sats",
|
||||
|
||||
"pasteEcashToken": "Coller le token ecash",
|
||||
"generateInvoiceToDeposit": "Générer une facture pour déposer",
|
||||
"createEcashToken": "Créer un token ecash",
|
||||
"payLightningInvoice": "Payer une facture Lightning",
|
||||
|
||||
"receiveCashu": "Recevoir Cashu",
|
||||
"pasteTheCashuToken": "Collez le token Cashu :",
|
||||
"pasteFromClipboard": "Coller depuis le presse-papiers",
|
||||
"validToken": "Token valide",
|
||||
"invalidToken": "Token invalide ou malformé",
|
||||
"amount": "Montant :",
|
||||
"mint": "Mint :",
|
||||
"claiming": "Réclamation...",
|
||||
"claimTokens": "Réclamer les tokens",
|
||||
"tokensReceived": "Tokens reçus",
|
||||
"backToHome": "Retour à l'accueil",
|
||||
"tokenAlreadyClaimed": "Ce token a déjà été réclamé",
|
||||
"unknownMint": "Token d'un mint inconnu",
|
||||
"claimError": "Erreur de réclamation : {error}",
|
||||
"@claimError": {
|
||||
"placeholders": {
|
||||
"error": { "type": "String" }
|
||||
}
|
||||
},
|
||||
|
||||
"sendCashu": "Envoyer Cashu",
|
||||
"selectNotesManually": "Sélectionner les notes manuellement",
|
||||
"amountToSend": "Montant à envoyer :",
|
||||
"available": "Disponible :",
|
||||
"max": "(Max)",
|
||||
"memoOptional": "Mémo (optionnel) :",
|
||||
"memoPlaceholder": "À quoi sert ce paiement ?",
|
||||
"creatingToken": "Création du token...",
|
||||
"createToken": "Créer le token",
|
||||
"noActiveMint": "Aucun mint actif",
|
||||
"offlineModeMessage": "Pas de connexion. Mode hors ligne...",
|
||||
"confirmSend": "Confirmer l'envoi",
|
||||
"confirm": "Confirmer",
|
||||
"cancel": "Annuler",
|
||||
"insufficientBalance": "Solde insuffisant",
|
||||
"tokenCreationError": "Erreur de création du token : {error}",
|
||||
"@tokenCreationError": {
|
||||
"placeholders": {
|
||||
"error": { "type": "String" }
|
||||
}
|
||||
},
|
||||
|
||||
"tokenCreated": "Token créé",
|
||||
"copy": "Copier",
|
||||
"share": "Partager",
|
||||
"tokenCashu": "Token Cashu",
|
||||
"tokenCashuAnimatedQr": "Token Cashu (QR animé - {fragments} fragments UR)",
|
||||
"@tokenCashuAnimatedQr": {
|
||||
"placeholders": {
|
||||
"fragments": { "type": "int" }
|
||||
}
|
||||
},
|
||||
"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) :",
|
||||
"depositPlaceholder": "À quoi sert ce dépôt ?",
|
||||
"generating": "Génération...",
|
||||
"generateInvoice": "Générer la facture",
|
||||
"depositLightning": "Déposer Lightning",
|
||||
|
||||
"payInvoiceTitle": "Payer la facture",
|
||||
"generatingInvoice": "Génération de la facture...",
|
||||
"waitingForPayment": "En attente du paiement...",
|
||||
"paymentReceived": "Paiement reçu",
|
||||
"tokensIssued": "Tokens émis !",
|
||||
"error": "Erreur",
|
||||
"unknownError": "Erreur inconnue",
|
||||
"back": "Retour",
|
||||
"copyInvoice": "Copier la facture",
|
||||
"description": "Description :",
|
||||
"invoiceCopiedToClipboard": "Facture copiée dans le presse-papiers",
|
||||
"deposited": "{amount} {unit} déposés",
|
||||
"@deposited": {
|
||||
"placeholders": {
|
||||
"amount": { "type": "String" },
|
||||
"unit": { "type": "String" }
|
||||
}
|
||||
},
|
||||
|
||||
"pasteLightningInvoice": "Collez la facture Lightning :",
|
||||
"gettingQuote": "Obtention du devis...",
|
||||
"validInvoice": "Facture valide",
|
||||
"invalidInvoice": "Facture invalide",
|
||||
"invalidInvoiceMalformed": "Facture invalide ou malformée",
|
||||
"feeReserved": "Frais réservés :",
|
||||
"total": "Total :",
|
||||
"paying": "Paiement...",
|
||||
"payInvoice": "Payer la facture",
|
||||
"confirmPayment": "Confirmer le paiement",
|
||||
"pay": "Payer",
|
||||
"fee": "frais",
|
||||
"invoiceExpired": "Facture expirée",
|
||||
"amountOutOfRange": "Montant hors de la plage autorisée",
|
||||
"resolvingType": "Résolution de {type}...",
|
||||
"@resolvingType": {
|
||||
"placeholders": {
|
||||
"type": { "type": "String" }
|
||||
}
|
||||
},
|
||||
"invoiceAlreadyPaid": "Facture déjà payée",
|
||||
"paymentError": "Erreur de paiement : {error}",
|
||||
"@paymentError": {
|
||||
"placeholders": {
|
||||
"error": { "type": "String" }
|
||||
}
|
||||
},
|
||||
"sent": "{amount} {unit} envoyés",
|
||||
"@sent": {
|
||||
"placeholders": {
|
||||
"amount": { "type": "String" },
|
||||
"unit": { "type": "String" }
|
||||
}
|
||||
},
|
||||
|
||||
"filterAll": "Tous",
|
||||
"filterPending": "En attente",
|
||||
"filterEcash": "Ecash",
|
||||
"filterLightning": "Lightning",
|
||||
"receiveTokensToStart": "Recevez des tokens Cashu pour commencer",
|
||||
"noPendingTransactions": "Aucune transaction en attente",
|
||||
"allTransactionsCompleted": "Toutes vos transactions sont terminées",
|
||||
"noEcashTransactions": "Aucune transaction Ecash",
|
||||
"sendOrReceiveTokens": "Envoyez ou recevez des tokens Cashu",
|
||||
"noLightningTransactions": "Aucune transaction Lightning",
|
||||
"depositOrWithdrawLightning": "Déposez ou retirez via Lightning",
|
||||
"pendingStatus": "En attente",
|
||||
"receivedStatus": "Reçu",
|
||||
"sentStatus": "Envoyé",
|
||||
"now": "Maintenant",
|
||||
"agoMinutes": "Il y a {minutes} min",
|
||||
"@agoMinutes": {
|
||||
"placeholders": {
|
||||
"minutes": { "type": "int" }
|
||||
}
|
||||
},
|
||||
"agoHours": "Il y a {hours} h",
|
||||
"@agoHours": {
|
||||
"placeholders": {
|
||||
"hours": { "type": "int" }
|
||||
}
|
||||
},
|
||||
"agoDays": "Il y a {days} jours",
|
||||
"@agoDays": {
|
||||
"placeholders": {
|
||||
"days": { "type": "int" }
|
||||
}
|
||||
},
|
||||
"lightningInvoice": "Facture Lightning",
|
||||
"receivedEcash": "Ecash reçu",
|
||||
"sentEcash": "Ecash envoyé",
|
||||
"outgoingLightningPayment": "Paiement Lightning sortant",
|
||||
"invoiceNotAvailable": "Facture non disponible",
|
||||
"tokenNotAvailable": "Token non disponible",
|
||||
"unit": "Unité",
|
||||
"status": "Statut",
|
||||
"pending": "En attente",
|
||||
"memo": "Mémo",
|
||||
"copyInvoiceButton": "COPIER LA FACTURE",
|
||||
"copyButton": "COPIER",
|
||||
"invoiceCopied": "Facture copiée",
|
||||
"tokenCopied": "Token copié",
|
||||
"speed": "VITESSE :",
|
||||
|
||||
"settings": "Paramètres",
|
||||
"walletSection": "PORTEFEUILLE",
|
||||
"backupSeedPhrase": "Sauvegarder la phrase de récupération",
|
||||
"viewRecoveryWords": "Voir vos mots de récupération",
|
||||
"connectedMints": "Mints connectés",
|
||||
"manageCashuMints": "Gérer vos mints Cashu",
|
||||
"pinAccess": "Code PIN",
|
||||
"pinEnabled": "Activé",
|
||||
"protectWithPin": "Protéger l'app avec un PIN",
|
||||
"recoverTokens": "Récupérer les tokens",
|
||||
"scanMintsWithSeed": "Scanner les mints avec la phrase de récupération",
|
||||
"appearanceSection": "LANGUE",
|
||||
"language": "Langue",
|
||||
"informationSection": "INFORMATIONS",
|
||||
"version": "Version",
|
||||
"about": "À propos",
|
||||
"deleteWallet": "Supprimer le portefeuille",
|
||||
"spanish": "Español",
|
||||
"english": "English",
|
||||
"portuguese": "Português",
|
||||
"french": "Français",
|
||||
"russian": "Русский",
|
||||
"german": "Deutsch",
|
||||
"italian": "Italiano",
|
||||
"korean": "한국어",
|
||||
"chinese": "中文",
|
||||
"japanese": "日本語",
|
||||
"swahili": "Kiswahili",
|
||||
|
||||
"mnemonicNotFound": "Mnémonique non trouvé",
|
||||
"createPin": "Créer un PIN",
|
||||
"enterPinDigits": "Entrez un PIN à 4 chiffres",
|
||||
"confirmPin": "Confirmer le PIN",
|
||||
"enterPinAgain": "Entrez le PIN à nouveau",
|
||||
"pinMismatch": "Les PIN ne correspondent pas",
|
||||
"pinActivated": "PIN activé",
|
||||
"pinDeactivated": "PIN désactivé",
|
||||
"verifyPin": "Vérifier le PIN",
|
||||
"enterCurrentPin": "Entrez votre PIN actuel",
|
||||
"incorrectPin": "PIN incorrect",
|
||||
"selectLanguage": "Sélectionner la langue",
|
||||
"languageChanged": "Langue changée en {language}",
|
||||
"@languageChanged": {
|
||||
"placeholders": {
|
||||
"language": { "type": "String" }
|
||||
}
|
||||
},
|
||||
"close": "Fermer",
|
||||
"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 ?",
|
||||
"actionIrreversible": "Cette action est irréversible",
|
||||
"deleteWalletWarning": "Toutes les données seront supprimées, y compris votre phrase de récupération et vos tokens. Assurez-vous d'avoir une sauvegarde.",
|
||||
"typeDeleteToConfirm": "Tapez \"SUPPRIMER\" pour confirmer :",
|
||||
"deleteConfirmWord": "SUPPRIMER",
|
||||
"deleteError": "Erreur de suppression : {error}",
|
||||
"@deleteError": {
|
||||
"placeholders": {
|
||||
"error": { "type": "String" }
|
||||
}
|
||||
},
|
||||
|
||||
"recoverTokensTitle": "Récupérer les tokens",
|
||||
"recoverTokensDescription": "Scanner les mints pour récupérer les tokens associés à votre phrase de récupération (NUT-13)",
|
||||
"useCurrentSeedPhrase": "Utiliser ma phrase de récupération actuelle",
|
||||
"scanWithSavedWords": "Scanner les mints avec les 12 mots sauvegardés",
|
||||
"useOtherSeedPhrase": "Utiliser une autre phrase de récupération",
|
||||
"recoverFromOtherWords": "Récupérer les tokens d'autres 12 mots",
|
||||
"mintsToScan": "Mints à scanner :",
|
||||
"allMints": "Tous les mints ({count})",
|
||||
"@allMints": {
|
||||
"placeholders": {
|
||||
"count": { "type": "int" }
|
||||
}
|
||||
},
|
||||
"specificMint": "Un mint spécifique",
|
||||
"enterMnemonicWords": "Entrez les 12 mots séparés par des espaces...",
|
||||
"scanMints": "Scanner les mints",
|
||||
"selectMintToScan": "Sélectionnez un mint à scanner",
|
||||
"mnemonicMustHaveWords": "Le mnémonique doit avoir 12 ou 24 mots",
|
||||
"noConnectedMintsToScan": "Aucun mint connecté à scanner",
|
||||
"recoveredTokens": "Récupéré {tokens} de {mints} mint(s) !",
|
||||
"@recoveredTokens": {
|
||||
"placeholders": {
|
||||
"tokens": { "type": "String" },
|
||||
"mints": { "type": "int" }
|
||||
}
|
||||
},
|
||||
"scanCompleteNoTokens": "Scan terminé. Aucun nouveau token trouvé.",
|
||||
"mintsWithError": "({count} mint(s) avec erreur)",
|
||||
"@mintsWithError": {
|
||||
"placeholders": {
|
||||
"count": { "type": "int" }
|
||||
}
|
||||
},
|
||||
"recoveredFromMint": "Récupéré {tokens} de {mint} !",
|
||||
"@recoveredFromMint": {
|
||||
"placeholders": {
|
||||
"tokens": { "type": "String" },
|
||||
"mint": { "type": "String" }
|
||||
}
|
||||
},
|
||||
"noTokensFoundInMint": "Aucun token trouvé dans {mint}.",
|
||||
"@noTokensFoundInMint": {
|
||||
"placeholders": {
|
||||
"mint": { "type": "String" }
|
||||
}
|
||||
},
|
||||
"recoveredAndTransferred": "Récupéré et transféré {amount} {unit} vers votre portefeuille !",
|
||||
"@recoveredAndTransferred": {
|
||||
"placeholders": {
|
||||
"amount": { "type": "String" },
|
||||
"unit": { "type": "String" }
|
||||
}
|
||||
},
|
||||
"noTokensForMnemonic": "Aucun token trouvé associé à ce mnémonique.",
|
||||
|
||||
"noConnectedMints": "Aucun mint connecté",
|
||||
"addMintToStart": "Ajoutez un mint pour commencer",
|
||||
"addMint": "Ajouter un mint",
|
||||
"mintDeleted": "Mint supprimé",
|
||||
"activeMintUpdated": "Mint actif mis à jour",
|
||||
"mintUrl": "URL du mint :",
|
||||
"mintUrlPlaceholder": "https://mint.example.com",
|
||||
"urlMustStartWithHttps": "L'URL doit commencer par https://",
|
||||
"connectingToMint": "Connexion au mint...",
|
||||
"mintAddedSuccessfully": "Mint ajouté avec succès",
|
||||
"couldNotConnectToMint": "Impossible de se connecter au mint",
|
||||
"add": "Ajouter",
|
||||
|
||||
"success": "Succès",
|
||||
"loading": "Chargement...",
|
||||
"retry": "Réessayer",
|
||||
|
||||
"activeMint": "Mint actif",
|
||||
"mintMessage": "Message du Mint",
|
||||
"url": "URL",
|
||||
"currency": "Devise",
|
||||
"unknown": "Inconnu",
|
||||
"useThisMint": "Utiliser ce mint",
|
||||
"copyMintUrl": "Copier l'URL du mint",
|
||||
"deleteMint": "Supprimer le mint",
|
||||
"copied": "{label} copié",
|
||||
"@copied": {
|
||||
"placeholders": {
|
||||
"label": { "type": "String" }
|
||||
}
|
||||
},
|
||||
"deleteMintConfirmTitle": "Supprimer le mint",
|
||||
"deleteMintConfirmMessage": "Si vous avez un solde sur ce mint, il sera perdu. Êtes-vous sûr ?",
|
||||
"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",
|
||||
"@notesSelected": {
|
||||
"placeholders": {
|
||||
"count": { "type": "int" }
|
||||
}
|
||||
},
|
||||
"loadingProofsError": "Erreur de chargement des preuves : {error}",
|
||||
"@loadingProofsError": {
|
||||
"placeholders": {
|
||||
"error": { "type": "String" }
|
||||
}
|
||||
},
|
||||
"creatingTokenError": "Erreur de création du token : {error}",
|
||||
"@creatingTokenError": {
|
||||
"placeholders": {
|
||||
"error": { "type": "String" }
|
||||
}
|
||||
},
|
||||
|
||||
"unknownState": "État inconnu",
|
||||
"depositAmountTitle": "Déposer {amount} {unit}",
|
||||
"@depositAmountTitle": {
|
||||
"placeholders": {
|
||||
"amount": { "type": "String" },
|
||||
"unit": { "type": "String" }
|
||||
}
|
||||
},
|
||||
|
||||
"receiveNow": "Recevoir maintenant",
|
||||
"receiveLater": "Recevoir plus tard",
|
||||
"tokenSavedForLater": "Token sauvegardé pour réclamer plus tard",
|
||||
"noConnectionTokenSaved": "Pas de connexion. Token sauvegardé pour réclamer plus tard.",
|
||||
"unknownMintOffline": "Ce token provient d'un mint inconnu. Connectez-vous à Internet pour l'ajouter et réclamer le token.",
|
||||
"noConnectionTryLater": "Pas de connexion au mint. Réessayez plus tard.",
|
||||
"saveTokenError": "Erreur lors de la sauvegarde du token. Veuillez réessayer.",
|
||||
"pendingTokenLimitReached": "Limite de tokens en attente atteinte (max 50)",
|
||||
"filterToReceive": "À recevoir",
|
||||
"noPendingTokens": "Aucun token en attente",
|
||||
"noPendingTokensHint": "Sauvegardez des tokens pour les réclamer plus tard",
|
||||
"pendingBadge": "EN ATTENTE",
|
||||
"expiresInDays": "{days, plural, =1{Expire dans 1 jour} other{Expire dans {days} jours}}",
|
||||
"@expiresInDays": {
|
||||
"placeholders": {
|
||||
"days": { "type": "int" }
|
||||
}
|
||||
},
|
||||
"retryCount": "{count} tentatives",
|
||||
"@retryCount": {
|
||||
"placeholders": {
|
||||
"count": { "type": "int" }
|
||||
}
|
||||
},
|
||||
"claimNow": "Réclamer maintenant",
|
||||
"pendingTokenClaimedSuccess": "Réclamé {amount} {unit}",
|
||||
"@pendingTokenClaimedSuccess": {
|
||||
"placeholders": {
|
||||
"amount": { "type": "String" },
|
||||
"unit": { "type": "String" }
|
||||
}
|
||||
},
|
||||
"pendingTokensClaimed": "{count, plural, =1{Réclamé 1 token ({amount} {unit})} other{Réclamé {count} tokens ({amount} {unit})}}",
|
||||
"@pendingTokensClaimed": {
|
||||
"placeholders": {
|
||||
"count": { "type": "int" },
|
||||
"amount": { "type": "String" },
|
||||
"unit": { "type": "String" }
|
||||
}
|
||||
},
|
||||
|
||||
"scan": "Scanner",
|
||||
"scanQrCode": "Scanner le QR",
|
||||
"scanCashuToken": "Scanner un token Cashu",
|
||||
"scanLightningInvoice": "Scanner une facture",
|
||||
"scanningAnimatedQr": "Scan du QR animé...",
|
||||
"pointCameraAtQr": "Pointez la caméra vers le code QR",
|
||||
"pointCameraAtCashuQr": "Pointez la caméra vers le QR du token Cashu",
|
||||
"pointCameraAtInvoiceQr": "Pointez la caméra vers le QR de la facture",
|
||||
"unrecognizedQrCode": "Code QR non reconnu",
|
||||
"scanCashuTokenHint": "Scannez un token Cashu (cashuA... ou cashuB...)",
|
||||
"scanLightningInvoiceHint": "Scannez une facture Lightning (lnbc...)",
|
||||
"addMintQuestion": "Ajouter ce mint ?",
|
||||
"cameraPermissionDenied": "Permission de la caméra refusée",
|
||||
"paymentRequestTitle": "Demande de paiement",
|
||||
"paymentRequestFrom": "Demande de",
|
||||
"paymentRequestAmount": "Montant demandé",
|
||||
"paymentRequestDescription": "Description",
|
||||
"paymentRequestMints": "Mints acceptés",
|
||||
"paymentRequestAnyMint": "N'importe quel mint",
|
||||
"paymentRequestPay": "Payer",
|
||||
"paymentRequestPaying": "Paiement en cours...",
|
||||
"paymentRequestSuccess": "Paiement envoyé avec succès",
|
||||
"paymentRequestNoTransport": "Cette demande n'a pas de méthode de livraison configurée",
|
||||
"paymentRequestTransport": "Transport",
|
||||
"paymentRequestMintNotAccepted": "Votre mint actif n'est pas dans la liste des mints acceptés",
|
||||
"paymentRequestUnitMismatch": "Unité incompatible : la demande nécessite {unit}",
|
||||
"paymentRequestInsufficientBalance": "Solde insuffisant",
|
||||
"paymentRequestErrorParsing": "Erreur lors de la lecture de la demande de paiement",
|
||||
|
||||
"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",
|
||||
|
||||
"request": "Demander",
|
||||
"requestPayment": "Demander un paiement",
|
||||
"requestPaymentDescription": "Générer une demande de paiement unifiée",
|
||||
"generateRequest": "Générer la demande",
|
||||
"generatingRequest": "Génération en cours...",
|
||||
"requestPaymentReceived": "Paiement reçu",
|
||||
"requestDescriptionHint": "Description (facultatif)",
|
||||
"universal": "Universel",
|
||||
"copiedToClipboard": "Copié dans le presse-papiers"
|
||||
}
|
||||
@@ -0,0 +1,563 @@
|
||||
{
|
||||
"@@locale": "it",
|
||||
|
||||
"appName": "ElCaju",
|
||||
"appTagline": "Il tuo portafoglio ecash privato",
|
||||
"loadingMessage1": "Crittografia delle tue monete...",
|
||||
"loadingMessage2": "Preparazione dei tuoi e-token...",
|
||||
"loadingMessage3": "Connessione al Mint...",
|
||||
"loadingMessage4": "Privacy di default.",
|
||||
"loadingMessage5": "Firma cieca dei token...",
|
||||
"loadingMessage6": "Go full Calle...",
|
||||
"loadingMessage7": "Cashu + Bitchat = Privacy + Libertà",
|
||||
"aboutTagline": "Privacy senza confini.",
|
||||
|
||||
"welcomeTitle": "Benvenuto su ElCaju",
|
||||
"welcomeSubtitle": "Cashu per il mondo. Fatto a Cuba.",
|
||||
|
||||
"createWallet": "Crea nuovo portafoglio",
|
||||
"restoreWallet": "Ripristina portafoglio",
|
||||
|
||||
"createWalletTitle": "Crea portafoglio",
|
||||
"creatingWallet": "Creazione del tuo portafoglio...",
|
||||
"generatingSeed": "Generazione sicura della tua frase seed",
|
||||
"createWalletDescription": "Verrà generata una frase seed di 12 parole.\nConservala in un luogo sicuro.",
|
||||
"generateWallet": "Genera portafoglio",
|
||||
|
||||
"walletCreated": "Portafoglio creato!",
|
||||
"walletCreatedDescription": "Il tuo portafoglio è pronto. Ti consigliamo di fare il backup della frase seed ora.",
|
||||
"backupWarning": "Senza backup, perderai l'accesso ai tuoi fondi se perdi il dispositivo.",
|
||||
"backupNow": "Backup adesso",
|
||||
"backupLater": "Fallo dopo",
|
||||
|
||||
"backupTitle": "Backup",
|
||||
"seedPhraseTitle": "La tua frase seed",
|
||||
"seedPhraseDescription": "Conserva queste 12 parole in ordine. Sono l'unico modo per recuperare il tuo portafoglio.",
|
||||
"revealSeedPhrase": "Mostra frase seed",
|
||||
"tapToReveal": "Tocca il pulsante per mostrare\nla tua frase seed",
|
||||
"copyToClipboard": "Copia negli appunti",
|
||||
"seedCopied": "Frase copiata negli appunti",
|
||||
"neverShareSeed": "Non condividere mai la tua frase seed con nessuno.",
|
||||
"confirmBackup": "Ho salvato la mia frase seed in un luogo sicuro",
|
||||
"continue_": "Continua",
|
||||
|
||||
"restoreTitle": "Ripristina portafoglio",
|
||||
"enterSeedPhrase": "Inserisci la tua frase seed",
|
||||
"enterSeedDescription": "Scrivi le 12 o 24 parole separate da spazi.",
|
||||
"seedPlaceholder": "parola1 parola2 parola3 ...",
|
||||
"wordCount": "{count} parole",
|
||||
"@wordCount": {
|
||||
"placeholders": {
|
||||
"count": { "type": "int" }
|
||||
}
|
||||
},
|
||||
"needWords": "(servono 12 o 24)",
|
||||
"restoreScanningMint": "Scansione del mint per token esistenti...",
|
||||
"restoreError": "Errore di ripristino: {error}",
|
||||
"@restoreError": {
|
||||
"placeholders": {
|
||||
"error": { "type": "String" }
|
||||
}
|
||||
},
|
||||
|
||||
"homeTitle": "Home",
|
||||
"receive": "Ricevi",
|
||||
"send": "Invia",
|
||||
"sendAction": "Invia ↗",
|
||||
"receiveAction": "↘ Ricevi",
|
||||
"deposit": "Deposita",
|
||||
"withdraw": "Preleva",
|
||||
"lightning": "Lightning",
|
||||
"cashu": "Cashu",
|
||||
"ecash": "Ecash",
|
||||
"history": "Cronologia",
|
||||
"noTransactions": "Nessuna transazione",
|
||||
"depositOrReceive": "Deposita o ricevi sats per iniziare",
|
||||
"noMint": "Nessun mint",
|
||||
|
||||
"balance": "Saldo",
|
||||
"sats": "sats",
|
||||
|
||||
"pasteEcashToken": "Incolla token ecash",
|
||||
"generateInvoiceToDeposit": "Genera fattura per depositare",
|
||||
"createEcashToken": "Crea token ecash",
|
||||
"payLightningInvoice": "Paga fattura Lightning",
|
||||
|
||||
"receiveCashu": "Ricevi Cashu",
|
||||
"pasteTheCashuToken": "Incolla il token Cashu:",
|
||||
"pasteFromClipboard": "Incolla dagli appunti",
|
||||
"validToken": "Token valido",
|
||||
"invalidToken": "Token non valido o malformato",
|
||||
"amount": "Importo:",
|
||||
"mint": "Mint:",
|
||||
"claiming": "Riscatto...",
|
||||
"claimTokens": "Riscatta token",
|
||||
"tokensReceived": "Token ricevuti",
|
||||
"backToHome": "Torna alla home",
|
||||
"tokenAlreadyClaimed": "Questo token è già stato riscattato",
|
||||
"unknownMint": "Token da mint sconosciuto",
|
||||
"claimError": "Errore di riscatto: {error}",
|
||||
"@claimError": {
|
||||
"placeholders": {
|
||||
"error": { "type": "String" }
|
||||
}
|
||||
},
|
||||
|
||||
"sendCashu": "Invia Cashu",
|
||||
"selectNotesManually": "Seleziona note manualmente",
|
||||
"amountToSend": "Importo da inviare:",
|
||||
"available": "Disponibile:",
|
||||
"max": "(Max)",
|
||||
"memoOptional": "Memo (opzionale):",
|
||||
"memoPlaceholder": "A cosa serve questo pagamento?",
|
||||
"creatingToken": "Creazione token...",
|
||||
"createToken": "Crea token",
|
||||
"noActiveMint": "Nessun mint attivo",
|
||||
"offlineModeMessage": "Nessuna connessione. Modalità offline...",
|
||||
"confirmSend": "Conferma invio",
|
||||
"confirm": "Conferma",
|
||||
"cancel": "Annulla",
|
||||
"insufficientBalance": "Saldo insufficiente",
|
||||
"tokenCreationError": "Errore creazione token: {error}",
|
||||
"@tokenCreationError": {
|
||||
"placeholders": {
|
||||
"error": { "type": "String" }
|
||||
}
|
||||
},
|
||||
|
||||
"tokenCreated": "Token creato",
|
||||
"copy": "Copia",
|
||||
"share": "Condividi",
|
||||
"tokenCashu": "Token Cashu",
|
||||
"tokenCashuAnimatedQr": "Token Cashu (QR animato - {fragments} frammenti UR)",
|
||||
"@tokenCashuAnimatedQr": {
|
||||
"placeholders": {
|
||||
"fragments": { "type": "int" }
|
||||
}
|
||||
},
|
||||
"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):",
|
||||
"depositPlaceholder": "A cosa serve questo deposito?",
|
||||
"generating": "Generazione...",
|
||||
"generateInvoice": "Genera fattura",
|
||||
"depositLightning": "Deposita Lightning",
|
||||
|
||||
"payInvoiceTitle": "Paga fattura",
|
||||
"generatingInvoice": "Generazione fattura...",
|
||||
"waitingForPayment": "In attesa del pagamento...",
|
||||
"paymentReceived": "Pagamento ricevuto",
|
||||
"tokensIssued": "Token emessi!",
|
||||
"error": "Errore",
|
||||
"unknownError": "Errore sconosciuto",
|
||||
"back": "Indietro",
|
||||
"copyInvoice": "Copia fattura",
|
||||
"description": "Descrizione:",
|
||||
"invoiceCopiedToClipboard": "Fattura copiata negli appunti",
|
||||
"deposited": "{amount} {unit} depositati",
|
||||
"@deposited": {
|
||||
"placeholders": {
|
||||
"amount": { "type": "String" },
|
||||
"unit": { "type": "String" }
|
||||
}
|
||||
},
|
||||
|
||||
"pasteLightningInvoice": "Incolla la fattura Lightning:",
|
||||
"gettingQuote": "Ottenimento preventivo...",
|
||||
"validInvoice": "Fattura valida",
|
||||
"invalidInvoice": "Fattura non valida",
|
||||
"invalidInvoiceMalformed": "Fattura non valida o malformata",
|
||||
"feeReserved": "Commissione riservata:",
|
||||
"total": "Totale:",
|
||||
"paying": "Pagamento...",
|
||||
"payInvoice": "Paga fattura",
|
||||
"confirmPayment": "Conferma pagamento",
|
||||
"pay": "Paga",
|
||||
"fee": "commissione",
|
||||
"invoiceExpired": "Fattura scaduta",
|
||||
"amountOutOfRange": "Importo fuori dall'intervallo consentito",
|
||||
"resolvingType": "Risoluzione {type}...",
|
||||
"@resolvingType": {
|
||||
"placeholders": {
|
||||
"type": { "type": "String" }
|
||||
}
|
||||
},
|
||||
"invoiceAlreadyPaid": "Fattura già pagata",
|
||||
"paymentError": "Errore di pagamento: {error}",
|
||||
"@paymentError": {
|
||||
"placeholders": {
|
||||
"error": { "type": "String" }
|
||||
}
|
||||
},
|
||||
"sent": "{amount} {unit} inviati",
|
||||
"@sent": {
|
||||
"placeholders": {
|
||||
"amount": { "type": "String" },
|
||||
"unit": { "type": "String" }
|
||||
}
|
||||
},
|
||||
|
||||
"filterAll": "Tutti",
|
||||
"filterPending": "In attesa",
|
||||
"filterEcash": "Ecash",
|
||||
"filterLightning": "Lightning",
|
||||
"receiveTokensToStart": "Ricevi token Cashu per iniziare",
|
||||
"noPendingTransactions": "Nessuna transazione in attesa",
|
||||
"allTransactionsCompleted": "Tutte le tue transazioni sono completate",
|
||||
"noEcashTransactions": "Nessuna transazione Ecash",
|
||||
"sendOrReceiveTokens": "Invia o ricevi token Cashu",
|
||||
"noLightningTransactions": "Nessuna transazione Lightning",
|
||||
"depositOrWithdrawLightning": "Deposita o preleva via Lightning",
|
||||
"pendingStatus": "In attesa",
|
||||
"receivedStatus": "Ricevuto",
|
||||
"sentStatus": "Inviato",
|
||||
"now": "Adesso",
|
||||
"agoMinutes": "{minutes} min fa",
|
||||
"@agoMinutes": {
|
||||
"placeholders": {
|
||||
"minutes": { "type": "int" }
|
||||
}
|
||||
},
|
||||
"agoHours": "{hours} ore fa",
|
||||
"@agoHours": {
|
||||
"placeholders": {
|
||||
"hours": { "type": "int" }
|
||||
}
|
||||
},
|
||||
"agoDays": "{days} giorni fa",
|
||||
"@agoDays": {
|
||||
"placeholders": {
|
||||
"days": { "type": "int" }
|
||||
}
|
||||
},
|
||||
"lightningInvoice": "Fattura Lightning",
|
||||
"receivedEcash": "Ecash ricevuto",
|
||||
"sentEcash": "Ecash inviato",
|
||||
"outgoingLightningPayment": "Pagamento Lightning in uscita",
|
||||
"invoiceNotAvailable": "Fattura non disponibile",
|
||||
"tokenNotAvailable": "Token non disponibile",
|
||||
"unit": "Unità",
|
||||
"status": "Stato",
|
||||
"pending": "In attesa",
|
||||
"memo": "Memo",
|
||||
"copyInvoiceButton": "COPIA FATTURA",
|
||||
"copyButton": "COPIA",
|
||||
"invoiceCopied": "Fattura copiata",
|
||||
"tokenCopied": "Token copiato",
|
||||
"speed": "VELOCITÀ:",
|
||||
|
||||
"settings": "Impostazioni",
|
||||
"walletSection": "PORTAFOGLIO",
|
||||
"backupSeedPhrase": "Backup frase seed",
|
||||
"viewRecoveryWords": "Visualizza le parole di recupero",
|
||||
"connectedMints": "Mint connessi",
|
||||
"manageCashuMints": "Gestisci i tuoi mint Cashu",
|
||||
"pinAccess": "PIN di accesso",
|
||||
"pinEnabled": "Attivato",
|
||||
"protectWithPin": "Proteggi l'app con PIN",
|
||||
"recoverTokens": "Recupera token",
|
||||
"scanMintsWithSeed": "Scansiona mint con frase seed",
|
||||
"appearanceSection": "LINGUA",
|
||||
"language": "Lingua",
|
||||
"informationSection": "INFORMAZIONI",
|
||||
"version": "Versione",
|
||||
"about": "Info",
|
||||
"deleteWallet": "Elimina portafoglio",
|
||||
"spanish": "Español",
|
||||
"english": "English",
|
||||
"portuguese": "Português",
|
||||
"french": "Français",
|
||||
"russian": "Русский",
|
||||
"german": "Deutsch",
|
||||
"italian": "Italiano",
|
||||
"korean": "한국어",
|
||||
"chinese": "中文",
|
||||
"japanese": "日本語",
|
||||
"swahili": "Kiswahili",
|
||||
|
||||
"mnemonicNotFound": "Mnemonic non trovato",
|
||||
"createPin": "Crea PIN",
|
||||
"enterPinDigits": "Inserisci un PIN a 4 cifre",
|
||||
"confirmPin": "Conferma PIN",
|
||||
"enterPinAgain": "Inserisci di nuovo il PIN",
|
||||
"pinMismatch": "I PIN non corrispondono",
|
||||
"pinActivated": "PIN attivato",
|
||||
"pinDeactivated": "PIN disattivato",
|
||||
"verifyPin": "Verifica PIN",
|
||||
"enterCurrentPin": "Inserisci il tuo PIN attuale",
|
||||
"incorrectPin": "PIN errato",
|
||||
"selectLanguage": "Seleziona lingua",
|
||||
"languageChanged": "Lingua cambiata in {language}",
|
||||
"@languageChanged": {
|
||||
"placeholders": {
|
||||
"language": { "type": "String" }
|
||||
}
|
||||
},
|
||||
"close": "Chiudi",
|
||||
"aboutDescription": "Un portafoglio Cashu con DNA cubano per il mondo intero. Fratello di LaChispa.",
|
||||
"couldNotOpenLink": "Impossibile aprire il link",
|
||||
|
||||
"deleteWalletQuestion": "Eliminare portafoglio?",
|
||||
"actionIrreversible": "Questa azione è irreversibile",
|
||||
"deleteWalletWarning": "Tutti i dati verranno eliminati, inclusa la frase seed e i token. Assicurati di avere un backup.",
|
||||
"typeDeleteToConfirm": "Scrivi \"ELIMINA\" per confermare:",
|
||||
"deleteConfirmWord": "ELIMINA",
|
||||
"deleteError": "Errore di eliminazione: {error}",
|
||||
"@deleteError": {
|
||||
"placeholders": {
|
||||
"error": { "type": "String" }
|
||||
}
|
||||
},
|
||||
|
||||
"recoverTokensTitle": "Recupera token",
|
||||
"recoverTokensDescription": "Scansiona i mint per recuperare i token associati alla tua frase seed (NUT-13)",
|
||||
"useCurrentSeedPhrase": "Usa la mia frase seed attuale",
|
||||
"scanWithSavedWords": "Scansiona i mint con le 12 parole salvate",
|
||||
"useOtherSeedPhrase": "Usa un'altra frase seed",
|
||||
"recoverFromOtherWords": "Recupera token da altre 12 parole",
|
||||
"mintsToScan": "Mint da scansionare:",
|
||||
"allMints": "Tutti i mint ({count})",
|
||||
"@allMints": {
|
||||
"placeholders": {
|
||||
"count": { "type": "int" }
|
||||
}
|
||||
},
|
||||
"specificMint": "Un mint specifico",
|
||||
"enterMnemonicWords": "Inserisci le 12 parole separate da spazi...",
|
||||
"scanMints": "Scansiona mint",
|
||||
"selectMintToScan": "Seleziona un mint da scansionare",
|
||||
"mnemonicMustHaveWords": "Il mnemonic deve avere 12 o 24 parole",
|
||||
"noConnectedMintsToScan": "Nessun mint connesso da scansionare",
|
||||
"recoveredTokens": "Recuperati {tokens} da {mints} mint!",
|
||||
"@recoveredTokens": {
|
||||
"placeholders": {
|
||||
"tokens": { "type": "String" },
|
||||
"mints": { "type": "int" }
|
||||
}
|
||||
},
|
||||
"scanCompleteNoTokens": "Scansione completata. Nessun nuovo token trovato.",
|
||||
"mintsWithError": "({count} mint con errore)",
|
||||
"@mintsWithError": {
|
||||
"placeholders": {
|
||||
"count": { "type": "int" }
|
||||
}
|
||||
},
|
||||
"recoveredFromMint": "Recuperati {tokens} da {mint}!",
|
||||
"@recoveredFromMint": {
|
||||
"placeholders": {
|
||||
"tokens": { "type": "String" },
|
||||
"mint": { "type": "String" }
|
||||
}
|
||||
},
|
||||
"noTokensFoundInMint": "Nessun token trovato in {mint}.",
|
||||
"@noTokensFoundInMint": {
|
||||
"placeholders": {
|
||||
"mint": { "type": "String" }
|
||||
}
|
||||
},
|
||||
"recoveredAndTransferred": "Recuperati e trasferiti {amount} {unit} nel tuo portafoglio!",
|
||||
"@recoveredAndTransferred": {
|
||||
"placeholders": {
|
||||
"amount": { "type": "String" },
|
||||
"unit": { "type": "String" }
|
||||
}
|
||||
},
|
||||
"noTokensForMnemonic": "Nessun token trovato associato a questo mnemonic.",
|
||||
|
||||
"noConnectedMints": "Nessun mint connesso",
|
||||
"addMintToStart": "Aggiungi un mint per iniziare",
|
||||
"addMint": "Aggiungi mint",
|
||||
"mintDeleted": "Mint eliminato",
|
||||
"activeMintUpdated": "Mint attivo aggiornato",
|
||||
"mintUrl": "URL del mint:",
|
||||
"mintUrlPlaceholder": "https://mint.example.com",
|
||||
"urlMustStartWithHttps": "L'URL deve iniziare con https://",
|
||||
"connectingToMint": "Connessione al mint...",
|
||||
"mintAddedSuccessfully": "Mint aggiunto con successo",
|
||||
"couldNotConnectToMint": "Impossibile connettersi al mint",
|
||||
"add": "Aggiungi",
|
||||
|
||||
"success": "Successo",
|
||||
"loading": "Caricamento...",
|
||||
"retry": "Riprova",
|
||||
|
||||
"activeMint": "Mint attivo",
|
||||
"mintMessage": "Messaggio del Mint",
|
||||
"url": "URL",
|
||||
"currency": "Valuta",
|
||||
"unknown": "Sconosciuto",
|
||||
"useThisMint": "Usa questo mint",
|
||||
"copyMintUrl": "Copia URL del mint",
|
||||
"deleteMint": "Elimina mint",
|
||||
"copied": "{label} copiato",
|
||||
"@copied": {
|
||||
"placeholders": {
|
||||
"label": { "type": "String" }
|
||||
}
|
||||
},
|
||||
"deleteMintConfirmTitle": "Elimina mint",
|
||||
"deleteMintConfirmMessage": "Se hai saldo su questo mint, verrà perso. Sei sicuro?",
|
||||
"delete": "Elimina",
|
||||
|
||||
"offlineSend": "Invio offline",
|
||||
"selectAll": "Tutto",
|
||||
"deselectAll": "Nessuno",
|
||||
"selectNotesToSend": "Seleziona le note da inviare:",
|
||||
"totalToSend": "Totale da inviare",
|
||||
"notesSelected": "{count} note selezionate",
|
||||
"@notesSelected": {
|
||||
"placeholders": {
|
||||
"count": { "type": "int" }
|
||||
}
|
||||
},
|
||||
"loadingProofsError": "Errore caricamento prove: {error}",
|
||||
"@loadingProofsError": {
|
||||
"placeholders": {
|
||||
"error": { "type": "String" }
|
||||
}
|
||||
},
|
||||
"creatingTokenError": "Errore creazione token: {error}",
|
||||
"@creatingTokenError": {
|
||||
"placeholders": {
|
||||
"error": { "type": "String" }
|
||||
}
|
||||
},
|
||||
|
||||
"unknownState": "Stato sconosciuto",
|
||||
"depositAmountTitle": "Deposita {amount} {unit}",
|
||||
"@depositAmountTitle": {
|
||||
"placeholders": {
|
||||
"amount": { "type": "String" },
|
||||
"unit": { "type": "String" }
|
||||
}
|
||||
},
|
||||
|
||||
"receiveNow": "Ricevi adesso",
|
||||
"receiveLater": "Ricevi dopo",
|
||||
"tokenSavedForLater": "Token salvato per riscattarlo dopo",
|
||||
"noConnectionTokenSaved": "Nessuna connessione. Token salvato per riscattarlo dopo.",
|
||||
"unknownMintOffline": "Questo token proviene da un mint sconosciuto. Connettiti a internet per aggiungerlo e riscattare il token.",
|
||||
"noConnectionTryLater": "Nessuna connessione al mint. Riprova più tardi.",
|
||||
"saveTokenError": "Errore nel salvare il token. Riprova.",
|
||||
"pendingTokenLimitReached": "Limite token in attesa raggiunto (max 50)",
|
||||
"filterToReceive": "Da ricevere",
|
||||
"noPendingTokens": "Nessun token in attesa",
|
||||
"noPendingTokensHint": "Salva token per riscattarli dopo",
|
||||
"pendingBadge": "IN ATTESA",
|
||||
"expiresInDays": "{days, plural, =1{Scade tra 1 giorno} other{Scade tra {days} giorni}}",
|
||||
"@expiresInDays": {
|
||||
"placeholders": {
|
||||
"days": { "type": "int" }
|
||||
}
|
||||
},
|
||||
"retryCount": "{count} tentativi",
|
||||
"@retryCount": {
|
||||
"placeholders": {
|
||||
"count": { "type": "int" }
|
||||
}
|
||||
},
|
||||
"claimNow": "Riscatta adesso",
|
||||
"pendingTokenClaimedSuccess": "Riscattati {amount} {unit}",
|
||||
"@pendingTokenClaimedSuccess": {
|
||||
"placeholders": {
|
||||
"amount": { "type": "String" },
|
||||
"unit": { "type": "String" }
|
||||
}
|
||||
},
|
||||
"pendingTokensClaimed": "{count, plural, =1{Riscattato 1 token ({amount} {unit})} other{Riscattati {count} token ({amount} {unit})}}",
|
||||
"@pendingTokensClaimed": {
|
||||
"placeholders": {
|
||||
"count": { "type": "int" },
|
||||
"amount": { "type": "String" },
|
||||
"unit": { "type": "String" }
|
||||
}
|
||||
},
|
||||
|
||||
"scan": "Scansiona",
|
||||
"scanQrCode": "Scansiona QR",
|
||||
"scanCashuToken": "Scansiona token Cashu",
|
||||
"scanLightningInvoice": "Scansiona fattura",
|
||||
"scanningAnimatedQr": "Scansione QR animato...",
|
||||
"pointCameraAtQr": "Punta la fotocamera sul codice QR",
|
||||
"pointCameraAtCashuQr": "Punta la fotocamera sul QR del token Cashu",
|
||||
"pointCameraAtInvoiceQr": "Punta la fotocamera sul QR della fattura",
|
||||
"unrecognizedQrCode": "Codice QR non riconosciuto",
|
||||
"scanCashuTokenHint": "Scansiona un token Cashu (cashuA... o cashuB...)",
|
||||
"scanLightningInvoiceHint": "Scansiona una fattura Lightning (lnbc...)",
|
||||
"addMintQuestion": "Aggiungere questo mint?",
|
||||
"cameraPermissionDenied": "Permesso fotocamera negato",
|
||||
"paymentRequestTitle": "Richiesta di pagamento",
|
||||
"paymentRequestFrom": "Richiesta da",
|
||||
"paymentRequestAmount": "Importo richiesto",
|
||||
"paymentRequestDescription": "Descrizione",
|
||||
"paymentRequestMints": "Mint accettati",
|
||||
"paymentRequestAnyMint": "Qualsiasi mint",
|
||||
"paymentRequestPay": "Paga",
|
||||
"paymentRequestPaying": "Pagamento in corso...",
|
||||
"paymentRequestSuccess": "Pagamento inviato con successo",
|
||||
"paymentRequestNoTransport": "Questa richiesta non ha un metodo di consegna configurato",
|
||||
"paymentRequestTransport": "Trasporto",
|
||||
"paymentRequestMintNotAccepted": "Il tuo mint attivo non è nella lista dei mint accettati",
|
||||
"paymentRequestUnitMismatch": "Unità incompatibile: la richiesta richiede {unit}",
|
||||
"paymentRequestInsufficientBalance": "Saldo insufficiente",
|
||||
"paymentRequestErrorParsing": "Errore nella lettura della richiesta di pagamento",
|
||||
|
||||
"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",
|
||||
|
||||
"request": "Richiedi",
|
||||
"requestPayment": "Richiedi pagamento",
|
||||
"requestPaymentDescription": "Genera richiesta di pagamento unificata",
|
||||
"generateRequest": "Genera richiesta",
|
||||
"generatingRequest": "Generazione in corso...",
|
||||
"requestPaymentReceived": "Pagamento ricevuto",
|
||||
"requestDescriptionHint": "Descrizione (opzionale)",
|
||||
"universal": "Universale",
|
||||
"copiedToClipboard": "Copiato negli appunti"
|
||||
}
|
||||
@@ -0,0 +1,563 @@
|
||||
{
|
||||
"@@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": "カメラの許可が拒否されました",
|
||||
"paymentRequestTitle": "支払いリクエスト",
|
||||
"paymentRequestFrom": "リクエスト元",
|
||||
"paymentRequestAmount": "リクエスト金額",
|
||||
"paymentRequestDescription": "説明",
|
||||
"paymentRequestMints": "対応ミント",
|
||||
"paymentRequestAnyMint": "すべてのミント",
|
||||
"paymentRequestPay": "支払う",
|
||||
"paymentRequestPaying": "支払い中...",
|
||||
"paymentRequestSuccess": "支払いが正常に送信されました",
|
||||
"paymentRequestNoTransport": "このリクエストには配信方法が設定されていません",
|
||||
"paymentRequestTransport": "トランスポート",
|
||||
"paymentRequestMintNotAccepted": "アクティブなミントは対応ミントリストにありません",
|
||||
"paymentRequestUnitMismatch": "単位が互換性がありません:リクエストには{unit}が必要です",
|
||||
"paymentRequestInsufficientBalance": "残高不足",
|
||||
"paymentRequestErrorParsing": "支払いリクエストの読み取りエラー",
|
||||
|
||||
"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": "プライマリ鍵は削除できません",
|
||||
|
||||
"request": "リクエスト",
|
||||
"requestPayment": "支払いをリクエスト",
|
||||
"requestPaymentDescription": "統合支払いリクエストを生成",
|
||||
"generateRequest": "リクエストを生成",
|
||||
"generatingRequest": "生成中...",
|
||||
"requestPaymentReceived": "支払いを受け取りました",
|
||||
"requestDescriptionHint": "説明(任意)",
|
||||
"universal": "ユニバーサル",
|
||||
"copiedToClipboard": "クリップボードにコピーしました"
|
||||
}
|
||||
@@ -0,0 +1,563 @@
|
||||
{
|
||||
"@@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": "카메라 권한이 거부되었습니다",
|
||||
"paymentRequestTitle": "결제 요청",
|
||||
"paymentRequestFrom": "요청자",
|
||||
"paymentRequestAmount": "요청 금액",
|
||||
"paymentRequestDescription": "설명",
|
||||
"paymentRequestMints": "허용된 민트",
|
||||
"paymentRequestAnyMint": "모든 민트",
|
||||
"paymentRequestPay": "결제",
|
||||
"paymentRequestPaying": "결제 중...",
|
||||
"paymentRequestSuccess": "결제가 성공적으로 전송되었습니다",
|
||||
"paymentRequestNoTransport": "이 요청에는 전달 방법이 설정되지 않았습니다",
|
||||
"paymentRequestTransport": "전송 방식",
|
||||
"paymentRequestMintNotAccepted": "활성 민트가 허용된 민트 목록에 없습니다",
|
||||
"paymentRequestUnitMismatch": "호환되지 않는 단위: 요청에 {unit}이(가) 필요합니다",
|
||||
"paymentRequestInsufficientBalance": "잔액 부족",
|
||||
"paymentRequestErrorParsing": "결제 요청을 읽는 중 오류 발생",
|
||||
|
||||
"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": "기본 키는 삭제할 수 없습니다",
|
||||
|
||||
"request": "요청",
|
||||
"requestPayment": "결제 요청",
|
||||
"requestPaymentDescription": "통합 결제 요청 생성",
|
||||
"generateRequest": "요청 생성",
|
||||
"generatingRequest": "생성 중...",
|
||||
"requestPaymentReceived": "결제가 수신되었습니다",
|
||||
"requestDescriptionHint": "설명 (선택사항)",
|
||||
"universal": "유니버설",
|
||||
"copiedToClipboard": "클립보드에 복사되었습니다"
|
||||
}
|
||||
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
@@ -0,0 +1,563 @@
|
||||
{
|
||||
"@@locale": "pt",
|
||||
|
||||
"appName": "ElCaju",
|
||||
"appTagline": "Sua wallet de ecash privada",
|
||||
"loadingMessage1": "Criptografando suas moedas...",
|
||||
"loadingMessage2": "Preparando seus e-tokens...",
|
||||
"loadingMessage3": "Conectando ao Mint...",
|
||||
"loadingMessage4": "Privacidade por padrão.",
|
||||
"loadingMessage5": "Assinando tokens cegamente...",
|
||||
"loadingMessage6": "Go full Calle...",
|
||||
"loadingMessage7": "Cashu + Bitchat = Privacidade + Liberdade",
|
||||
"aboutTagline": "Privacidade sem fronteiras.",
|
||||
|
||||
"welcomeTitle": "Bem-vindo ao ElCaju",
|
||||
"welcomeSubtitle": "Cashu para o mundo. Feito em Cuba.",
|
||||
|
||||
"createWallet": "Criar nova wallet",
|
||||
"restoreWallet": "Restaurar wallet",
|
||||
|
||||
"createWalletTitle": "Criar wallet",
|
||||
"creatingWallet": "Criando sua wallet...",
|
||||
"generatingSeed": "Gerando sua frase semente de forma segura",
|
||||
"createWalletDescription": "Uma frase semente de 12 palavras será gerada.\nGuarde-a em um lugar seguro.",
|
||||
"generateWallet": "Gerar wallet",
|
||||
|
||||
"walletCreated": "Wallet criada!",
|
||||
"walletCreatedDescription": "Sua wallet está pronta. Recomendamos fazer backup da sua frase semente agora.",
|
||||
"backupWarning": "Sem backup, você perderá acesso aos seus fundos se perder o dispositivo.",
|
||||
"backupNow": "Fazer backup agora",
|
||||
"backupLater": "Fazer depois",
|
||||
|
||||
"backupTitle": "Backup",
|
||||
"seedPhraseTitle": "Sua frase semente",
|
||||
"seedPhraseDescription": "Guarde estas 12 palavras em ordem. Elas são a única forma de recuperar sua wallet.",
|
||||
"revealSeedPhrase": "Revelar frase semente",
|
||||
"tapToReveal": "Toque no botão para revelar\nsua frase semente",
|
||||
"copyToClipboard": "Copiar para área de transferência",
|
||||
"seedCopied": "Frase copiada para área de transferência",
|
||||
"neverShareSeed": "Nunca compartilhe sua frase semente com ninguém.",
|
||||
"confirmBackup": "Guardei minha frase semente em um lugar seguro",
|
||||
"continue_": "Continuar",
|
||||
|
||||
"restoreTitle": "Restaurar wallet",
|
||||
"enterSeedPhrase": "Digite sua frase semente",
|
||||
"enterSeedDescription": "Digite as 12 ou 24 palavras separadas por espaços.",
|
||||
"seedPlaceholder": "palavra1 palavra2 palavra3 ...",
|
||||
"wordCount": "{count} palavras",
|
||||
"@wordCount": {
|
||||
"placeholders": {
|
||||
"count": { "type": "int" }
|
||||
}
|
||||
},
|
||||
"needWords": "(você precisa de 12 ou 24)",
|
||||
"restoreScanningMint": "Escaneando mint em busca de tokens...",
|
||||
"restoreError": "Erro ao restaurar: {error}",
|
||||
"@restoreError": {
|
||||
"placeholders": {
|
||||
"error": { "type": "String" }
|
||||
}
|
||||
},
|
||||
|
||||
"homeTitle": "Início",
|
||||
"receive": "Receber",
|
||||
"send": "Enviar",
|
||||
"sendAction": "Enviar ↗",
|
||||
"receiveAction": "↘ Receber",
|
||||
"deposit": "Depositar",
|
||||
"withdraw": "Sacar",
|
||||
"lightning": "Lightning",
|
||||
"cashu": "Cashu",
|
||||
"ecash": "Ecash",
|
||||
"history": "Histórico",
|
||||
"noTransactions": "Sem transações ainda",
|
||||
"depositOrReceive": "Deposite ou receba sats para começar",
|
||||
"noMint": "Sem mint",
|
||||
|
||||
"balance": "Saldo",
|
||||
"sats": "sats",
|
||||
|
||||
"pasteEcashToken": "Colar token ecash",
|
||||
"generateInvoiceToDeposit": "Gerar invoice para depositar",
|
||||
"createEcashToken": "Criar token ecash",
|
||||
"payLightningInvoice": "Pagar invoice Lightning",
|
||||
|
||||
"receiveCashu": "Receber Cashu",
|
||||
"pasteTheCashuToken": "Cole o token Cashu:",
|
||||
"pasteFromClipboard": "Colar da área de transferência",
|
||||
"validToken": "Token válido",
|
||||
"invalidToken": "Token inválido ou malformado",
|
||||
"amount": "Valor:",
|
||||
"mint": "Mint:",
|
||||
"claiming": "Resgatando...",
|
||||
"claimTokens": "Resgatar tokens",
|
||||
"tokensReceived": "Tokens recebidos",
|
||||
"backToHome": "Voltar ao início",
|
||||
"tokenAlreadyClaimed": "Este token já foi resgatado",
|
||||
"unknownMint": "Token de um mint desconhecido",
|
||||
"claimError": "Erro ao resgatar: {error}",
|
||||
"@claimError": {
|
||||
"placeholders": {
|
||||
"error": { "type": "String" }
|
||||
}
|
||||
},
|
||||
|
||||
"sendCashu": "Enviar Cashu",
|
||||
"selectNotesManually": "Selecionar notas manualmente",
|
||||
"amountToSend": "Valor a enviar:",
|
||||
"available": "Disponível:",
|
||||
"max": "(Máx)",
|
||||
"memoOptional": "Memo (opcional):",
|
||||
"memoPlaceholder": "Para que é este pagamento?",
|
||||
"creatingToken": "Criando token...",
|
||||
"createToken": "Criar token",
|
||||
"noActiveMint": "Nenhum mint ativo",
|
||||
"offlineModeMessage": "Sem conexão. Usando modo offline...",
|
||||
"confirmSend": "Confirmar envio",
|
||||
"confirm": "Confirmar",
|
||||
"cancel": "Cancelar",
|
||||
"insufficientBalance": "Saldo insuficiente",
|
||||
"tokenCreationError": "Erro ao criar token: {error}",
|
||||
"@tokenCreationError": {
|
||||
"placeholders": {
|
||||
"error": { "type": "String" }
|
||||
}
|
||||
},
|
||||
|
||||
"tokenCreated": "Token criado",
|
||||
"copy": "Copiar",
|
||||
"share": "Compartilhar",
|
||||
"tokenCashu": "Token Cashu",
|
||||
"tokenCashuAnimatedQr": "Token Cashu (QR animado - {fragments} fragmentos UR)",
|
||||
"@tokenCashuAnimatedQr": {
|
||||
"placeholders": {
|
||||
"fragments": { "type": "int" }
|
||||
}
|
||||
},
|
||||
"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):",
|
||||
"depositPlaceholder": "Para que é este depósito?",
|
||||
"generating": "Gerando...",
|
||||
"generateInvoice": "Gerar invoice",
|
||||
"depositLightning": "Depositar Lightning",
|
||||
|
||||
"payInvoiceTitle": "Pagar invoice",
|
||||
"generatingInvoice": "Gerando invoice...",
|
||||
"waitingForPayment": "Aguardando pagamento...",
|
||||
"paymentReceived": "Pagamento recebido",
|
||||
"tokensIssued": "Tokens emitidos!",
|
||||
"error": "Erro",
|
||||
"unknownError": "Erro desconhecido",
|
||||
"back": "Voltar",
|
||||
"copyInvoice": "Copiar invoice",
|
||||
"description": "Descrição:",
|
||||
"invoiceCopiedToClipboard": "Invoice copiado para área de transferência",
|
||||
"deposited": "{amount} {unit} depositados",
|
||||
"@deposited": {
|
||||
"placeholders": {
|
||||
"amount": { "type": "String" },
|
||||
"unit": { "type": "String" }
|
||||
}
|
||||
},
|
||||
|
||||
"pasteLightningInvoice": "Cole o invoice Lightning:",
|
||||
"gettingQuote": "Obtendo cotação...",
|
||||
"validInvoice": "Invoice válido",
|
||||
"invalidInvoice": "Invoice inválido",
|
||||
"invalidInvoiceMalformed": "Invoice inválido ou malformado",
|
||||
"feeReserved": "Taxa reservada:",
|
||||
"total": "Total:",
|
||||
"paying": "Pagando...",
|
||||
"payInvoice": "Pagar invoice",
|
||||
"confirmPayment": "Confirmar pagamento",
|
||||
"pay": "Pagar",
|
||||
"fee": "taxa",
|
||||
"invoiceExpired": "Invoice expirado",
|
||||
"amountOutOfRange": "Valor fora do intervalo permitido",
|
||||
"resolvingType": "Resolvendo {type}...",
|
||||
"@resolvingType": {
|
||||
"placeholders": {
|
||||
"type": { "type": "String" }
|
||||
}
|
||||
},
|
||||
"invoiceAlreadyPaid": "Invoice já foi pago",
|
||||
"paymentError": "Erro ao pagar: {error}",
|
||||
"@paymentError": {
|
||||
"placeholders": {
|
||||
"error": { "type": "String" }
|
||||
}
|
||||
},
|
||||
"sent": "{amount} {unit} enviados",
|
||||
"@sent": {
|
||||
"placeholders": {
|
||||
"amount": { "type": "String" },
|
||||
"unit": { "type": "String" }
|
||||
}
|
||||
},
|
||||
|
||||
"filterAll": "Todos",
|
||||
"filterPending": "Pendentes",
|
||||
"filterEcash": "Ecash",
|
||||
"filterLightning": "Lightning",
|
||||
"receiveTokensToStart": "Receba tokens Cashu para começar",
|
||||
"noPendingTransactions": "Sem transações pendentes",
|
||||
"allTransactionsCompleted": "Todas as suas transações estão completas",
|
||||
"noEcashTransactions": "Sem transações Ecash",
|
||||
"sendOrReceiveTokens": "Envie ou receba tokens Cashu",
|
||||
"noLightningTransactions": "Sem transações Lightning",
|
||||
"depositOrWithdrawLightning": "Deposite ou saque via Lightning",
|
||||
"pendingStatus": "Pendente",
|
||||
"receivedStatus": "Recebido",
|
||||
"sentStatus": "Enviado",
|
||||
"now": "Agora",
|
||||
"agoMinutes": "Há {minutes} min",
|
||||
"@agoMinutes": {
|
||||
"placeholders": {
|
||||
"minutes": { "type": "int" }
|
||||
}
|
||||
},
|
||||
"agoHours": "Há {hours} h",
|
||||
"@agoHours": {
|
||||
"placeholders": {
|
||||
"hours": { "type": "int" }
|
||||
}
|
||||
},
|
||||
"agoDays": "Há {days} dias",
|
||||
"@agoDays": {
|
||||
"placeholders": {
|
||||
"days": { "type": "int" }
|
||||
}
|
||||
},
|
||||
"lightningInvoice": "Invoice Lightning",
|
||||
"receivedEcash": "Ecash Recebido",
|
||||
"sentEcash": "Ecash Enviado",
|
||||
"outgoingLightningPayment": "Pagamento Lightning Enviado",
|
||||
"invoiceNotAvailable": "Invoice não disponível",
|
||||
"tokenNotAvailable": "Token não disponível",
|
||||
"unit": "Unidade",
|
||||
"status": "Status",
|
||||
"pending": "Pendente",
|
||||
"memo": "Memo",
|
||||
"copyInvoiceButton": "COPIAR INVOICE",
|
||||
"copyButton": "COPIAR",
|
||||
"invoiceCopied": "Invoice copiado",
|
||||
"tokenCopied": "Token copiado",
|
||||
"speed": "VELOCIDADE:",
|
||||
|
||||
"settings": "Configurações",
|
||||
"walletSection": "WALLET",
|
||||
"backupSeedPhrase": "Backup da frase semente",
|
||||
"viewRecoveryWords": "Ver suas palavras de recuperação",
|
||||
"connectedMints": "Mints conectados",
|
||||
"manageCashuMints": "Gerenciar seus mints Cashu",
|
||||
"pinAccess": "PIN de acesso",
|
||||
"pinEnabled": "Ativado",
|
||||
"protectWithPin": "Proteger o app com PIN",
|
||||
"recoverTokens": "Recuperar tokens",
|
||||
"scanMintsWithSeed": "Escanear mints com frase semente",
|
||||
"appearanceSection": "IDIOMA",
|
||||
"language": "Idioma",
|
||||
"informationSection": "INFORMAÇÃO",
|
||||
"version": "Versão",
|
||||
"about": "Sobre",
|
||||
"deleteWallet": "Apagar wallet",
|
||||
"spanish": "Español",
|
||||
"english": "English",
|
||||
"portuguese": "Português",
|
||||
"french": "Français",
|
||||
"russian": "Русский",
|
||||
"german": "Deutsch",
|
||||
"italian": "Italiano",
|
||||
"korean": "한국어",
|
||||
"chinese": "中文",
|
||||
"japanese": "日本語",
|
||||
"swahili": "Kiswahili",
|
||||
|
||||
"mnemonicNotFound": "Mnemônico não encontrado",
|
||||
"createPin": "Criar PIN",
|
||||
"enterPinDigits": "Digite um PIN de 4 dígitos",
|
||||
"confirmPin": "Confirmar PIN",
|
||||
"enterPinAgain": "Digite o PIN novamente",
|
||||
"pinMismatch": "Os PINs não coincidem",
|
||||
"pinActivated": "PIN ativado",
|
||||
"pinDeactivated": "PIN desativado",
|
||||
"verifyPin": "Verificar PIN",
|
||||
"enterCurrentPin": "Digite seu PIN atual",
|
||||
"incorrectPin": "PIN incorreto",
|
||||
"selectLanguage": "Selecionar idioma",
|
||||
"languageChanged": "Idioma alterado para {language}",
|
||||
"@languageChanged": {
|
||||
"placeholders": {
|
||||
"language": { "type": "String" }
|
||||
}
|
||||
},
|
||||
"close": "Fechar",
|
||||
"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?",
|
||||
"actionIrreversible": "Esta ação é irreversível",
|
||||
"deleteWalletWarning": "Todos os dados serão excluídos, incluindo sua frase semente e tokens. Certifique-se de ter um backup.",
|
||||
"typeDeleteToConfirm": "Digite \"APAGAR\" para confirmar:",
|
||||
"deleteConfirmWord": "APAGAR",
|
||||
"deleteError": "Erro ao apagar: {error}",
|
||||
"@deleteError": {
|
||||
"placeholders": {
|
||||
"error": { "type": "String" }
|
||||
}
|
||||
},
|
||||
|
||||
"recoverTokensTitle": "Recuperar tokens",
|
||||
"recoverTokensDescription": "Escanear mints para recuperar tokens associados à sua frase semente (NUT-13)",
|
||||
"useCurrentSeedPhrase": "Usar minha frase semente atual",
|
||||
"scanWithSavedWords": "Escanear mints com as 12 palavras salvas",
|
||||
"useOtherSeedPhrase": "Usar outra frase semente",
|
||||
"recoverFromOtherWords": "Recuperar tokens de outras 12 palavras",
|
||||
"mintsToScan": "Mints para escanear:",
|
||||
"allMints": "Todos os mints ({count})",
|
||||
"@allMints": {
|
||||
"placeholders": {
|
||||
"count": { "type": "int" }
|
||||
}
|
||||
},
|
||||
"specificMint": "Um mint específico",
|
||||
"enterMnemonicWords": "Digite as 12 palavras separadas por espaços...",
|
||||
"scanMints": "Escanear mints",
|
||||
"selectMintToScan": "Selecione um mint para escanear",
|
||||
"mnemonicMustHaveWords": "O mnemônico deve ter 12 ou 24 palavras",
|
||||
"noConnectedMintsToScan": "Nenhum mint conectado para escanear",
|
||||
"recoveredTokens": "Recuperados {tokens} de {mints} mint(s)!",
|
||||
"@recoveredTokens": {
|
||||
"placeholders": {
|
||||
"tokens": { "type": "String" },
|
||||
"mints": { "type": "int" }
|
||||
}
|
||||
},
|
||||
"scanCompleteNoTokens": "Escaneamento completo. Nenhum token novo encontrado.",
|
||||
"mintsWithError": "({count} mint(s) com erro)",
|
||||
"@mintsWithError": {
|
||||
"placeholders": {
|
||||
"count": { "type": "int" }
|
||||
}
|
||||
},
|
||||
"recoveredFromMint": "Recuperados {tokens} de {mint}!",
|
||||
"@recoveredFromMint": {
|
||||
"placeholders": {
|
||||
"tokens": { "type": "String" },
|
||||
"mint": { "type": "String" }
|
||||
}
|
||||
},
|
||||
"noTokensFoundInMint": "Nenhum token encontrado em {mint}.",
|
||||
"@noTokensFoundInMint": {
|
||||
"placeholders": {
|
||||
"mint": { "type": "String" }
|
||||
}
|
||||
},
|
||||
"recoveredAndTransferred": "Recuperados e transferidos {amount} {unit} para sua wallet!",
|
||||
"@recoveredAndTransferred": {
|
||||
"placeholders": {
|
||||
"amount": { "type": "String" },
|
||||
"unit": { "type": "String" }
|
||||
}
|
||||
},
|
||||
"noTokensForMnemonic": "Nenhum token encontrado associado a esse mnemônico.",
|
||||
|
||||
"noConnectedMints": "Nenhum mint conectado",
|
||||
"addMintToStart": "Adicione um mint para começar",
|
||||
"addMint": "Adicionar mint",
|
||||
"mintDeleted": "Mint excluído",
|
||||
"activeMintUpdated": "Mint ativo atualizado",
|
||||
"mintUrl": "URL do mint:",
|
||||
"mintUrlPlaceholder": "https://mint.example.com",
|
||||
"urlMustStartWithHttps": "A URL deve começar com https://",
|
||||
"connectingToMint": "Conectando ao mint...",
|
||||
"mintAddedSuccessfully": "Mint adicionado com sucesso",
|
||||
"couldNotConnectToMint": "Não foi possível conectar ao mint",
|
||||
"add": "Adicionar",
|
||||
|
||||
"success": "Sucesso",
|
||||
"loading": "Carregando...",
|
||||
"retry": "Tentar novamente",
|
||||
|
||||
"activeMint": "Mint ativo",
|
||||
"mintMessage": "Mensagem do Mint",
|
||||
"url": "URL",
|
||||
"currency": "Moeda",
|
||||
"unknown": "Desconhecido",
|
||||
"useThisMint": "Usar este mint",
|
||||
"copyMintUrl": "Copiar URL do mint",
|
||||
"deleteMint": "Excluir mint",
|
||||
"copied": "{label} copiado",
|
||||
"@copied": {
|
||||
"placeholders": {
|
||||
"label": { "type": "String" }
|
||||
}
|
||||
},
|
||||
"deleteMintConfirmTitle": "Excluir mint",
|
||||
"deleteMintConfirmMessage": "Se você tiver saldo neste mint, ele será perdido. Tem certeza?",
|
||||
"delete": "Excluir",
|
||||
|
||||
"offlineSend": "Envio Offline",
|
||||
"selectAll": "Tudo",
|
||||
"deselectAll": "Nenhum",
|
||||
"selectNotesToSend": "Selecione as notas que deseja enviar:",
|
||||
"totalToSend": "Total a enviar",
|
||||
"notesSelected": "{count} notas selecionadas",
|
||||
"@notesSelected": {
|
||||
"placeholders": {
|
||||
"count": { "type": "int" }
|
||||
}
|
||||
},
|
||||
"loadingProofsError": "Erro ao carregar provas: {error}",
|
||||
"@loadingProofsError": {
|
||||
"placeholders": {
|
||||
"error": { "type": "String" }
|
||||
}
|
||||
},
|
||||
"creatingTokenError": "Erro ao criar token: {error}",
|
||||
"@creatingTokenError": {
|
||||
"placeholders": {
|
||||
"error": { "type": "String" }
|
||||
}
|
||||
},
|
||||
|
||||
"unknownState": "Estado desconhecido",
|
||||
"depositAmountTitle": "Depositar {amount} {unit}",
|
||||
"@depositAmountTitle": {
|
||||
"placeholders": {
|
||||
"amount": { "type": "String" },
|
||||
"unit": { "type": "String" }
|
||||
}
|
||||
},
|
||||
|
||||
"receiveNow": "Receber agora",
|
||||
"receiveLater": "Receber depois",
|
||||
"tokenSavedForLater": "Token salvo para resgatar depois",
|
||||
"noConnectionTokenSaved": "Sem conexão. Token salvo para resgatar depois.",
|
||||
"unknownMintOffline": "Este token é de um mint desconhecido. Conecte-se à internet para adicioná-lo e resgatar o token.",
|
||||
"noConnectionTryLater": "Sem conexão ao mint. Tente mais tarde.",
|
||||
"saveTokenError": "Erro ao salvar o token. Tente novamente.",
|
||||
"pendingTokenLimitReached": "Limite de tokens pendentes atingido (máx 50)",
|
||||
"filterToReceive": "Para receber",
|
||||
"noPendingTokens": "Sem tokens pendentes",
|
||||
"noPendingTokensHint": "Salve tokens para resgatar depois",
|
||||
"pendingBadge": "PENDENTE",
|
||||
"expiresInDays": "{days, plural, =1{Expira em 1 dia} other{Expira em {days} dias}}",
|
||||
"@expiresInDays": {
|
||||
"placeholders": {
|
||||
"days": { "type": "int" }
|
||||
}
|
||||
},
|
||||
"retryCount": "{count} tentativas",
|
||||
"@retryCount": {
|
||||
"placeholders": {
|
||||
"count": { "type": "int" }
|
||||
}
|
||||
},
|
||||
"claimNow": "Resgatar agora",
|
||||
"pendingTokenClaimedSuccess": "Resgatados {amount} {unit}",
|
||||
"@pendingTokenClaimedSuccess": {
|
||||
"placeholders": {
|
||||
"amount": { "type": "String" },
|
||||
"unit": { "type": "String" }
|
||||
}
|
||||
},
|
||||
"pendingTokensClaimed": "{count, plural, =1{Resgatado 1 token ({amount} {unit})} other{Resgatados {count} tokens ({amount} {unit})}}",
|
||||
"@pendingTokensClaimed": {
|
||||
"placeholders": {
|
||||
"count": { "type": "int" },
|
||||
"amount": { "type": "String" },
|
||||
"unit": { "type": "String" }
|
||||
}
|
||||
},
|
||||
|
||||
"scan": "Escanear",
|
||||
"scanQrCode": "Escanear QR",
|
||||
"scanCashuToken": "Escanear token Cashu",
|
||||
"scanLightningInvoice": "Escanear invoice",
|
||||
"scanningAnimatedQr": "Escaneando QR animado...",
|
||||
"pointCameraAtQr": "Aponte a câmera para o código QR",
|
||||
"pointCameraAtCashuQr": "Aponte a câmera para o QR do token Cashu",
|
||||
"pointCameraAtInvoiceQr": "Aponte a câmera para o QR do invoice",
|
||||
"unrecognizedQrCode": "Código QR não reconhecido",
|
||||
"scanCashuTokenHint": "Escaneie um token Cashu (cashuA... ou cashuB...)",
|
||||
"scanLightningInvoiceHint": "Escaneie um invoice Lightning (lnbc...)",
|
||||
"addMintQuestion": "Adicionar este mint?",
|
||||
"cameraPermissionDenied": "Permissão de câmera negada",
|
||||
"paymentRequestTitle": "Solicitação de pagamento",
|
||||
"paymentRequestFrom": "Solicitação de",
|
||||
"paymentRequestAmount": "Valor solicitado",
|
||||
"paymentRequestDescription": "Descrição",
|
||||
"paymentRequestMints": "Mints aceitos",
|
||||
"paymentRequestAnyMint": "Qualquer mint",
|
||||
"paymentRequestPay": "Pagar",
|
||||
"paymentRequestPaying": "Pagando...",
|
||||
"paymentRequestSuccess": "Pagamento enviado com sucesso",
|
||||
"paymentRequestNoTransport": "Esta solicitação não tem método de entrega configurado",
|
||||
"paymentRequestTransport": "Transporte",
|
||||
"paymentRequestMintNotAccepted": "Seu mint ativo não está na lista de mints aceitos",
|
||||
"paymentRequestUnitMismatch": "Unidade incompatível: a solicitação requer {unit}",
|
||||
"paymentRequestInsufficientBalance": "Saldo insuficiente",
|
||||
"paymentRequestErrorParsing": "Erro ao ler a solicitação de pagamento",
|
||||
|
||||
"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",
|
||||
|
||||
"request": "Solicitar",
|
||||
"requestPayment": "Solicitar pagamento",
|
||||
"requestPaymentDescription": "Gerar solicitação de pagamento unificada",
|
||||
"generateRequest": "Gerar solicitação",
|
||||
"generatingRequest": "Gerando solicitação...",
|
||||
"requestPaymentReceived": "Pagamento recebido",
|
||||
"requestDescriptionHint": "Descrição (opcional)",
|
||||
"universal": "Universal",
|
||||
"copiedToClipboard": "Copiado para a área de transferência"
|
||||
}
|
||||
@@ -0,0 +1,563 @@
|
||||
{
|
||||
"@@locale": "ru",
|
||||
|
||||
"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": "Cashu кошелёк с кубинской ДНК для всего мира. Брат LaChispa.",
|
||||
"couldNotOpenLink": "Не удалось открыть ссылку",
|
||||
|
||||
"deleteWalletQuestion": "Удалить кошелёк?",
|
||||
"actionIrreversible": "Это действие необратимо",
|
||||
"deleteWalletWarning": "Все данные будут удалены, включая сид-фразу и токены. Убедитесь, что у вас есть резервная копия.",
|
||||
"typeDeleteToConfirm": "Введите \"УДАЛИТЬ\" для подтверждения:",
|
||||
"deleteConfirmWord": "УДАЛИТЬ",
|
||||
"deleteError": "Ошибка удаления: {error}",
|
||||
"@deleteError": {
|
||||
"placeholders": {
|
||||
"error": { "type": "String" }
|
||||
}
|
||||
},
|
||||
|
||||
"recoverTokensTitle": "Восстановить токены",
|
||||
"recoverTokensDescription": "Сканировать mint для восстановления токенов, связанных с вашей сид-фразой (NUT-13)",
|
||||
"useCurrentSeedPhrase": "Использовать текущую сид-фразу",
|
||||
"scanWithSavedWords": "Сканировать mint с сохранёнными 12 словами",
|
||||
"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": "Восстановлено {tokens} из {mints} mint!",
|
||||
"@recoveredTokens": {
|
||||
"placeholders": {
|
||||
"tokens": { "type": "String" },
|
||||
"mints": { "type": "int" }
|
||||
}
|
||||
},
|
||||
"scanCompleteNoTokens": "Сканирование завершено. Новых токенов не найдено.",
|
||||
"mintsWithError": "({count} mint с ошибкой)",
|
||||
"@mintsWithError": {
|
||||
"placeholders": {
|
||||
"count": { "type": "int" }
|
||||
}
|
||||
},
|
||||
"recoveredFromMint": "Восстановлено {tokens} из {mint}!",
|
||||
"@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": "URL mint:",
|
||||
"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": "Копировать URL mint",
|
||||
"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": "Наведите камеру на QR Cashu токена",
|
||||
"pointCameraAtInvoiceQr": "Наведите камеру на QR счёта",
|
||||
"unrecognizedQrCode": "Нераспознанный QR-код",
|
||||
"scanCashuTokenHint": "Сканируйте Cashu токен (cashuA... или cashuB...)",
|
||||
"scanLightningInvoiceHint": "Сканируйте Lightning счёт (lnbc...)",
|
||||
"addMintQuestion": "Добавить этот mint?",
|
||||
"cameraPermissionDenied": "Доступ к камере запрещён",
|
||||
"paymentRequestTitle": "Запрос на оплату",
|
||||
"paymentRequestFrom": "Запрос от",
|
||||
"paymentRequestAmount": "Запрошенная сумма",
|
||||
"paymentRequestDescription": "Описание",
|
||||
"paymentRequestMints": "Принимаемые минты",
|
||||
"paymentRequestAnyMint": "Любой минт",
|
||||
"paymentRequestPay": "Оплатить",
|
||||
"paymentRequestPaying": "Оплата...",
|
||||
"paymentRequestSuccess": "Платёж успешно отправлен",
|
||||
"paymentRequestNoTransport": "В этом запросе не настроен метод доставки",
|
||||
"paymentRequestTransport": "Транспорт",
|
||||
"paymentRequestMintNotAccepted": "Ваш активный минт не в списке принимаемых",
|
||||
"paymentRequestUnitMismatch": "Несовместимая единица: запрос требует {unit}",
|
||||
"paymentRequestInsufficientBalance": "Недостаточный баланс",
|
||||
"paymentRequestErrorParsing": "Ошибка чтения запроса на оплату",
|
||||
|
||||
"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": "Невозможно удалить основной ключ",
|
||||
|
||||
"request": "Запросить",
|
||||
"requestPayment": "Запросить платёж",
|
||||
"requestPaymentDescription": "Создать унифицированный запрос на оплату",
|
||||
"generateRequest": "Создать запрос",
|
||||
"generatingRequest": "Создание запроса...",
|
||||
"requestPaymentReceived": "Платёж получен",
|
||||
"requestDescriptionHint": "Описание (необязательно)",
|
||||
"universal": "Универсальный",
|
||||
"copiedToClipboard": "Скопировано в буфер обмена"
|
||||
}
|
||||
@@ -0,0 +1,563 @@
|
||||
{
|
||||
"@@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",
|
||||
"paymentRequestTitle": "Ombi la malipo",
|
||||
"paymentRequestFrom": "Ombi kutoka",
|
||||
"paymentRequestAmount": "Kiasi kilichoombwa",
|
||||
"paymentRequestDescription": "Maelezo",
|
||||
"paymentRequestMints": "Mint zinazokubaliwa",
|
||||
"paymentRequestAnyMint": "Mint yoyote",
|
||||
"paymentRequestPay": "Lipa",
|
||||
"paymentRequestPaying": "Inalipa...",
|
||||
"paymentRequestSuccess": "Malipo yametumwa kwa mafanikio",
|
||||
"paymentRequestNoTransport": "Ombi hili halina njia ya uwasilishaji iliyosanidiwa",
|
||||
"paymentRequestTransport": "Njia ya usafirishaji",
|
||||
"paymentRequestMintNotAccepted": "Mint yako hai haiko kwenye orodha ya mint zinazokubaliwa",
|
||||
"paymentRequestUnitMismatch": "Kitengo kisichooana: ombi linahitaji {unit}",
|
||||
"paymentRequestInsufficientBalance": "Salio haitoshi",
|
||||
"paymentRequestErrorParsing": "Hitilafu wakati wa kusoma ombi la malipo",
|
||||
|
||||
"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",
|
||||
|
||||
"request": "Omba",
|
||||
"requestPayment": "Omba malipo",
|
||||
"requestPaymentDescription": "Tengeneza ombi la malipo la pamoja",
|
||||
"generateRequest": "Tengeneza ombi",
|
||||
"generatingRequest": "Inatengeneza ombi...",
|
||||
"requestPaymentReceived": "Malipo limepokelewa",
|
||||
"requestDescriptionHint": "Maelezo (si lazima)",
|
||||
"universal": "Universal",
|
||||
"copiedToClipboard": "Imenakiliwa kwenye ubao wa kunakili"
|
||||
}
|
||||
@@ -0,0 +1,563 @@
|
||||
{
|
||||
"@@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": "相机权限被拒绝",
|
||||
"paymentRequestTitle": "付款请求",
|
||||
"paymentRequestFrom": "来自",
|
||||
"paymentRequestAmount": "请求金额",
|
||||
"paymentRequestDescription": "描述",
|
||||
"paymentRequestMints": "接受的铸造厂",
|
||||
"paymentRequestAnyMint": "任何铸造厂",
|
||||
"paymentRequestPay": "支付",
|
||||
"paymentRequestPaying": "支付中...",
|
||||
"paymentRequestSuccess": "付款发送成功",
|
||||
"paymentRequestNoTransport": "此请求未配置交付方式",
|
||||
"paymentRequestTransport": "传输方式",
|
||||
"paymentRequestMintNotAccepted": "您的活跃铸造厂不在接受的铸造厂列表中",
|
||||
"paymentRequestUnitMismatch": "单位不兼容:请求需要 {unit}",
|
||||
"paymentRequestInsufficientBalance": "余额不足",
|
||||
"paymentRequestErrorParsing": "读取付款请求时出错",
|
||||
|
||||
"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... 或 hex(64/66字符)",
|
||||
"p2pkInvalidPubkey": "无效的公钥",
|
||||
"p2pkInvalidPrivateKey": "无效的私钥",
|
||||
"p2pkLockedToYou": "已锁定给您",
|
||||
"p2pkLockedToOther": "已锁定给其他密钥",
|
||||
"p2pkCannotUnlock": "您没有解锁此代币的密钥",
|
||||
"p2pkEnterPrivateKey": "输入私钥(nsec)",
|
||||
"p2pkDeleteTitle": "删除密钥",
|
||||
"p2pkDeleteConfirm": "删除此密钥?您将无法接收锁定给它的代币。",
|
||||
"p2pkRequiresConnection": "P2PK需要连接到铸造厂",
|
||||
"p2pkErrorMaxKeysReached": "已达到导入密钥的最大数量(10)",
|
||||
"p2pkErrorInvalidNsec": "无效的nsec",
|
||||
"p2pkErrorKeyAlreadyExists": "此密钥已存在",
|
||||
"p2pkErrorKeyNotFound": "未找到密钥",
|
||||
"p2pkErrorCannotDeletePrimary": "无法删除主密钥",
|
||||
|
||||
"request": "请求",
|
||||
"requestPayment": "请求付款",
|
||||
"requestPaymentDescription": "生成统一付款请求",
|
||||
"generateRequest": "生成请求",
|
||||
"generatingRequest": "正在生成请求...",
|
||||
"requestPaymentReceived": "已收到付款",
|
||||
"requestDescriptionHint": "描述(可选)",
|
||||
"universal": "通用",
|
||||
"copiedToClipboard": "已复制到剪贴板"
|
||||
}
|
||||
+33
-5
@@ -2,18 +2,21 @@ import 'package:flutter/material.dart';
|
||||
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 'src/rust/frb_generated.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 {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
// Inicializar CDK Flutter (Cashu SDK)
|
||||
await CdkFlutter.init();
|
||||
// Inicializar bridge Rust interno (Cashu SDK)
|
||||
await RustLib.init();
|
||||
|
||||
// Configurar orientación y estilo de barra de estado
|
||||
SystemChrome.setPreferredOrientations([
|
||||
@@ -30,11 +33,17 @@ void main() async {
|
||||
),
|
||||
);
|
||||
|
||||
// Inicializar PriceProvider
|
||||
final priceProvider = PriceProvider();
|
||||
priceProvider.initialize();
|
||||
|
||||
runApp(
|
||||
MultiProvider(
|
||||
providers: [
|
||||
ChangeNotifierProvider(create: (_) => SettingsProvider()),
|
||||
ChangeNotifierProvider(create: (_) => WalletProvider()),
|
||||
ChangeNotifierProvider(create: (_) => P2PKProvider()),
|
||||
ChangeNotifierProvider.value(value: priceProvider),
|
||||
],
|
||||
child: const ElCajuApp(),
|
||||
),
|
||||
@@ -46,6 +55,8 @@ class ElCajuApp extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final settingsProvider = context.watch<SettingsProvider>();
|
||||
|
||||
return MaterialApp(
|
||||
title: 'ElCaju',
|
||||
debugShowCheckedModeBanner: false,
|
||||
@@ -61,8 +72,25 @@ class ElCajuApp extends StatelessWidget {
|
||||
supportedLocales: const [
|
||||
Locale('es'), // Español (por defecto)
|
||||
Locale('en'), // English
|
||||
Locale('pt'), // Português
|
||||
Locale('fr'), // Français
|
||||
Locale('ru'), // Русский
|
||||
Locale('de'), // Deutsch
|
||||
Locale('it'), // Italiano
|
||||
Locale('ko'), // 한국어
|
||||
Locale('zh'), // 中文
|
||||
Locale('ja'), // 日本語
|
||||
Locale('sw'), // Kiswahili
|
||||
],
|
||||
locale: null, // null = detectar del sistema
|
||||
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(),
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 '../src/rust/api/keys.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');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import '../core/services/price_service.dart';
|
||||
|
||||
/// Provider para manejar precios de BTC con caché
|
||||
class PriceProvider extends ChangeNotifier {
|
||||
/// Precio de BTC en USD (caché)
|
||||
double? _btcPriceUsd;
|
||||
|
||||
/// Precio de BTC en EUR (caché)
|
||||
double? _btcPriceEur;
|
||||
|
||||
/// Timestamp de última actualización
|
||||
DateTime? _lastUpdate;
|
||||
|
||||
/// Intervalo mínimo entre actualizaciones (60 segundos)
|
||||
static const _minRefreshInterval = Duration(seconds: 60);
|
||||
|
||||
/// Timer para refresh automático
|
||||
Timer? _refreshTimer;
|
||||
|
||||
/// Error de última operación
|
||||
String? _error;
|
||||
|
||||
/// Getters
|
||||
double? get btcPriceUsd => _btcPriceUsd;
|
||||
double? get btcPriceEur => _btcPriceEur;
|
||||
DateTime? get lastUpdate => _lastUpdate;
|
||||
String? get error => _error;
|
||||
bool get hasPrice => _btcPriceUsd != null;
|
||||
|
||||
/// Obtiene el precio de BTC para una moneda específica
|
||||
double? getBtcPrice(String currency) {
|
||||
switch (currency.toLowerCase()) {
|
||||
case 'usd':
|
||||
return _btcPriceUsd;
|
||||
case 'eur':
|
||||
return _btcPriceEur;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Verifica si necesita actualizar el precio
|
||||
bool get _needsRefresh {
|
||||
if (_lastUpdate == null) return true;
|
||||
return DateTime.now().difference(_lastUpdate!) > _minRefreshInterval;
|
||||
}
|
||||
|
||||
/// Inicializa el provider y comienza refresh automático
|
||||
Future<void> initialize() async {
|
||||
await refreshPrices();
|
||||
_startAutoRefresh();
|
||||
}
|
||||
|
||||
/// Inicia el timer de refresh automático
|
||||
void _startAutoRefresh() {
|
||||
_refreshTimer?.cancel();
|
||||
_refreshTimer = Timer.periodic(_minRefreshInterval, (_) {
|
||||
refreshPrices();
|
||||
});
|
||||
}
|
||||
|
||||
/// Actualiza los precios desde la API
|
||||
Future<void> refreshPrices() async {
|
||||
if (!_needsRefresh && _btcPriceUsd != null) return;
|
||||
|
||||
try {
|
||||
_error = null;
|
||||
|
||||
// Obtener precio USD
|
||||
_btcPriceUsd = await PriceService.getBtcPrice('USD');
|
||||
|
||||
// Intentar obtener EUR también
|
||||
try {
|
||||
_btcPriceEur = await PriceService.getBtcPrice('EUR');
|
||||
} catch (_) {
|
||||
// EUR es opcional
|
||||
}
|
||||
|
||||
_lastUpdate = DateTime.now();
|
||||
notifyListeners();
|
||||
} catch (e) {
|
||||
_error = e.toString();
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
/// Convierte cantidad en unidad fiat (centavos) a sats
|
||||
/// Usa caché si está disponible, sino llama a la API
|
||||
Future<BigInt> fiatCentsToSats(BigInt cents, String fiatCurrency) async {
|
||||
final btcPrice = getBtcPrice(fiatCurrency);
|
||||
|
||||
if (btcPrice != null) {
|
||||
// Usar precio cacheado
|
||||
final fiatAmount = cents.toDouble() / 100;
|
||||
final btcAmount = fiatAmount / btcPrice;
|
||||
final sats = (btcAmount * 100000000).round();
|
||||
return BigInt.from(sats);
|
||||
}
|
||||
|
||||
// Fallback a API directa
|
||||
return PriceService.fiatCentsToSats(cents, fiatCurrency);
|
||||
}
|
||||
|
||||
/// Convierte sats a cantidad en unidad fiat (centavos)
|
||||
Future<BigInt> satsToFiatCents(BigInt sats, String fiatCurrency) async {
|
||||
final btcPrice = getBtcPrice(fiatCurrency);
|
||||
|
||||
if (btcPrice != null) {
|
||||
// Usar precio cacheado
|
||||
final btcAmount = sats.toDouble() / 100000000;
|
||||
final fiatAmount = btcAmount * btcPrice;
|
||||
return BigInt.from((fiatAmount * 100).round());
|
||||
}
|
||||
|
||||
// Fallback a API directa
|
||||
return PriceService.satsToFiatCents(sats, fiatCurrency);
|
||||
}
|
||||
|
||||
/// Formatea el precio para display
|
||||
String formatBtcPrice(String currency) {
|
||||
final price = getBtcPrice(currency);
|
||||
if (price == null) return '--';
|
||||
|
||||
// Formatear con separador de miles
|
||||
return price.toStringAsFixed(0).replaceAllMapped(
|
||||
RegExp(r'(\d{1,3})(?=(\d{3})+(?!\d))'),
|
||||
(match) => '${match[1]},',
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_refreshTimer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,377 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:lucide_icons/lucide_icons.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';
|
||||
import '../../providers/wallet_provider.dart';
|
||||
import '../4_receive/receive_screen.dart';
|
||||
import '../7_melt/melt_screen.dart';
|
||||
import '../11_payment_request/payment_request_screen.dart';
|
||||
|
||||
/// Pantalla de escaneo QR con soporte para diferentes modos
|
||||
class ScanScreen extends StatefulWidget {
|
||||
/// Modo de escaneo
|
||||
final ScanMode mode;
|
||||
|
||||
/// Callback cuando se detecta un dato válido (para modos específicos)
|
||||
final void Function(String data)? onDataScanned;
|
||||
|
||||
const ScanScreen({
|
||||
super.key,
|
||||
this.mode = ScanMode.any,
|
||||
this.onDataScanned,
|
||||
});
|
||||
|
||||
@override
|
||||
State<ScanScreen> createState() => _ScanScreenState();
|
||||
}
|
||||
|
||||
class _ScanScreenState extends State<ScanScreen> {
|
||||
bool _isProcessing = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = L10n.of(context)!;
|
||||
|
||||
return 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(
|
||||
_getTitleForMode(l10n),
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
centerTitle: true,
|
||||
),
|
||||
body: SafeArea(
|
||||
child: Stack(
|
||||
children: [
|
||||
// Scanner
|
||||
QrScannerWidget(
|
||||
onDetect: _onCodeDetected,
|
||||
showFlashControl: true,
|
||||
showCameraSwitch: false,
|
||||
),
|
||||
|
||||
// Instrucciones en la parte inferior
|
||||
Positioned(
|
||||
bottom: 100,
|
||||
left: 24,
|
||||
right: 24,
|
||||
child: Text(
|
||||
_getInstructionForMode(l10n),
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 16,
|
||||
color: Colors.white.withValues(alpha: 0.8),
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
|
||||
// Indicador de procesamiento
|
||||
if (_isProcessing)
|
||||
Container(
|
||||
color: Colors.black54,
|
||||
child: const Center(
|
||||
child: CircularProgressIndicator(
|
||||
color: AppColors.primaryAction,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _getTitleForMode(L10n l10n) {
|
||||
switch (widget.mode) {
|
||||
case ScanMode.any:
|
||||
return l10n.scanQrCode;
|
||||
case ScanMode.cashuOnly:
|
||||
return l10n.scanCashuToken;
|
||||
case ScanMode.invoiceOnly:
|
||||
return l10n.scanLightningInvoice;
|
||||
}
|
||||
}
|
||||
|
||||
String _getInstructionForMode(L10n l10n) {
|
||||
switch (widget.mode) {
|
||||
case ScanMode.any:
|
||||
return l10n.pointCameraAtQr;
|
||||
case ScanMode.cashuOnly:
|
||||
return l10n.pointCameraAtCashuQr;
|
||||
case ScanMode.invoiceOnly:
|
||||
return l10n.pointCameraAtInvoiceQr;
|
||||
}
|
||||
}
|
||||
|
||||
void _onCodeDetected(String rawData) async {
|
||||
if (_isProcessing) return;
|
||||
|
||||
setState(() => _isProcessing = true);
|
||||
|
||||
try {
|
||||
// Parsear los datos
|
||||
final parsed = IncomingDataParser.parse(rawData);
|
||||
|
||||
// Verificar si es válido para el modo actual
|
||||
if (!IncomingDataParser.isValidForMode(parsed, widget.mode)) {
|
||||
_showInvalidTypeError(parsed.type);
|
||||
return; // finally se encarga de setState
|
||||
}
|
||||
|
||||
// Procesar según el tipo y modo
|
||||
await _processData(parsed);
|
||||
} catch (e) {
|
||||
_showError(e.toString());
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() => _isProcessing = false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _processData(ParsedData data) async {
|
||||
switch (widget.mode) {
|
||||
case ScanMode.any:
|
||||
await _handleAnyMode(data);
|
||||
break;
|
||||
case ScanMode.cashuOnly:
|
||||
_handleCashuOnlyMode(data);
|
||||
break;
|
||||
case ScanMode.invoiceOnly:
|
||||
_handleInvoiceOnlyMode(data);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _handleAnyMode(ParsedData data) async {
|
||||
final l10n = L10n.of(context)!;
|
||||
|
||||
switch (data.type) {
|
||||
case IncomingDataType.cashuToken:
|
||||
// Navegar a ReceiveScreen con el token pre-cargado
|
||||
if (mounted) {
|
||||
Navigator.pop(context); // Cerrar scanner
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => ReceiveScreen(initialToken: data.raw),
|
||||
),
|
||||
);
|
||||
}
|
||||
break;
|
||||
|
||||
case IncomingDataType.lightningInvoice:
|
||||
// Navegar a MeltScreen con el invoice pre-cargado
|
||||
if (mounted) {
|
||||
Navigator.pop(context); // Cerrar scanner
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => MeltScreen(initialInvoice: data.invoiceBolt11),
|
||||
),
|
||||
);
|
||||
}
|
||||
break;
|
||||
|
||||
case IncomingDataType.mintUrl:
|
||||
// Mostrar diálogo para agregar mint
|
||||
if (mounted) {
|
||||
await _showAddMintDialog(data.mintUrl!);
|
||||
}
|
||||
break;
|
||||
|
||||
case IncomingDataType.paymentRequest:
|
||||
if (mounted) {
|
||||
Navigator.pop(context);
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => PaymentRequestScreen(
|
||||
encodedRequest: data.raw,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
break;
|
||||
|
||||
case IncomingDataType.unknown:
|
||||
// Si es una URL https, intentar verificar si es un mint
|
||||
if (data.raw.toLowerCase().startsWith('https://')) {
|
||||
await _tryVerifyAndAddMint(data.raw);
|
||||
} else {
|
||||
_showError(l10n.unrecognizedQrCode);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
void _handleCashuOnlyMode(ParsedData data) {
|
||||
if (data.type == IncomingDataType.cashuToken) {
|
||||
// Retornar el token vía callback
|
||||
Navigator.pop(context, data.raw);
|
||||
widget.onDataScanned?.call(data.raw);
|
||||
} else {
|
||||
_showInvalidTypeError(data.type);
|
||||
}
|
||||
}
|
||||
|
||||
void _handleInvoiceOnlyMode(ParsedData data) {
|
||||
if (data.type == IncomingDataType.lightningInvoice ||
|
||||
(data.type == IncomingDataType.paymentRequest &&
|
||||
data.invoiceBolt11 != null)) {
|
||||
// Return the invoice — works for pure BOLT11 and BIP-321 URIs with lightning=
|
||||
Navigator.pop(context, data.invoiceBolt11 ?? data.raw);
|
||||
widget.onDataScanned?.call(data.invoiceBolt11 ?? data.raw);
|
||||
} else {
|
||||
_showInvalidTypeError(data.type);
|
||||
}
|
||||
}
|
||||
|
||||
/// Intenta verificar si una URL es un mint válido antes de mostrar el diálogo
|
||||
Future<void> _tryVerifyAndAddMint(String rawUrl) async {
|
||||
final l10n = L10n.of(context)!;
|
||||
|
||||
try {
|
||||
// Extraer URL base
|
||||
final uri = Uri.parse(rawUrl);
|
||||
String mintUrl = '${uri.scheme}://${uri.host}';
|
||||
if (uri.port != 443 && uri.port != 0) {
|
||||
mintUrl += ':${uri.port}';
|
||||
}
|
||||
|
||||
// Verificar si es un mint real (timeout corto)
|
||||
final walletProvider = context.read<WalletProvider>();
|
||||
final isValidMint = await walletProvider.canReachMint(mintUrl);
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
if (isValidMint) {
|
||||
// Es un mint válido → mostrar diálogo
|
||||
await _showAddMintDialog(mintUrl);
|
||||
} else {
|
||||
// No es un mint → QR no reconocido
|
||||
_showError(l10n.unrecognizedQrCode);
|
||||
}
|
||||
} catch (_) {
|
||||
if (mounted) {
|
||||
_showError(l10n.unrecognizedQrCode);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _showAddMintDialog(String mintUrl) async {
|
||||
final l10n = L10n.of(context)!;
|
||||
|
||||
final result = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
backgroundColor: AppColors.deepVoidPurple,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
title: Text(
|
||||
l10n.addMintQuestion,
|
||||
style: const TextStyle(color: Colors.white),
|
||||
),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
mintUrl,
|
||||
style: TextStyle(
|
||||
color: AppColors.textSecondary,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
child: Text(
|
||||
l10n.cancel,
|
||||
style: const TextStyle(color: Colors.white70),
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
child: Text(
|
||||
l10n.add,
|
||||
style: const TextStyle(color: AppColors.primaryAction),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (result == true && mounted) {
|
||||
try {
|
||||
final walletProvider = context.read<WalletProvider>();
|
||||
await walletProvider.addMint(mintUrl);
|
||||
|
||||
if (mounted) {
|
||||
Navigator.pop(context); // Cerrar scanner
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(l10n.mintAddedSuccessfully),
|
||||
backgroundColor: AppColors.success,
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
_showError(l10n.couldNotConnectToMint);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _showInvalidTypeError(IncomingDataType detectedType) {
|
||||
final l10n = L10n.of(context)!;
|
||||
|
||||
String message;
|
||||
switch (widget.mode) {
|
||||
case ScanMode.cashuOnly:
|
||||
message = l10n.scanCashuTokenHint;
|
||||
break;
|
||||
case ScanMode.invoiceOnly:
|
||||
message = l10n.scanLightningInvoiceHint;
|
||||
break;
|
||||
case ScanMode.any:
|
||||
message = l10n.unrecognizedQrCode;
|
||||
break;
|
||||
}
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(message),
|
||||
backgroundColor: AppColors.warning,
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showError(String message) {
|
||||
if (!mounted) return;
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(message),
|
||||
backgroundColor: AppColors.error,
|
||||
duration: const Duration(seconds: 3),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,338 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:lucide_icons/lucide_icons.dart';
|
||||
import 'package:elcaju/l10n/app_localizations.dart';
|
||||
import '../../core/constants/colors.dart';
|
||||
import '../../core/constants/dimensions.dart';
|
||||
import '../../core/utils/formatters.dart';
|
||||
import '../../src/rust/api/payment_request.dart';
|
||||
import '../../widgets/common/gradient_background.dart';
|
||||
import '../../widgets/common/glass_card.dart';
|
||||
import '../../widgets/common/primary_button.dart';
|
||||
import '../../providers/wallet_provider.dart';
|
||||
|
||||
/// Pantalla de confirmación para pagar un Payment Request (NUT-18/26)
|
||||
class PaymentRequestScreen extends StatefulWidget {
|
||||
/// Raw encoded payment request (creqA, CREQB1, or bitcoin:?creq=)
|
||||
final String encodedRequest;
|
||||
|
||||
const PaymentRequestScreen({super.key, required this.encodedRequest});
|
||||
|
||||
@override
|
||||
State<PaymentRequestScreen> createState() => _PaymentRequestScreenState();
|
||||
}
|
||||
|
||||
class _PaymentRequestScreenState extends State<PaymentRequestScreen> {
|
||||
PaymentRequestInfo? _info;
|
||||
String? _parseError;
|
||||
bool _isPaying = false;
|
||||
String? _payError;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_parseRequest();
|
||||
}
|
||||
|
||||
void _parseRequest() {
|
||||
try {
|
||||
final info = PaymentRequestInfo.parse(encoded: widget.encodedRequest);
|
||||
setState(() => _info = info);
|
||||
} catch (e) {
|
||||
setState(() => _parseError = e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = L10n.of(context)!;
|
||||
|
||||
return GradientBackground(
|
||||
child: Scaffold(
|
||||
backgroundColor: Colors.transparent,
|
||||
appBar: AppBar(
|
||||
backgroundColor: Colors.transparent,
|
||||
elevation: 0,
|
||||
title: Text(
|
||||
l10n.paymentRequestTitle,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
leading: IconButton(
|
||||
icon: const Icon(LucideIcons.arrowLeft),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
),
|
||||
body: _parseError != null
|
||||
? _buildError(l10n)
|
||||
: _info == null
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: _buildContent(l10n),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildError(L10n l10n) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(AppDimensions.paddingLarge),
|
||||
child: GlassCard(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(LucideIcons.alertCircle, color: Colors.redAccent, size: 48),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
l10n.paymentRequestErrorParsing,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.white,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
_parseError!,
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 13,
|
||||
color: Colors.white.withValues(alpha: 0.6),
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildContent(L10n l10n) {
|
||||
final info = _info!;
|
||||
final walletProvider = context.watch<WalletProvider>();
|
||||
final activeUnit = walletProvider.activeUnit;
|
||||
final requestUnit = info.unit ?? 'sat';
|
||||
|
||||
// Validaciones
|
||||
final unitMismatch = info.unit != null && info.unit != activeUnit;
|
||||
String stripSlash(String url) =>
|
||||
url.endsWith('/') ? url.substring(0, url.length - 1) : url;
|
||||
final normalizedMints = info.mints.map(stripSlash).toSet();
|
||||
final activeMint = walletProvider.activeMintUrl;
|
||||
final mintNotAccepted = normalizedMints.isNotEmpty &&
|
||||
(activeMint == null || !normalizedMints.contains(stripSlash(activeMint)));
|
||||
final hasTransport = info.transports.isNotEmpty;
|
||||
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(AppDimensions.paddingLarge),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// Monto
|
||||
if (info.amount != null)
|
||||
_buildAmountCard(info.amount!, requestUnit, l10n),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Detalles
|
||||
GlassCard(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Descripción
|
||||
if (info.description != null && info.description!.isNotEmpty) ...[
|
||||
_buildDetailRow(
|
||||
LucideIcons.fileText,
|
||||
l10n.paymentRequestDescription,
|
||||
info.description!,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
],
|
||||
|
||||
// Mints aceptados
|
||||
_buildDetailRow(
|
||||
LucideIcons.server,
|
||||
l10n.paymentRequestMints,
|
||||
info.mints.isEmpty
|
||||
? l10n.paymentRequestAnyMint
|
||||
: info.mints.map((m) => Uri.parse(m).host).join(', '),
|
||||
),
|
||||
|
||||
// Transporte (solo mostrar si hay transporte configurado)
|
||||
if (info.transports.isNotEmpty) ...[
|
||||
const SizedBox(height: 12),
|
||||
_buildDetailRow(
|
||||
LucideIcons.send,
|
||||
l10n.paymentRequestTransport,
|
||||
info.transports
|
||||
.map((t) => t.transportType == 'nostr' ? 'Nostr (NIP-17)' : 'HTTP POST')
|
||||
.join(', '),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Warnings
|
||||
if (unitMismatch)
|
||||
_buildWarning(
|
||||
LucideIcons.alertTriangle,
|
||||
l10n.paymentRequestUnitMismatch(requestUnit),
|
||||
),
|
||||
if (mintNotAccepted)
|
||||
_buildWarning(
|
||||
LucideIcons.alertTriangle,
|
||||
l10n.paymentRequestMintNotAccepted,
|
||||
),
|
||||
if (!hasTransport)
|
||||
_buildWarning(
|
||||
LucideIcons.info,
|
||||
l10n.paymentRequestNoTransport,
|
||||
),
|
||||
|
||||
if (_payError != null) ...[
|
||||
const SizedBox(height: 12),
|
||||
_buildWarning(LucideIcons.alertCircle, _payError!),
|
||||
],
|
||||
|
||||
const SizedBox(height: 32),
|
||||
|
||||
// Botón pagar
|
||||
PrimaryButton(
|
||||
text: _isPaying ? l10n.paymentRequestPaying : l10n.paymentRequestPay,
|
||||
icon: LucideIcons.zap,
|
||||
isLoading: _isPaying,
|
||||
onPressed: (!hasTransport || _isPaying)
|
||||
? null
|
||||
: _pay,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAmountCard(BigInt amount, String unit, L10n l10n) {
|
||||
return GlassCard(
|
||||
child: Column(
|
||||
children: [
|
||||
Text(
|
||||
l10n.paymentRequestAmount,
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 14,
|
||||
color: Colors.white.withValues(alpha: 0.7),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
UnitFormatter.formatBalanceWithUnit(amount, unit),
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 36,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDetailRow(IconData icon, String label, String value) {
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(icon, size: 18, color: AppColors.primaryAction),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Colors.white.withValues(alpha: 0.6),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
value,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 14,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildWarning(IconData icon, String text) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: GlassCard(
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(icon, size: 20, color: Colors.amber),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text(
|
||||
text,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 13,
|
||||
color: Colors.amber,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _pay() async {
|
||||
setState(() {
|
||||
_isPaying = true;
|
||||
_payError = null;
|
||||
});
|
||||
|
||||
try {
|
||||
final walletProvider = context.read<WalletProvider>();
|
||||
await walletProvider.payPaymentRequest(
|
||||
widget.encodedRequest,
|
||||
customAmount: _info!.amount,
|
||||
);
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
// Éxito — mostrar snackbar y volver
|
||||
final l10n = L10n.of(context)!;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(l10n.paymentRequestSuccess),
|
||||
backgroundColor: Colors.green.shade700,
|
||||
),
|
||||
);
|
||||
Navigator.pop(context, true);
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_isPaying = false;
|
||||
_payError = e.toString();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,904 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:qr_flutter/qr_flutter.dart';
|
||||
import 'package:lucide_icons/lucide_icons.dart';
|
||||
import 'package:share_plus/share_plus.dart';
|
||||
import '../../src/rust/api/wallet.dart';
|
||||
import '../../src/rust/api/payment_request.dart';
|
||||
import 'package:elcaju/l10n/app_localizations.dart';
|
||||
import '../../core/constants/colors.dart';
|
||||
import '../../core/constants/dimensions.dart';
|
||||
import '../../core/utils/formatters.dart';
|
||||
// TODO: re-enable once CDK fixes NIP-17 (cashubtc/cdk#1807)
|
||||
// import '../../core/utils/bip321_builder.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/common/numpad_widget.dart';
|
||||
import '../../providers/wallet_provider.dart';
|
||||
|
||||
enum RequestStatus { input, generating, waiting, received, error }
|
||||
enum QrMode { universal, cashu, lightning }
|
||||
|
||||
/// Pantalla para solicitar pagos via Payment Request unificado.
|
||||
/// Genera creqB (Cashu/Nostr) + Lightning invoice en paralelo.
|
||||
class RequestScreen extends StatefulWidget {
|
||||
const RequestScreen({super.key});
|
||||
|
||||
@override
|
||||
State<RequestScreen> createState() => _RequestScreenState();
|
||||
}
|
||||
|
||||
class _RequestScreenState extends State<RequestScreen> {
|
||||
final TextEditingController _descriptionController = TextEditingController();
|
||||
|
||||
// Input state
|
||||
String _amountValue = '';
|
||||
late String _activeUnit;
|
||||
|
||||
// Request state
|
||||
RequestStatus _status = RequestStatus.input;
|
||||
String? _errorMessage;
|
||||
|
||||
// Payment data
|
||||
String? _creqB;
|
||||
String? _bolt11;
|
||||
// TODO: re-enable QrMode.cashu / universal once CDK fixes NIP-17 sender pubkey bug
|
||||
// See: https://github.com/cashubtc/cdk/issues/1807
|
||||
// QrMode _activeMode = QrMode.lightning;
|
||||
bool _paymentHandled = false;
|
||||
|
||||
// Listeners
|
||||
StreamSubscription<NostrPaymentEvent>? _nostrSubscription;
|
||||
StreamSubscription<MintQuote>? _mintSubscription;
|
||||
|
||||
// NFC
|
||||
NfcState _nfcState = NfcState.unsupported;
|
||||
bool _nfcEmulating = false;
|
||||
|
||||
// Success
|
||||
BigInt _receivedAmount = BigInt.zero;
|
||||
|
||||
late final WalletProvider _walletProvider;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_walletProvider = context.read<WalletProvider>();
|
||||
_activeUnit = _walletProvider.activeUnit;
|
||||
_checkNfc();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_descriptionController.dispose();
|
||||
// TODO: re-enable once CDK fixes NIP-17 (cashubtc/cdk#1807)
|
||||
// No-op while Nostr is disabled (_nostrSubscription is always null, _creqB is always null)
|
||||
_nostrSubscription?.cancel();
|
||||
_mintSubscription?.cancel();
|
||||
if (_nfcEmulating) NfcService.stopEmulating();
|
||||
if (!_paymentHandled && _creqB != null) {
|
||||
_walletProvider.removePendingNostrRequest();
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _checkNfc() async {
|
||||
final state = await NfcService.checkState();
|
||||
if (mounted) setState(() => _nfcState = state);
|
||||
}
|
||||
|
||||
String get _unitLabel => UnitFormatter.getUnitLabel(_activeUnit);
|
||||
BigInt get _amount => UnitFormatter.parseRawDigits(_amountValue, _activeUnit);
|
||||
bool get _isValidAmount => _amount > BigInt.zero;
|
||||
|
||||
// ─── Build ───
|
||||
|
||||
@override
|
||||
Widget build(BuildContext 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.of(context)!.requestPayment,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
if (_status == RequestStatus.waiting)
|
||||
IconButton(
|
||||
icon: const Icon(LucideIcons.share2, color: Colors.white),
|
||||
onPressed: _shareRequest,
|
||||
),
|
||||
],
|
||||
),
|
||||
body: SafeArea(child: _buildBody()),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBody() {
|
||||
switch (_status) {
|
||||
case RequestStatus.input:
|
||||
return _buildInputView();
|
||||
case RequestStatus.generating:
|
||||
return _buildGeneratingView();
|
||||
case RequestStatus.waiting:
|
||||
return _buildWaitingView();
|
||||
case RequestStatus.received:
|
||||
return _buildSuccessView();
|
||||
case RequestStatus.error:
|
||||
return _buildErrorView();
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Input View ───
|
||||
|
||||
Widget _buildInputView() {
|
||||
return Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(AppDimensions.paddingMedium),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_buildAmountSection(),
|
||||
const SizedBox(height: AppDimensions.paddingLarge),
|
||||
_buildDescriptionSection(),
|
||||
const SizedBox(height: AppDimensions.paddingMedium),
|
||||
if (_errorMessage != null) _buildErrorMessage(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(AppDimensions.paddingMedium),
|
||||
child: PrimaryButton(
|
||||
text: L10n.of(context)!.generateRequest,
|
||||
onPressed: _isValidAmount ? _generateRequest : null,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAmountSection() {
|
||||
final displayAmount =
|
||||
UnitFormatter.formatRawDigitsForDisplay(_amountValue, _activeUnit);
|
||||
return Column(
|
||||
children: [
|
||||
Text(
|
||||
displayAmount,
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 48,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: _isValidAmount || _amountValue.isEmpty
|
||||
? Colors.white
|
||||
: AppColors.error,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
_unitLabel,
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 18,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppDimensions.paddingMedium),
|
||||
NumpadWidget(
|
||||
value: _amountValue,
|
||||
onChanged: (v) => setState(() {
|
||||
_errorMessage = null;
|
||||
_amountValue = v;
|
||||
}),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDescriptionSection() {
|
||||
final l10n = L10n.of(context)!;
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
l10n.descriptionOptional,
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppDimensions.paddingSmall),
|
||||
GlassCard(
|
||||
padding: const EdgeInsets.all(AppDimensions.paddingSmall),
|
||||
child: TextField(
|
||||
controller: _descriptionController,
|
||||
maxLines: 2,
|
||||
maxLength: 100,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 16,
|
||||
color: Colors.white,
|
||||
),
|
||||
decoration: InputDecoration(
|
||||
hintText: l10n.requestDescriptionHint,
|
||||
hintStyle: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 16,
|
||||
color: Colors.white.withValues(alpha: 0.3),
|
||||
),
|
||||
border: InputBorder.none,
|
||||
counterStyle: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 12,
|
||||
color: AppColors.textSecondary.withValues(alpha: 0.5),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildErrorMessage() {
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: AppDimensions.paddingMedium),
|
||||
padding: const EdgeInsets.all(AppDimensions.paddingMedium),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.error.withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: AppColors.error.withValues(alpha: 0.3),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(LucideIcons.alertCircle, color: AppColors.error, size: 20),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
_errorMessage!,
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 14,
|
||||
color: AppColors.error,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Generating View ───
|
||||
|
||||
Widget _buildGeneratingView() {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const CircularProgressIndicator(color: AppColors.primaryAction),
|
||||
const SizedBox(height: AppDimensions.paddingMedium),
|
||||
Text(
|
||||
L10n.of(context)!.generatingRequest,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 16,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Waiting View (QR + sub-toggle + actions) ───
|
||||
|
||||
Widget _buildWaitingView() {
|
||||
final l10n = L10n.of(context)!;
|
||||
final formattedAmount = UnitFormatter.formatBalance(_amount, _activeUnit);
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(AppDimensions.paddingMedium),
|
||||
child: Column(
|
||||
children: [
|
||||
// Amount header
|
||||
Text(
|
||||
'$formattedAmount $_unitLabel',
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 28,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppDimensions.paddingLarge),
|
||||
|
||||
// QR Code with logo overlay
|
||||
Center(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
QrImageView(
|
||||
data: _getActiveQrContent(),
|
||||
version: QrVersions.auto,
|
||||
size: 260,
|
||||
backgroundColor: Colors.white,
|
||||
// TODO: use QrErrorCorrectLevel.M for universal mode once CDK fixes NIP-17 (cashubtc/cdk#1807)
|
||||
errorCorrectionLevel: QrErrorCorrectLevel.H,
|
||||
),
|
||||
// TODO: re-enable Cashu logo overlay once CDK fixes NIP-17 (cashubtc/cdk#1807)
|
||||
// if (_activeMode == QrMode.cashu)
|
||||
// Container(
|
||||
// width: 48, height: 48,
|
||||
// decoration: BoxDecoration(
|
||||
// color: Colors.white,
|
||||
// borderRadius: BorderRadius.circular(10),
|
||||
// border: Border.all(color: Colors.white, width: 4),
|
||||
// ),
|
||||
// child: ClipRRect(
|
||||
// borderRadius: BorderRadius.circular(6),
|
||||
// child: Image.asset('assets/img/cashu.png', fit: BoxFit.contain),
|
||||
// ),
|
||||
// ),
|
||||
Container(
|
||||
width: 48,
|
||||
height: 48,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.bitcoinOrange,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border: Border.all(color: Colors.white, width: 4),
|
||||
),
|
||||
child: const Icon(
|
||||
LucideIcons.zap,
|
||||
color: Colors.white,
|
||||
size: 28,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppDimensions.paddingMedium),
|
||||
|
||||
// Sub-toggle: Universal / Cashu / Lightning
|
||||
_buildModeToggle(),
|
||||
const SizedBox(height: AppDimensions.paddingMedium),
|
||||
|
||||
// Action buttons: Copy, NFC, Share
|
||||
_buildActionButtons(),
|
||||
const SizedBox(height: AppDimensions.paddingMedium),
|
||||
|
||||
// Status indicator
|
||||
_buildStatusIndicator(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Cancel button
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(AppDimensions.paddingMedium),
|
||||
child: GestureDetector(
|
||||
onTap: () => Navigator.pop(context),
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
l10n.cancel,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildModeToggle() {
|
||||
// TODO: re-enable Universal/Cashu tabs once CDK fixes NIP-17 (cashubtc/cdk#1807)
|
||||
// Hidden while only Lightning is available (single button toggle is a no-op)
|
||||
return const SizedBox.shrink(
|
||||
// child: Container(
|
||||
// padding: const EdgeInsets.all(4),
|
||||
// decoration: BoxDecoration(
|
||||
// color: Colors.white.withValues(alpha: 0.1),
|
||||
// borderRadius: BorderRadius.circular(12),
|
||||
// ),
|
||||
// child: Row(
|
||||
// children: [
|
||||
// _buildToggleButton(label: L10n.of(context)!.universal, mode: QrMode.universal, enabled: _bolt11 != null),
|
||||
// _buildToggleButton(label: 'Cashu', mode: QrMode.cashu, enabled: true),
|
||||
// _buildToggleButton(label: 'Lightning', mode: QrMode.lightning, enabled: _bolt11 != null),
|
||||
// ],
|
||||
// ),
|
||||
// ),
|
||||
);
|
||||
}
|
||||
|
||||
// TODO: re-enable once CDK fixes NIP-17 (cashubtc/cdk#1807)
|
||||
// Widget _buildToggleButton({
|
||||
// required String label,
|
||||
// required QrMode mode,
|
||||
// required bool enabled,
|
||||
// }) {
|
||||
// final isActive = _activeMode == mode;
|
||||
// return Expanded(
|
||||
// child: GestureDetector(
|
||||
// onTap: enabled
|
||||
// ? () {
|
||||
// setState(() => _activeMode = mode);
|
||||
// _updateNfcPayload();
|
||||
// }
|
||||
// : null,
|
||||
// child: Container(
|
||||
// padding: const EdgeInsets.symmetric(vertical: 10),
|
||||
// decoration: BoxDecoration(
|
||||
// gradient: isActive
|
||||
// ? const LinearGradient(colors: AppColors.buttonGradient)
|
||||
// : null,
|
||||
// borderRadius: BorderRadius.circular(10),
|
||||
// ),
|
||||
// child: Center(
|
||||
// child: Text(
|
||||
// label,
|
||||
// style: TextStyle(
|
||||
// fontFamily: 'Inter',
|
||||
// fontSize: 13,
|
||||
// fontWeight: isActive ? FontWeight.w600 : FontWeight.w400,
|
||||
// color: enabled
|
||||
// ? (isActive ? Colors.white : AppColors.textSecondary)
|
||||
// : Colors.white.withValues(alpha: 0.3),
|
||||
// ),
|
||||
// ),
|
||||
// ),
|
||||
// ),
|
||||
// ),
|
||||
// );
|
||||
// }
|
||||
|
||||
Widget _buildActionButtons() {
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
_buildActionButton(
|
||||
icon: LucideIcons.copy,
|
||||
label: L10n.of(context)!.copy,
|
||||
onTap: _copyRequest,
|
||||
),
|
||||
if (_nfcState == NfcState.enabled) ...[
|
||||
const SizedBox(width: 16),
|
||||
_buildActionButton(
|
||||
icon: _nfcEmulating ? LucideIcons.wifiOff : LucideIcons.wifi,
|
||||
label: 'NFC',
|
||||
onTap: _toggleNfc,
|
||||
active: _nfcEmulating,
|
||||
),
|
||||
],
|
||||
const SizedBox(width: 16),
|
||||
_buildActionButton(
|
||||
icon: LucideIcons.share2,
|
||||
label: L10n.of(context)!.share,
|
||||
onTap: _shareRequest,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildActionButton({
|
||||
required IconData icon,
|
||||
required String label,
|
||||
required VoidCallback onTap,
|
||||
bool active = false,
|
||||
}) {
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: active
|
||||
? AppColors.primaryAction.withValues(alpha: 0.2)
|
||||
: Colors.white.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: active
|
||||
? Border.all(color: AppColors.primaryAction.withValues(alpha: 0.5))
|
||||
: null,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(icon, color: active ? AppColors.primaryAction : AppColors.textSecondary, size: 18),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 14,
|
||||
color: active ? AppColors.primaryAction : AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildStatusIndicator() {
|
||||
return GlassCard(
|
||||
padding: const EdgeInsets.all(AppDimensions.paddingMedium),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(AppColors.textSecondary),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Text(
|
||||
L10n.of(context)!.waitingForPayment,
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Success View ───
|
||||
|
||||
Widget _buildSuccessView() {
|
||||
final l10n = L10n.of(context)!;
|
||||
final formattedAmount =
|
||||
'+${UnitFormatter.formatBalance(_receivedAmount, _activeUnit)}';
|
||||
final unitLabel = UnitFormatter.getUnitLabel(_activeUnit);
|
||||
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Container(
|
||||
width: 80,
|
||||
height: 80,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.success.withValues(alpha: 0.2),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Icon(
|
||||
LucideIcons.checkCircle2,
|
||||
color: AppColors.success,
|
||||
size: 40,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppDimensions.paddingMedium),
|
||||
Text(
|
||||
'$formattedAmount $unitLabel',
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 32,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppDimensions.paddingSmall),
|
||||
Text(
|
||||
l10n.requestPaymentReceived,
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 16,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppDimensions.paddingLarge * 2),
|
||||
PrimaryButton(
|
||||
text: l10n.backToHome,
|
||||
onPressed: () =>
|
||||
Navigator.popUntil(context, (route) => route.isFirst),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Error View ───
|
||||
|
||||
Widget _buildErrorView() {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Container(
|
||||
width: 80,
|
||||
height: 80,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.error.withValues(alpha: 0.2),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Icon(
|
||||
LucideIcons.alertCircle,
|
||||
color: AppColors.error,
|
||||
size: 40,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppDimensions.paddingMedium),
|
||||
Text(
|
||||
L10n.of(context)!.error,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppDimensions.paddingSmall),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 32),
|
||||
child: Text(
|
||||
_errorMessage ?? L10n.of(context)!.unknownError,
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 14,
|
||||
color: AppColors.textSecondary.withValues(alpha: 0.7),
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppDimensions.paddingLarge),
|
||||
PrimaryButton(
|
||||
text: L10n.of(context)!.back,
|
||||
onPressed: () => setState(() {
|
||||
_status = RequestStatus.input;
|
||||
_errorMessage = null;
|
||||
}),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Logic ───
|
||||
|
||||
// TODO: re-enable once CDK fixes NIP-17 (cashubtc/cdk#1807)
|
||||
// static const List<String> _defaultNostrRelays = [
|
||||
// 'wss://relay.damus.io',
|
||||
// 'wss://relay.primal.net',
|
||||
// 'wss://nos.lol',
|
||||
// ];
|
||||
|
||||
Future<void> _generateRequest() async {
|
||||
final walletProvider = context.read<WalletProvider>();
|
||||
final wallet = walletProvider.activeWallet;
|
||||
|
||||
// Clean up previous request state — await to ensure Rust locks are released
|
||||
await _nostrSubscription?.cancel();
|
||||
_nostrSubscription = null;
|
||||
await _mintSubscription?.cancel();
|
||||
_mintSubscription = null;
|
||||
if (_nfcEmulating) {
|
||||
NfcService.stopEmulating();
|
||||
_nfcEmulating = false;
|
||||
}
|
||||
_paymentHandled = false;
|
||||
_creqB = null;
|
||||
_bolt11 = null;
|
||||
// TODO: re-enable Cashu/Nostr payment request once CDK fixes NIP-17 sender pubkey bug
|
||||
// See: https://github.com/cashubtc/cdk/issues/1807
|
||||
// _activeMode = QrMode.lightning;
|
||||
|
||||
setState(() => _status = RequestStatus.generating);
|
||||
|
||||
if (wallet == null) {
|
||||
setState(() {
|
||||
_status = RequestStatus.error;
|
||||
_errorMessage = 'No active wallet';
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
final description = _descriptionController.text.isNotEmpty
|
||||
? _descriptionController.text
|
||||
: null;
|
||||
|
||||
// TODO: re-enable once CDK fixes NIP-17 (cashubtc/cdk#1807)
|
||||
// // 1. Create payment request (creqB + Nostr keys)
|
||||
// final request = await wallet.createPaymentRequest(
|
||||
// params: CreateRequestParams(
|
||||
// amount: _amount,
|
||||
// unit: _activeUnit,
|
||||
// description: description,
|
||||
// nostrRelays: _defaultNostrRelays,
|
||||
// ),
|
||||
// );
|
||||
//
|
||||
// _creqB = request.creqB;
|
||||
//
|
||||
// // Persist handle for recovery if app is killed
|
||||
// await walletProvider.savePendingNostrRequest(
|
||||
// request.listenerHandle.toPersisted(),
|
||||
// );
|
||||
//
|
||||
// // 2. Start Nostr listener
|
||||
// _nostrSubscription = wallet
|
||||
// .waitForNostrPayment(handle: request.listenerHandle)
|
||||
// .listen(_onNostrEvent);
|
||||
|
||||
// Lightning invoice generation via CDK
|
||||
_mintSubscription = wallet
|
||||
.mint(amount: _amount, description: description)
|
||||
.listen(_onMintEvent);
|
||||
} catch (e) {
|
||||
setState(() {
|
||||
_status = RequestStatus.error;
|
||||
_errorMessage = e.toString();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: re-enable once CDK fixes NIP-17 (cashubtc/cdk#1807)
|
||||
// void _onNostrEvent(NostrPaymentEvent event) {
|
||||
// if (!mounted || _paymentHandled) return;
|
||||
// if (event.state == NostrPaymentState.received) {
|
||||
// _paymentHandled = true;
|
||||
// _mintSubscription?.cancel();
|
||||
// _onPaymentSuccess(event.amount ?? _amount);
|
||||
// }
|
||||
// }
|
||||
|
||||
void _onMintEvent(MintQuote quote) {
|
||||
if (!mounted) return;
|
||||
switch (quote.state) {
|
||||
case MintQuoteState.unpaid:
|
||||
if (_bolt11 == null) {
|
||||
setState(() {
|
||||
_bolt11 = quote.request;
|
||||
// TODO: switch to QrMode.universal once CDK fixes NIP-17 (cashubtc/cdk#1807)
|
||||
// _activeMode = QrMode.lightning;
|
||||
_status = RequestStatus.waiting;
|
||||
});
|
||||
_updateNfcPayload();
|
||||
}
|
||||
break;
|
||||
case MintQuoteState.issued:
|
||||
if (!_paymentHandled) {
|
||||
_paymentHandled = true;
|
||||
_nostrSubscription?.cancel();
|
||||
_onPaymentSuccess(quote.amount ?? _amount);
|
||||
}
|
||||
break;
|
||||
case MintQuoteState.error:
|
||||
// No Nostr fallback while CDK NIP-17 bug is open (cashubtc/cdk#1807)
|
||||
if (!_paymentHandled) {
|
||||
if (_nfcEmulating) {
|
||||
NfcService.stopEmulating();
|
||||
_nfcEmulating = false;
|
||||
}
|
||||
setState(() {
|
||||
_status = RequestStatus.error;
|
||||
_errorMessage = quote.error ?? L10n.of(context)!.unknownError;
|
||||
});
|
||||
}
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onPaymentSuccess(BigInt amount) async {
|
||||
if (_nfcEmulating) NfcService.stopEmulating();
|
||||
setState(() {
|
||||
_status = RequestStatus.received;
|
||||
_receivedAmount = amount;
|
||||
});
|
||||
final walletProvider = context.read<WalletProvider>();
|
||||
walletProvider.confettiController.fire();
|
||||
// TODO: re-enable once CDK fixes NIP-17 (cashubtc/cdk#1807)
|
||||
// Only remove pending Nostr request if this screen created one.
|
||||
// Disabled while Nostr payment requests are disabled to avoid
|
||||
// deleting a recovery record from a previous session.
|
||||
// await walletProvider.removePendingNostrRequest();
|
||||
}
|
||||
|
||||
// ─── QR Content ───
|
||||
|
||||
String _getActiveQrContent() {
|
||||
// TODO: re-enable once CDK fixes NIP-17 (cashubtc/cdk#1807)
|
||||
// case QrMode.universal:
|
||||
// return buildUnifiedUri(creqB: _creqB!, bolt11: _bolt11);
|
||||
// case QrMode.cashu:
|
||||
// return _creqB!.toUpperCase();
|
||||
return _bolt11?.toUpperCase() ?? '';
|
||||
}
|
||||
|
||||
// ─── Actions ───
|
||||
|
||||
Future<void> _copyRequest() async {
|
||||
final content = _getActiveQrContent();
|
||||
await Clipboard.setData(ClipboardData(text: content));
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(L10n.of(context)!.copiedToClipboard),
|
||||
backgroundColor: AppColors.success,
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _toggleNfc() {
|
||||
if (_nfcEmulating) {
|
||||
NfcService.stopEmulating();
|
||||
setState(() => _nfcEmulating = false);
|
||||
} else {
|
||||
NfcService.startEmulating(_getActiveQrContent());
|
||||
setState(() => _nfcEmulating = true);
|
||||
}
|
||||
}
|
||||
|
||||
void _updateNfcPayload() {
|
||||
if (_nfcEmulating) {
|
||||
NfcService.startEmulating(_getActiveQrContent());
|
||||
}
|
||||
}
|
||||
|
||||
void _shareRequest() async {
|
||||
final content = _getActiveQrContent();
|
||||
final formattedAmount =
|
||||
UnitFormatter.formatBalance(_amount, _activeUnit);
|
||||
final unitLabel = UnitFormatter.getUnitLabel(_activeUnit);
|
||||
|
||||
await SharePlus.instance.share(
|
||||
ShareParams(
|
||||
text: '$formattedAmount $unitLabel\n\n$content',
|
||||
subject: 'Payment Request - $formattedAmount $unitLabel',
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,14 @@
|
||||
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';
|
||||
|
||||
@@ -25,6 +27,11 @@ class _SplashScreenState extends State<SplashScreen>
|
||||
late Animation<double> _fadeAnimation;
|
||||
String? _errorMessage;
|
||||
|
||||
// Mensajes de carga aleatorios
|
||||
int _currentMessageIndex = 0;
|
||||
Timer? _messageTimer;
|
||||
final _random = Random();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
@@ -39,14 +46,40 @@ class _SplashScreenState extends State<SplashScreen>
|
||||
|
||||
_controller.forward();
|
||||
|
||||
// Iniciar rotación de mensajes de carga
|
||||
_currentMessageIndex = _random.nextInt(7);
|
||||
_messageTimer = Timer.periodic(const Duration(milliseconds: 800), (_) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_currentMessageIndex = _random.nextInt(7);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Inicializar la app
|
||||
_initializeApp();
|
||||
}
|
||||
|
||||
/// Obtiene el mensaje de carga actual basado en el índice
|
||||
String _getLoadingMessage(L10n? l10n) {
|
||||
if (l10n == null) return 'Cargando...';
|
||||
switch (_currentMessageIndex) {
|
||||
case 0: return l10n.loadingMessage1;
|
||||
case 1: return l10n.loadingMessage2;
|
||||
case 2: return l10n.loadingMessage3;
|
||||
case 3: return l10n.loadingMessage4;
|
||||
case 4: return l10n.loadingMessage5;
|
||||
case 5: return l10n.loadingMessage6;
|
||||
case 6: return l10n.loadingMessage7;
|
||||
default: return l10n.loadingMessage1;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _initializeApp() async {
|
||||
try {
|
||||
final settingsProvider = context.read<SettingsProvider>();
|
||||
final walletProvider = context.read<WalletProvider>();
|
||||
final p2pkProvider = context.read<P2PKProvider>();
|
||||
|
||||
// Inicializar settings (cargar preferencias)
|
||||
await settingsProvider.initialize();
|
||||
@@ -64,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
|
||||
@@ -148,6 +189,7 @@ class _SplashScreenState extends State<SplashScreen>
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_messageTimer?.cancel();
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
@@ -188,9 +230,9 @@ class _SplashScreenState extends State<SplashScreen>
|
||||
),
|
||||
const SizedBox(height: AppDimensions.paddingSmall),
|
||||
|
||||
// Subtítulo
|
||||
// Mensaje de carga aleatorio
|
||||
Text(
|
||||
l10n?.appTagline ?? 'Tu wallet de ecash privado',
|
||||
_getLoadingMessage(l10n),
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 16,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
import 'package:lucide_icons/lucide_icons.dart';
|
||||
import 'package:elcaju/l10n/app_localizations.dart';
|
||||
import '../../core/constants/colors.dart';
|
||||
import '../../core/constants/dimensions.dart';
|
||||
import '../../widgets/common/gradient_background.dart';
|
||||
@@ -53,7 +54,7 @@ class _BackupSeedScreenState extends State<BackupSeedScreen> {
|
||||
backgroundColor: Colors.transparent,
|
||||
elevation: 0,
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back, color: Colors.white),
|
||||
icon: const Icon(LucideIcons.arrowLeft, color: Colors.white),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
title: Text(
|
||||
@@ -111,7 +112,7 @@ class _BackupSeedScreenState extends State<BackupSeedScreen> {
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.copy,
|
||||
LucideIcons.copy,
|
||||
color: AppColors.textSecondary,
|
||||
size: 18,
|
||||
),
|
||||
@@ -140,7 +141,7 @@ class _BackupSeedScreenState extends State<BackupSeedScreen> {
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.warning_amber_rounded,
|
||||
LucideIcons.alertTriangle,
|
||||
color: AppColors.warning,
|
||||
size: 24,
|
||||
),
|
||||
@@ -182,7 +183,7 @@ class _BackupSeedScreenState extends State<BackupSeedScreen> {
|
||||
),
|
||||
),
|
||||
child: _confirmed
|
||||
? const Icon(Icons.check, size: 16, color: Colors.white)
|
||||
? const Icon(LucideIcons.check, size: 16, color: Colors.white)
|
||||
: null,
|
||||
),
|
||||
const SizedBox(width: AppDimensions.paddingSmall),
|
||||
@@ -205,7 +206,7 @@ class _BackupSeedScreenState extends State<BackupSeedScreen> {
|
||||
if (!_revealed)
|
||||
PrimaryButton(
|
||||
text: l10n.revealSeedPhrase,
|
||||
icon: Icons.visibility,
|
||||
icon: LucideIcons.eye,
|
||||
onPressed: () => setState(() => _revealed = true),
|
||||
)
|
||||
else
|
||||
@@ -229,7 +230,7 @@ class _BackupSeedScreenState extends State<BackupSeedScreen> {
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.visibility_off,
|
||||
LucideIcons.eyeOff,
|
||||
size: 48,
|
||||
color: AppColors.textSecondary.withValues(alpha: 0.5),
|
||||
),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
import 'package:lucide_icons/lucide_icons.dart';
|
||||
import 'package:elcaju/l10n/app_localizations.dart';
|
||||
import '../../core/constants/colors.dart';
|
||||
import '../../core/constants/dimensions.dart';
|
||||
import '../../widgets/common/gradient_background.dart';
|
||||
@@ -9,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';
|
||||
|
||||
@@ -35,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();
|
||||
@@ -45,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;
|
||||
@@ -87,7 +97,7 @@ class _CreateWalletScreenState extends State<CreateWalletScreen> {
|
||||
backgroundColor: Colors.transparent,
|
||||
elevation: 0,
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back, color: Colors.white),
|
||||
icon: const Icon(LucideIcons.arrowLeft, color: Colors.white),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
title: Text(
|
||||
@@ -114,101 +124,111 @@ class _CreateWalletScreenState extends State<CreateWalletScreen> {
|
||||
Widget _buildCreateView(L10n l10n) {
|
||||
return Column(
|
||||
children: [
|
||||
const Spacer(),
|
||||
|
||||
Container(
|
||||
width: 120,
|
||||
height: 120,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.primaryAction.withValues(alpha: 0.1),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(
|
||||
_isCreating ? Icons.hourglass_top : Icons.account_balance_wallet,
|
||||
size: 60,
|
||||
color: AppColors.secondaryAction,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppDimensions.paddingLarge),
|
||||
|
||||
Text(
|
||||
_isCreating ? l10n.creatingWallet : l10n.createWalletTitle,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.white,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: AppDimensions.paddingMedium),
|
||||
|
||||
Text(
|
||||
_isCreating ? l10n.generatingSeed : l10n.createWalletDescription,
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 16,
|
||||
color: AppColors.textSecondary.withValues(alpha: 0.8),
|
||||
height: 1.5,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
|
||||
// Mensaje de error si existe
|
||||
if (_errorMessage != null) ...[
|
||||
const SizedBox(height: AppDimensions.paddingLarge),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(AppDimensions.paddingMedium),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.error.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: AppColors.error.withValues(alpha: 0.3),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
// Contenido principal centrado
|
||||
Expanded(
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.error_outline,
|
||||
color: AppColors.error,
|
||||
size: 20,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
_errorMessage!,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 14,
|
||||
color: AppColors.error,
|
||||
),
|
||||
Container(
|
||||
width: 120,
|
||||
height: 120,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.primaryAction.withValues(alpha: 0.1),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(
|
||||
_isCreating ? LucideIcons.hourglass : LucideIcons.wallet,
|
||||
size: 60,
|
||||
color: AppColors.secondaryAction,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppDimensions.paddingLarge),
|
||||
|
||||
Text(
|
||||
_isCreating ? l10n.creatingWallet : l10n.createWalletTitle,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.white,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: AppDimensions.paddingMedium),
|
||||
|
||||
Text(
|
||||
_isCreating ? l10n.generatingSeed : l10n.createWalletDescription,
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 16,
|
||||
color: AppColors.textSecondary.withValues(alpha: 0.8),
|
||||
height: 1.5,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
|
||||
// Mensaje de error si existe
|
||||
if (_errorMessage != null) ...[
|
||||
const SizedBox(height: AppDimensions.paddingLarge),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(AppDimensions.paddingMedium),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.error.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: AppColors.error.withValues(alpha: 0.3),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(
|
||||
LucideIcons.alertCircle,
|
||||
color: AppColors.error,
|
||||
size: 20,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
_errorMessage!,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 14,
|
||||
color: AppColors.error,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
// Spinner cuando está creando (integrado en el contenido centrado)
|
||||
if (_isCreating) ...[
|
||||
const SizedBox(height: AppDimensions.paddingXLarge),
|
||||
const SizedBox(
|
||||
width: 48,
|
||||
height: 48,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 3,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(AppColors.secondaryAction),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const Spacer(),
|
||||
|
||||
if (!_isCreating)
|
||||
// Botón solo cuando no está creando
|
||||
if (!_isCreating) ...[
|
||||
PrimaryButton(
|
||||
text: l10n.generateWallet,
|
||||
icon: Icons.auto_awesome,
|
||||
icon: LucideIcons.sparkles,
|
||||
onPressed: _createWallet,
|
||||
),
|
||||
|
||||
if (_isCreating)
|
||||
const SizedBox(
|
||||
width: 48,
|
||||
height: 48,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 3,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(AppColors.secondaryAction),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: AppDimensions.paddingXLarge),
|
||||
const SizedBox(height: AppDimensions.paddingXLarge),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -226,7 +246,7 @@ class _CreateWalletScreenState extends State<CreateWalletScreen> {
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.check_circle,
|
||||
LucideIcons.checkCircle,
|
||||
size: 60,
|
||||
color: AppColors.success,
|
||||
),
|
||||
@@ -261,7 +281,7 @@ class _CreateWalletScreenState extends State<CreateWalletScreen> {
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.warning_amber_rounded,
|
||||
LucideIcons.alertTriangle,
|
||||
color: AppColors.warning,
|
||||
size: 32,
|
||||
),
|
||||
@@ -285,7 +305,7 @@ class _CreateWalletScreenState extends State<CreateWalletScreen> {
|
||||
|
||||
PrimaryButton(
|
||||
text: l10n.backupNow,
|
||||
icon: Icons.shield,
|
||||
icon: LucideIcons.shield,
|
||||
onPressed: _goToBackup,
|
||||
),
|
||||
const SizedBox(height: AppDimensions.paddingMedium),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
import 'package:lucide_icons/lucide_icons.dart';
|
||||
import 'package:elcaju/l10n/app_localizations.dart';
|
||||
import '../../core/constants/colors.dart';
|
||||
import '../../core/constants/dimensions.dart';
|
||||
import '../../widgets/common/gradient_background.dart';
|
||||
@@ -8,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
|
||||
@@ -23,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();
|
||||
@@ -54,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, el bridge Rust 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(
|
||||
@@ -73,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(() {
|
||||
@@ -96,7 +127,7 @@ class _RestoreWalletScreenState extends State<RestoreWalletScreen> {
|
||||
backgroundColor: Colors.transparent,
|
||||
elevation: 0,
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back, color: Colors.white),
|
||||
icon: const Icon(LucideIcons.arrowLeft, color: Colors.white),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
title: Text(
|
||||
@@ -174,8 +205,8 @@ class _RestoreWalletScreenState extends State<RestoreWalletScreen> {
|
||||
children: [
|
||||
Icon(
|
||||
_isValidWordCount
|
||||
? Icons.check_circle
|
||||
: Icons.info_outline,
|
||||
? LucideIcons.checkCircle
|
||||
: LucideIcons.info,
|
||||
size: 18,
|
||||
color: _isValidWordCount
|
||||
? AppColors.success
|
||||
@@ -220,7 +251,7 @@ class _RestoreWalletScreenState extends State<RestoreWalletScreen> {
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.error_outline,
|
||||
LucideIcons.alertCircle,
|
||||
color: AppColors.error,
|
||||
size: 20,
|
||||
),
|
||||
@@ -244,12 +275,28 @@ class _RestoreWalletScreenState extends State<RestoreWalletScreen> {
|
||||
|
||||
PrimaryButton(
|
||||
text: l10n.restoreWallet,
|
||||
icon: Icons.restore,
|
||||
icon: LucideIcons.rotateCcw,
|
||||
isLoading: _isRestoring,
|
||||
onPressed: _isValidWordCount && !_isRestoring
|
||||
? _restoreWallet
|
||||
: null,
|
||||
),
|
||||
|
||||
if (_isRestoring && _statusMessage != null) ...[
|
||||
const SizedBox(height: AppDimensions.paddingMedium),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: Text(
|
||||
_statusMessage!,
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 14,
|
||||
color: AppColors.textSecondary.withValues(alpha: 0.8),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -1,21 +1,47 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:lucide_icons/lucide_icons.dart';
|
||||
import 'package:elcaju/l10n/app_localizations.dart';
|
||||
import '../../core/constants/colors.dart';
|
||||
import '../../core/constants/dimensions.dart';
|
||||
import '../../providers/settings_provider.dart';
|
||||
import '../../widgets/common/gradient_background.dart';
|
||||
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});
|
||||
|
||||
// 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': '🇰🇪',
|
||||
};
|
||||
|
||||
String _getFlag(String locale) {
|
||||
return _languageFlags[locale] ?? '🇪🇸';
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = L10n.of(context)!;
|
||||
|
||||
final settingsProvider = context.watch<SettingsProvider>();
|
||||
final currentLocale = settingsProvider.locale;
|
||||
|
||||
return GradientBackground(
|
||||
child: Scaffold(
|
||||
backgroundColor: Colors.transparent,
|
||||
@@ -24,6 +50,59 @@ class WelcomeScreen extends StatelessWidget {
|
||||
padding: const EdgeInsets.all(AppDimensions.paddingLarge),
|
||||
child: Column(
|
||||
children: [
|
||||
// Selector de idioma en esquina superior derecha
|
||||
Align(
|
||||
alignment: Alignment.topRight,
|
||||
child: GestureDetector(
|
||||
onTap: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => const LanguageScreen(),
|
||||
),
|
||||
);
|
||||
},
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 8,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(
|
||||
color: Colors.white.withValues(alpha: 0.2),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
_getFlag(currentLocale),
|
||||
style: const TextStyle(fontSize: 18),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
currentLocale.toUpperCase(),
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Icon(
|
||||
LucideIcons.chevronRight,
|
||||
color: Colors.white.withValues(alpha: 0.7),
|
||||
size: 18,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const Spacer(flex: 1),
|
||||
|
||||
// Logo / Mascota
|
||||
@@ -66,7 +145,7 @@ class WelcomeScreen extends StatelessWidget {
|
||||
// Botón crear wallet
|
||||
PrimaryButton(
|
||||
text: l10n.createWallet,
|
||||
icon: Icons.add_circle_outline,
|
||||
icon: LucideIcons.plusCircle,
|
||||
onPressed: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
@@ -81,7 +160,7 @@ class WelcomeScreen extends StatelessWidget {
|
||||
// Botón restaurar wallet
|
||||
SecondaryButton(
|
||||
text: l10n.restoreWallet,
|
||||
icon: Icons.restore,
|
||||
icon: LucideIcons.rotateCcw,
|
||||
onPressed: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
|
||||
@@ -1,22 +1,25 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:lucide_icons/lucide_icons.dart';
|
||||
import 'package:elcaju/l10n/app_localizations.dart';
|
||||
import '../../core/constants/colors.dart';
|
||||
import '../../core/constants/dimensions.dart';
|
||||
import '../../core/utils/formatters.dart';
|
||||
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 '../../providers/p2pk_provider.dart';
|
||||
import '../4_receive/receive_screen.dart';
|
||||
import '../5_send/send_screen.dart';
|
||||
import '../6_mint/mint_screen.dart';
|
||||
import '../12_request/request_screen.dart';
|
||||
import '../7_melt/melt_screen.dart';
|
||||
import '../8_settings/settings_screen.dart';
|
||||
import '../8_settings/mints_screen.dart';
|
||||
import '../9_history/history_screen.dart';
|
||||
import '../10_scanner/scan_screen.dart';
|
||||
|
||||
/// Pantalla principal - Home
|
||||
/// Muestra balance, acciones principales e historial
|
||||
@@ -31,20 +34,52 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
// Estado local
|
||||
bool _isBalanceVisible = true;
|
||||
|
||||
// Controller para el efecto confeti
|
||||
final CashuConfettiController _confettiController = CashuConfettiController();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_confettiController.dispose();
|
||||
super.dispose();
|
||||
void initState() {
|
||||
super.initState();
|
||||
// Verificar tokens pendientes al iniciar
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_checkPendingTokens();
|
||||
});
|
||||
}
|
||||
|
||||
/// Dispara confetti global desde WalletProvider
|
||||
void _fireConfetti() => context.read<WalletProvider>().confettiController.fire();
|
||||
|
||||
/// Verifica y reclama automáticamente tokens pendientes
|
||||
Future<void> _checkPendingTokens() async {
|
||||
final walletProvider = context.read<WalletProvider>();
|
||||
if (!walletProvider.hasPendingTokens) return;
|
||||
|
||||
try {
|
||||
final p2pkProvider = context.read<P2PKProvider>();
|
||||
final result = await walletProvider.checkPendingTokens(
|
||||
p2pkKeyResolver: p2pkProvider.getPrivateKeyForToken,
|
||||
);
|
||||
final claimed = (result['claimed'] as int?) ?? 0;
|
||||
final totalClaimed = result['totalClaimed'] as BigInt? ?? BigInt.zero;
|
||||
final unit = (result['unit'] as String?) ?? walletProvider.activeUnit;
|
||||
|
||||
if (claimed > 0 && mounted) {
|
||||
// Confetti se dispara globalmente desde WalletProvider.receiveToken
|
||||
// Mostrar snackbar
|
||||
final l10n = L10n.of(context)!;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(l10n.pendingTokensClaimed(claimed, totalClaimed.toString(), unit)),
|
||||
backgroundColor: AppColors.success,
|
||||
duration: const Duration(seconds: 3),
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Error checking pending tokens: $e');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return CashuConfetti(
|
||||
controller: _confettiController,
|
||||
child: GradientBackground(
|
||||
return GradientBackground(
|
||||
child: Scaffold(
|
||||
backgroundColor: Colors.transparent,
|
||||
body: SafeArea(
|
||||
@@ -80,7 +115,6 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -119,7 +153,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,
|
||||
@@ -212,9 +246,10 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
final activeMintUrl = walletProvider.activeMintUrl;
|
||||
|
||||
// Extraer nombre del mint para mostrar
|
||||
final l10n = L10n.of(context)!;
|
||||
final displayMint = activeMintUrl != null
|
||||
? UnitFormatter.getMintDisplayName(activeMintUrl)
|
||||
: 'Sin mint';
|
||||
: l10n.noMint;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: AppDimensions.paddingSmall),
|
||||
@@ -312,6 +347,7 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
}
|
||||
|
||||
Widget _buildActions() {
|
||||
final l10n = L10n.of(context)!;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(AppDimensions.paddingMedium),
|
||||
child: Row(
|
||||
@@ -319,16 +355,19 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
// Enviar (primero) - acción crítica que mueve dinero
|
||||
Expanded(
|
||||
child: AnimatedActionButton(
|
||||
label: 'Enviar ↗',
|
||||
label: l10n.sendAction,
|
||||
type: ButtonType.criticalAction,
|
||||
onTap: _showSendOptions,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: AppDimensions.paddingMedium),
|
||||
const SizedBox(width: AppDimensions.paddingSmall),
|
||||
// Botón scan circular (centro)
|
||||
_buildScanButton(),
|
||||
const SizedBox(width: AppDimensions.paddingSmall),
|
||||
// Recibir (segundo) - acción importante pero segura
|
||||
Expanded(
|
||||
child: AnimatedActionButton(
|
||||
label: '↘ Recibir',
|
||||
label: l10n.receiveAction,
|
||||
type: ButtonType.primaryAction,
|
||||
onTap: _showReceiveOptions,
|
||||
),
|
||||
@@ -338,17 +377,57 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildScanButton() {
|
||||
return GestureDetector(
|
||||
onTap: _openScanner,
|
||||
child: Container(
|
||||
width: 52,
|
||||
height: 52,
|
||||
decoration: BoxDecoration(
|
||||
gradient: const LinearGradient(
|
||||
colors: AppColors.buttonGradient,
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
),
|
||||
shape: BoxShape.circle,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: AppColors.primaryAction.withValues(alpha: 0.3),
|
||||
blurRadius: 12,
|
||||
offset: const Offset(0, 4),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: const Icon(
|
||||
LucideIcons.scan,
|
||||
color: Colors.white,
|
||||
size: 24,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _openScanner() {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => const ScanScreen(mode: ScanMode.any),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showReceiveOptions() {
|
||||
final l10n = L10n.of(context)!;
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
backgroundColor: Colors.transparent,
|
||||
builder: (context) => _MethodSelectorModal(
|
||||
title: 'Recibir',
|
||||
title: l10n.receive,
|
||||
options: [
|
||||
_MethodOption(
|
||||
icon: LucideIcons.bean,
|
||||
label: 'Cashu',
|
||||
description: 'Pegar token ecash',
|
||||
label: l10n.cashu,
|
||||
description: l10n.pasteEcashToken,
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
Navigator.push(
|
||||
@@ -358,14 +437,14 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
},
|
||||
),
|
||||
_MethodOption(
|
||||
icon: LucideIcons.zap,
|
||||
label: 'Lightning',
|
||||
description: 'Generar invoice para depositar',
|
||||
icon: LucideIcons.bellRing,
|
||||
label: l10n.request,
|
||||
description: l10n.requestPaymentDescription,
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(builder: (context) => const MintScreen()),
|
||||
MaterialPageRoute(builder: (context) => const RequestScreen()),
|
||||
);
|
||||
},
|
||||
),
|
||||
@@ -375,16 +454,17 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
}
|
||||
|
||||
void _showSendOptions() {
|
||||
final l10n = L10n.of(context)!;
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
backgroundColor: Colors.transparent,
|
||||
builder: (context) => _MethodSelectorModal(
|
||||
title: 'Enviar',
|
||||
title: l10n.send,
|
||||
options: [
|
||||
_MethodOption(
|
||||
icon: LucideIcons.bean,
|
||||
label: 'Cashu',
|
||||
description: 'Crear token ecash',
|
||||
label: l10n.cashu,
|
||||
description: l10n.createEcashToken,
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
Navigator.push(
|
||||
@@ -395,8 +475,8 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
),
|
||||
_MethodOption(
|
||||
icon: LucideIcons.zap,
|
||||
label: 'Lightning',
|
||||
description: 'Pagar invoice Lightning',
|
||||
label: l10n.lightning,
|
||||
description: l10n.payLightningInvoice,
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
Navigator.push(
|
||||
@@ -411,19 +491,59 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
}
|
||||
|
||||
Widget _buildHistoryButton() {
|
||||
final l10n = L10n.of(context)!;
|
||||
final walletProvider = context.watch<WalletProvider>();
|
||||
final pendingCount = walletProvider.pendingTokenCount;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(AppDimensions.paddingMedium),
|
||||
child: AnimatedActionButton(
|
||||
label: 'Historial',
|
||||
type: ButtonType.navigation,
|
||||
icon: LucideIcons.history,
|
||||
showIcon: true,
|
||||
onTap: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(builder: (context) => const HistoryScreen()),
|
||||
);
|
||||
},
|
||||
child: Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
AnimatedActionButton(
|
||||
label: l10n.history,
|
||||
type: ButtonType.navigation,
|
||||
icon: LucideIcons.history,
|
||||
showIcon: true,
|
||||
onTap: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(builder: (context) => const HistoryScreen()),
|
||||
);
|
||||
},
|
||||
),
|
||||
// Badge de tokens pendientes
|
||||
if (pendingCount > 0)
|
||||
Positioned(
|
||||
right: -4,
|
||||
top: -4,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(6),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.warning,
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(
|
||||
color: AppColors.deepVoidPurple,
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
constraints: const BoxConstraints(
|
||||
minWidth: 20,
|
||||
minHeight: 20,
|
||||
),
|
||||
child: Text(
|
||||
pendingCount > 9 ? '9+' : pendingCount.toString(),
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.black,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -499,7 +619,8 @@ class _MethodSelectorModal extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: AppDimensions.paddingSmall),
|
||||
// Espacio para la barra de navegación del sistema
|
||||
SizedBox(height: MediaQuery.of(context).padding.bottom + AppDimensions.paddingSmall),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,9 +1,12 @@
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:lucide_icons/lucide_icons.dart';
|
||||
import 'package:elcaju/l10n/app_localizations.dart';
|
||||
import '../../core/constants/colors.dart';
|
||||
import '../../core/constants/dimensions.dart';
|
||||
import '../../core/models/proof.dart';
|
||||
import '../../core/services/proof_service.dart';
|
||||
import '../../src/rust/api/token.dart' as cdk;
|
||||
import '../../widgets/common/gradient_background.dart';
|
||||
import '../../widgets/common/glass_card.dart';
|
||||
import '../../widgets/common/primary_button.dart';
|
||||
@@ -44,7 +47,7 @@ class _OfflineSendScreenState extends State<OfflineSendScreen> {
|
||||
@override
|
||||
void dispose() {
|
||||
_memoController.dispose();
|
||||
_proofService.close();
|
||||
if (!_isCreating) _proofService.close();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -64,7 +67,7 @@ class _OfflineSendScreenState extends State<OfflineSendScreen> {
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_errorMessage = 'Error cargando proofs: $e';
|
||||
_errorMessage = L10n.of(context)!.loadingProofsError(e.toString());
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
@@ -120,9 +123,9 @@ class _OfflineSendScreenState extends State<OfflineSendScreen> {
|
||||
icon: const Icon(LucideIcons.arrowLeft, color: Colors.white),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
title: const Text(
|
||||
'Envio Offline',
|
||||
style: TextStyle(
|
||||
title: Text(
|
||||
L10n.of(context)!.offlineSend,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.white,
|
||||
@@ -131,12 +134,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
|
||||
@@ -201,7 +215,7 @@ class _OfflineSendScreenState extends State<OfflineSendScreen> {
|
||||
|
||||
// Instrucciones
|
||||
Text(
|
||||
'Selecciona las notas que deseas enviar:',
|
||||
L10n.of(context)!.selectNotesToSend,
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 14,
|
||||
@@ -235,7 +249,7 @@ class _OfflineSendScreenState extends State<OfflineSendScreen> {
|
||||
|
||||
// Botón crear token
|
||||
PrimaryButton(
|
||||
text: _isCreating ? 'Creando...' : 'Crear token',
|
||||
text: _isCreating ? L10n.of(context)!.creatingToken : L10n.of(context)!.createToken,
|
||||
onPressed: _selectedIds.isNotEmpty && !_isCreating
|
||||
? _createOfflineToken
|
||||
: null,
|
||||
@@ -246,12 +260,13 @@ class _OfflineSendScreenState extends State<OfflineSendScreen> {
|
||||
}
|
||||
|
||||
Widget _buildTotalDisplay() {
|
||||
final l10n = L10n.of(context)!;
|
||||
return GlassCard(
|
||||
padding: const EdgeInsets.all(AppDimensions.paddingMedium),
|
||||
child: Column(
|
||||
children: [
|
||||
Text(
|
||||
'Total a enviar',
|
||||
l10n.totalToSend,
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 14,
|
||||
@@ -265,7 +280,7 @@ class _OfflineSendScreenState extends State<OfflineSendScreen> {
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'${_selectedIds.length} notas seleccionadas',
|
||||
l10n.notesSelected(_selectedIds.length),
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 12,
|
||||
@@ -290,7 +305,7 @@ class _OfflineSendScreenState extends State<OfflineSendScreen> {
|
||||
color: Colors.white,
|
||||
),
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Memo (opcional)',
|
||||
hintText: L10n.of(context)!.memoOptional,
|
||||
hintStyle: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 16,
|
||||
@@ -308,32 +323,40 @@ class _OfflineSendScreenState extends State<OfflineSendScreen> {
|
||||
}
|
||||
|
||||
Future<void> _createOfflineToken() async {
|
||||
final selectedProofs = _selectedProofs;
|
||||
final selectedTotal = _proofService.calculateTotal(selectedProofs);
|
||||
var proofsMarkedPending = false;
|
||||
|
||||
setState(() {
|
||||
_isCreating = true;
|
||||
});
|
||||
|
||||
try {
|
||||
final selectedProofs = _selectedProofs;
|
||||
|
||||
// Marcar proofs como PENDING_SPENT
|
||||
await _proofService.markProofsPendingSpent(selectedProofs);
|
||||
proofsMarkedPending = true;
|
||||
|
||||
// Crear token V3
|
||||
// Crear token via CDK (produce V4/cashuB válido)
|
||||
final memo = _memoController.text.isNotEmpty ? _memoController.text : null;
|
||||
final token = _proofService.createTokenV3(
|
||||
final proofsJson = jsonEncode(
|
||||
selectedProofs.map((p) => p.toTokenProof()).toList(),
|
||||
);
|
||||
final cdkToken = cdk.createOfflineToken(
|
||||
mintUrl: widget.mintUrl,
|
||||
proofs: selectedProofs,
|
||||
proofsJson: proofsJson,
|
||||
memo: memo,
|
||||
unit: widget.unit,
|
||||
);
|
||||
final token = cdkToken.encoded;
|
||||
|
||||
if (mounted) {
|
||||
_isCreating = false;
|
||||
Navigator.pushReplacement(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => ShareTokenScreen(
|
||||
token: token,
|
||||
amount: _selectedTotal,
|
||||
amount: selectedTotal,
|
||||
unit: widget.unit,
|
||||
memo: memo,
|
||||
),
|
||||
@@ -341,8 +364,20 @@ class _OfflineSendScreenState extends State<OfflineSendScreen> {
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (proofsMarkedPending) {
|
||||
try {
|
||||
await _proofService.markProofsUnspent(selectedProofs);
|
||||
} catch (rollbackErr) {
|
||||
debugPrint('Failed to rollback proofs to UNSPENT: $rollbackErr');
|
||||
}
|
||||
}
|
||||
if (!mounted) {
|
||||
_proofService.close();
|
||||
return;
|
||||
}
|
||||
final errorMessage = L10n.of(context)!.creatingTokenError(e.toString());
|
||||
setState(() {
|
||||
_errorMessage = 'Error creando token: $e';
|
||||
_errorMessage = errorMessage;
|
||||
_isCreating = false;
|
||||
});
|
||||
}
|
||||
|
||||
+345
-131
@@ -1,14 +1,19 @@
|
||||
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: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/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';
|
||||
|
||||
@@ -21,14 +26,19 @@ class SendScreen extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _SendScreenState extends State<SendScreen> {
|
||||
final TextEditingController _amountController = TextEditingController();
|
||||
final TextEditingController _memoController = TextEditingController();
|
||||
final TextEditingController _pubkeyController = TextEditingController();
|
||||
|
||||
String _amountValue = '';
|
||||
bool _isProcessing = false;
|
||||
String? _errorMessage;
|
||||
BigInt _availableBalance = BigInt.zero;
|
||||
late String _activeUnit;
|
||||
|
||||
// P2PK
|
||||
bool _useP2PK = false;
|
||||
String? _pubkeyError;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
@@ -48,16 +58,16 @@ class _SendScreenState extends State<SendScreen> {
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_amountController.dispose();
|
||||
_memoController.dispose();
|
||||
_pubkeyController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// Obtiene la etiqueta de la unidad para display
|
||||
String get _unitLabel => UnitFormatter.getUnitLabel(_activeUnit);
|
||||
|
||||
/// Parsea el input del usuario a BigInt según la unidad
|
||||
BigInt get _amount => UnitFormatter.parseUserInput(_amountController.text, _activeUnit);
|
||||
/// Parsea los dígitos crudos a BigInt (ya son centavos para USD/EUR)
|
||||
BigInt get _amount => UnitFormatter.parseRawDigits(_amountValue, _activeUnit);
|
||||
|
||||
bool get _isValidAmount => _amount > BigInt.zero && _amount <= _availableBalance;
|
||||
|
||||
@@ -73,9 +83,9 @@ class _SendScreenState extends State<SendScreen> {
|
||||
icon: const Icon(LucideIcons.arrowLeft, color: Colors.white),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
title: const Text(
|
||||
'Enviar Cashu',
|
||||
style: TextStyle(
|
||||
title: Text(
|
||||
L10n.of(context)!.sendCashu,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.white,
|
||||
@@ -85,7 +95,7 @@ class _SendScreenState extends State<SendScreen> {
|
||||
// Botón para modo offline (selección manual de proofs)
|
||||
IconButton(
|
||||
icon: const Icon(LucideIcons.coins, color: AppColors.primaryAction),
|
||||
tooltip: 'Seleccionar notas manualmente',
|
||||
tooltip: L10n.of(context)!.selectNotesManually,
|
||||
onPressed: _goToOfflineMode,
|
||||
),
|
||||
],
|
||||
@@ -110,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(),
|
||||
],
|
||||
@@ -131,116 +146,94 @@ class _SendScreenState extends State<SendScreen> {
|
||||
|
||||
Widget _buildAmountSection() {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Monto a enviar:',
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppDimensions.paddingSmall),
|
||||
|
||||
// Input de monto
|
||||
GlassCard(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: AppDimensions.paddingMedium,
|
||||
vertical: AppDimensions.paddingSmall,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _amountController,
|
||||
keyboardType: TextInputType.number,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 32,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.white,
|
||||
),
|
||||
decoration: InputDecoration(
|
||||
hintText: '0',
|
||||
hintStyle: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 32,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.white.withValues(alpha: 0.3),
|
||||
),
|
||||
border: InputBorder.none,
|
||||
),
|
||||
onChanged: (_) => setState(() {
|
||||
_errorMessage = null;
|
||||
}),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
_unitLabel,
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Display del monto
|
||||
_buildAmountDisplay(),
|
||||
const SizedBox(height: AppDimensions.paddingSmall),
|
||||
|
||||
// Balance disponible
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
'Disponible:',
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 14,
|
||||
color: AppColors.textSecondary.withValues(alpha: 0.7),
|
||||
),
|
||||
),
|
||||
GestureDetector(
|
||||
onTap: _setMaxAmount,
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
'${UnitFormatter.formatBalance(_availableBalance, _activeUnit)} $_unitLabel',
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.primaryAction,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'(Max)',
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 12,
|
||||
color: AppColors.primaryAction.withValues(alpha: 0.7),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
_buildBalanceRow(),
|
||||
const SizedBox(height: AppDimensions.paddingMedium),
|
||||
|
||||
// Teclado numérico
|
||||
NumpadWidget(
|
||||
value: _amountValue,
|
||||
showMaxButton: true,
|
||||
onMaxPressed: _setMaxAmount,
|
||||
onChanged: (newValue) {
|
||||
setState(() {
|
||||
_errorMessage = null;
|
||||
_amountValue = newValue;
|
||||
});
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAmountDisplay() {
|
||||
final displayAmount = UnitFormatter.formatRawDigitsForDisplay(_amountValue, _activeUnit);
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
Text(
|
||||
displayAmount,
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 48,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: _isValidAmount || _amountValue.isEmpty
|
||||
? Colors.white
|
||||
: AppColors.error,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
_unitLabel,
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 18,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBalanceRow() {
|
||||
final l10n = L10n.of(context)!;
|
||||
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
'${l10n.available} ',
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 14,
|
||||
color: AppColors.textSecondary.withValues(alpha: 0.7),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'${UnitFormatter.formatBalance(_availableBalance, _activeUnit)} $_unitLabel',
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.primaryAction,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMemoSection() {
|
||||
final l10n = L10n.of(context)!;
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Memo (opcional):',
|
||||
l10n.memoOptional,
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 16,
|
||||
@@ -262,7 +255,7 @@ class _SendScreenState extends State<SendScreen> {
|
||||
color: Colors.white,
|
||||
),
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Ej: Para el cafe',
|
||||
hintText: l10n.memoPlaceholder,
|
||||
hintStyle: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 16,
|
||||
@@ -281,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),
|
||||
@@ -317,16 +498,21 @@ 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 ? 'Creando token...' : 'Crear token',
|
||||
onPressed: _isValidAmount && !_isProcessing ? _showConfirmation : null,
|
||||
text: _isProcessing ? l10n.creatingToken : l10n.createToken,
|
||||
onPressed: canCreate ? _showConfirmation : null,
|
||||
);
|
||||
}
|
||||
|
||||
void _setMaxAmount() {
|
||||
// Formatear el balance para el input (sin separadores de miles)
|
||||
_amountController.text = UnitFormatter.formatBalance(_availableBalance, _activeUnit).replaceAll(',', '');
|
||||
setState(() {
|
||||
// El balance ya está en la unidad base (centavos para USD/EUR, sats para SAT)
|
||||
_amountValue = _availableBalance.toString();
|
||||
_errorMessage = null;
|
||||
});
|
||||
}
|
||||
@@ -338,7 +524,7 @@ class _SendScreenState extends State<SendScreen> {
|
||||
|
||||
if (mintUrl == null) {
|
||||
setState(() {
|
||||
_errorMessage = 'No hay mint activo';
|
||||
_errorMessage = L10n.of(context)!.noActiveMint;
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -361,7 +547,7 @@ class _SendScreenState extends State<SendScreen> {
|
||||
|
||||
if (mintUrl == null) {
|
||||
setState(() {
|
||||
_errorMessage = 'No hay mint activo';
|
||||
_errorMessage = L10n.of(context)!.noActiveMint;
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -379,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;
|
||||
@@ -388,6 +581,7 @@ class _SendScreenState extends State<SendScreen> {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
backgroundColor: Colors.transparent,
|
||||
isScrollControlled: true,
|
||||
builder: (context) => _ConfirmationModal(
|
||||
amount: _amount,
|
||||
unit: _activeUnit,
|
||||
@@ -414,6 +608,8 @@ class _SendScreenState extends State<SendScreen> {
|
||||
}
|
||||
|
||||
Future<void> _createToken() async {
|
||||
final l10n = L10n.of(context)!;
|
||||
|
||||
setState(() {
|
||||
_isProcessing = true;
|
||||
_errorMessage = null;
|
||||
@@ -425,7 +621,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(
|
||||
@@ -440,7 +650,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
|
||||
@@ -454,13 +666,15 @@ class _SendScreenState extends State<SendScreen> {
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
if (errorStr.contains('insufficient') || errorStr.contains('not enough')) {
|
||||
_errorMessage = 'Balance insuficiente';
|
||||
} else {
|
||||
_errorMessage = 'Error al crear token: $e';
|
||||
}
|
||||
});
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
if (errorStr.contains('insufficient') || errorStr.contains('not enough')) {
|
||||
_errorMessage = l10n.insufficientBalance;
|
||||
} else {
|
||||
_errorMessage = l10n.tokenCreationError(e.toString());
|
||||
}
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
@@ -489,17 +703,17 @@ class _SendScreenState extends State<SendScreen> {
|
||||
|
||||
if (mintUrl == null) {
|
||||
setState(() {
|
||||
_errorMessage = 'No hay mint activo';
|
||||
_errorMessage = L10n.of(context)!.noActiveMint;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Mostrar snackbar informativo
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Sin conexion. Usando modo offline...'),
|
||||
SnackBar(
|
||||
content: Text(L10n.of(context)!.offlineModeMessage),
|
||||
backgroundColor: AppColors.primaryAction,
|
||||
duration: Duration(seconds: 2),
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -575,9 +789,9 @@ class _ConfirmationModal extends StatelessWidget {
|
||||
const SizedBox(height: AppDimensions.paddingMedium),
|
||||
|
||||
// Titulo
|
||||
const Text(
|
||||
'Confirmar envio',
|
||||
style: TextStyle(
|
||||
Text(
|
||||
L10n.of(context)!.confirmSend,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
@@ -625,10 +839,10 @@ class _ConfirmationModal extends StatelessWidget {
|
||||
color: Colors.white.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: const Center(
|
||||
child: Center(
|
||||
child: Text(
|
||||
'Cancelar',
|
||||
style: TextStyle(
|
||||
L10n.of(context)!.cancel,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
@@ -642,7 +856,7 @@ class _ConfirmationModal extends StatelessWidget {
|
||||
const SizedBox(width: AppDimensions.paddingMedium),
|
||||
Expanded(
|
||||
child: PrimaryButton(
|
||||
text: 'Confirmar',
|
||||
text: L10n.of(context)!.confirm,
|
||||
onPressed: onConfirm,
|
||||
height: 52,
|
||||
),
|
||||
|
||||
@@ -4,10 +4,13 @@ import 'package:flutter/services.dart';
|
||||
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 '../../src/rust/api/token.dart' as cdk;
|
||||
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';
|
||||
@@ -43,21 +46,39 @@ 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);
|
||||
// Construir Token directamente sin Token.parse para evitar validación
|
||||
// estricta de keyset IDs. encodeQrToken solo usa token.encoded.
|
||||
final token = cdk.Token(
|
||||
encoded: widget.token,
|
||||
amount: widget.amount,
|
||||
mintUrl: '',
|
||||
);
|
||||
|
||||
// Codificar a fragmentos UR usando cdk-flutter
|
||||
// maxFragmentLength por defecto es 150 bytes
|
||||
@@ -114,9 +135,9 @@ class _ShareTokenScreenState extends State<ShareTokenScreen> {
|
||||
icon: const Icon(LucideIcons.x, color: Colors.white),
|
||||
onPressed: () => _goToHome(context),
|
||||
),
|
||||
title: const Text(
|
||||
'Token creado',
|
||||
style: TextStyle(
|
||||
title: Text(
|
||||
L10n.of(context)!.tokenCreated,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.white,
|
||||
@@ -154,9 +175,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
|
||||
@@ -170,7 +196,7 @@ class _ShareTokenScreenState extends State<ShareTokenScreen> {
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(AppDimensions.paddingMedium),
|
||||
child: PrimaryButton(
|
||||
text: 'Volver al inicio',
|
||||
text: L10n.of(context)!.backToHome,
|
||||
onPressed: () => _goToHome(context),
|
||||
),
|
||||
),
|
||||
@@ -334,6 +360,7 @@ class _ShareTokenScreenState extends State<ShareTokenScreen> {
|
||||
}
|
||||
|
||||
Widget _buildTokenTextDisplay() {
|
||||
final l10n = L10n.of(context)!;
|
||||
final displayToken = widget.token.length > 50
|
||||
? '${widget.token.substring(0, 25)}...${widget.token.substring(widget.token.length - 20)}'
|
||||
: widget.token;
|
||||
@@ -351,8 +378,8 @@ class _ShareTokenScreenState extends State<ShareTokenScreen> {
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
isAnimated
|
||||
? 'Token Cashu (QR animado - ${_urFragments.length} fragmentos UR)'
|
||||
: 'Token Cashu',
|
||||
? l10n.tokenCashuAnimatedQr(_urFragments.length)
|
||||
: l10n.tokenCashu,
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 12,
|
||||
@@ -398,9 +425,9 @@ class _ShareTokenScreenState extends State<ShareTokenScreen> {
|
||||
children: [
|
||||
Icon(LucideIcons.copy, color: Colors.white, size: 20),
|
||||
const SizedBox(width: 8),
|
||||
const Text(
|
||||
'Copiar',
|
||||
style: TextStyle(
|
||||
Text(
|
||||
L10n.of(context)!.copy,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
@@ -430,9 +457,9 @@ class _ShareTokenScreenState extends State<ShareTokenScreen> {
|
||||
children: [
|
||||
Icon(LucideIcons.share2, color: Colors.white, size: 20),
|
||||
const SizedBox(width: 8),
|
||||
const Text(
|
||||
'Compartir',
|
||||
style: TextStyle(
|
||||
Text(
|
||||
L10n.of(context)!.share,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
@@ -448,6 +475,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),
|
||||
@@ -465,7 +561,7 @@ class _ShareTokenScreenState extends State<ShareTokenScreen> {
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Guarda este token hasta que el receptor lo reclame. Si lo pierdes, perderas los fondos.',
|
||||
L10n.of(context)!.keepTokenWarning,
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 14,
|
||||
@@ -482,7 +578,20 @@ class _ShareTokenScreenState extends State<ShareTokenScreen> {
|
||||
Clipboard.setData(ClipboardData(text: widget.token));
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: const Text('Token copiado al portapapeles'),
|
||||
content: Text(L10n.of(context)!.tokenCopiedToClipboard),
|
||||
backgroundColor: AppColors.success,
|
||||
behavior: SnackBarBehavior.floating,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
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)),
|
||||
@@ -506,6 +615,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);
|
||||
}
|
||||
|
||||
@@ -4,13 +4,13 @@ import 'package:flutter/services.dart';
|
||||
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 '../../src/rust/api/wallet.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
|
||||
@@ -38,7 +38,6 @@ class _InvoiceScreenState extends State<InvoiceScreen> {
|
||||
String? _invoice;
|
||||
String? _errorMessage;
|
||||
StreamSubscription<MintQuote>? _mintSubscription;
|
||||
final CashuConfettiController _confettiController = CashuConfettiController();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -49,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,
|
||||
);
|
||||
@@ -82,10 +80,9 @@ class _InvoiceScreenState extends State<InvoiceScreen> {
|
||||
_onMintCompleted();
|
||||
break;
|
||||
|
||||
default:
|
||||
// Cualquier otro estado se trata como error
|
||||
case MintQuoteState.error:
|
||||
_status = MintStatus.error;
|
||||
_errorMessage = 'Estado desconocido';
|
||||
_errorMessage = quote.error ?? L10n.of(context)!.unknownError;
|
||||
}
|
||||
});
|
||||
},
|
||||
@@ -106,18 +103,20 @@ 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), () {
|
||||
if (mounted) {
|
||||
final l10n = L10n.of(context)!;
|
||||
final formattedAmount = '+${UnitFormatter.formatBalance(widget.amount, widget.unit)}';
|
||||
final unitLabel = UnitFormatter.getUnitLabel(widget.unit);
|
||||
// Volver al home con mensaje de éxito
|
||||
Navigator.popUntil(context, (route) => route.isFirst);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
'+${UnitFormatter.formatBalance(widget.amount, widget.unit)} ${UnitFormatter.getUnitLabel(widget.unit)} depositados',
|
||||
l10n.deposited(formattedAmount, unitLabel),
|
||||
),
|
||||
backgroundColor: AppColors.success,
|
||||
duration: const Duration(seconds: 3),
|
||||
@@ -129,28 +128,22 @@ 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,
|
||||
title: const Text(
|
||||
'Pagar invoice',
|
||||
style: TextStyle(
|
||||
leading: IconButton(
|
||||
icon: const Icon(
|
||||
LucideIcons.arrowLeft,
|
||||
color: Colors.white,
|
||||
),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
title: Text(
|
||||
L10n.of(context)!.payInvoiceTitle,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.white,
|
||||
@@ -160,9 +153,9 @@ class _InvoiceScreenState extends State<InvoiceScreen> {
|
||||
if (_status == MintStatus.unpaid)
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text(
|
||||
'Cancelar',
|
||||
style: TextStyle(
|
||||
child: Text(
|
||||
L10n.of(context)!.cancel,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 14,
|
||||
color: AppColors.textSecondary,
|
||||
@@ -177,9 +170,7 @@ class _InvoiceScreenState extends State<InvoiceScreen> {
|
||||
child: _buildContent(),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -197,17 +188,17 @@ class _InvoiceScreenState extends State<InvoiceScreen> {
|
||||
}
|
||||
|
||||
Widget _buildLoading() {
|
||||
return const Center(
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
CircularProgressIndicator(
|
||||
const CircularProgressIndicator(
|
||||
color: AppColors.primaryAction,
|
||||
),
|
||||
SizedBox(height: AppDimensions.paddingMedium),
|
||||
const SizedBox(height: AppDimensions.paddingMedium),
|
||||
Text(
|
||||
'Generando invoice...',
|
||||
style: TextStyle(
|
||||
L10n.of(context)!.generatingInvoice,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 16,
|
||||
color: AppColors.textSecondary,
|
||||
@@ -285,9 +276,9 @@ class _InvoiceScreenState extends State<InvoiceScreen> {
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppDimensions.paddingMedium),
|
||||
const Text(
|
||||
'Error',
|
||||
style: TextStyle(
|
||||
Text(
|
||||
L10n.of(context)!.error,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
@@ -298,7 +289,7 @@ class _InvoiceScreenState extends State<InvoiceScreen> {
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 32),
|
||||
child: Text(
|
||||
_errorMessage ?? 'Error desconocido',
|
||||
_errorMessage ?? L10n.of(context)!.unknownError,
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 14,
|
||||
@@ -316,9 +307,9 @@ class _InvoiceScreenState extends State<InvoiceScreen> {
|
||||
color: Colors.white.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: const Text(
|
||||
'Volver',
|
||||
style: TextStyle(
|
||||
child: Text(
|
||||
L10n.of(context)!.back,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
@@ -333,13 +324,14 @@ class _InvoiceScreenState extends State<InvoiceScreen> {
|
||||
}
|
||||
|
||||
Widget _buildHeader() {
|
||||
final l10n = L10n.of(context)!;
|
||||
final formattedAmount = UnitFormatter.formatBalance(widget.amount, widget.unit);
|
||||
final unitLabel = UnitFormatter.getUnitLabel(widget.unit);
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
Text(
|
||||
'Depositar $formattedAmount $unitLabel',
|
||||
l10n.depositAmountTitle(formattedAmount, unitLabel),
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 24,
|
||||
@@ -404,7 +396,7 @@ class _InvoiceScreenState extends State<InvoiceScreen> {
|
||||
Icon(LucideIcons.copy, color: AppColors.textSecondary, size: 20),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'Copiar invoice',
|
||||
L10n.of(context)!.copyInvoice,
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 16,
|
||||
@@ -419,6 +411,7 @@ class _InvoiceScreenState extends State<InvoiceScreen> {
|
||||
}
|
||||
|
||||
Widget _buildStatus() {
|
||||
final l10n = L10n.of(context)!;
|
||||
IconData icon;
|
||||
String text;
|
||||
Color color;
|
||||
@@ -426,27 +419,27 @@ class _InvoiceScreenState extends State<InvoiceScreen> {
|
||||
switch (_status) {
|
||||
case MintStatus.loading:
|
||||
icon = LucideIcons.clock;
|
||||
text = 'Generando...';
|
||||
text = l10n.generating;
|
||||
color = AppColors.textSecondary;
|
||||
break;
|
||||
case MintStatus.unpaid:
|
||||
icon = LucideIcons.clock;
|
||||
text = 'Esperando pago...';
|
||||
text = l10n.waitingForPayment;
|
||||
color = AppColors.textSecondary;
|
||||
break;
|
||||
case MintStatus.paid:
|
||||
icon = LucideIcons.checkCircle;
|
||||
text = 'Pago recibido';
|
||||
text = l10n.paymentReceived;
|
||||
color = AppColors.success;
|
||||
break;
|
||||
case MintStatus.issued:
|
||||
icon = LucideIcons.checkCircle2;
|
||||
text = 'Tokens emitidos!';
|
||||
text = l10n.tokensIssued;
|
||||
color = AppColors.success;
|
||||
break;
|
||||
case MintStatus.error:
|
||||
icon = LucideIcons.xCircle;
|
||||
text = 'Error';
|
||||
text = l10n.error;
|
||||
color = AppColors.error;
|
||||
break;
|
||||
}
|
||||
@@ -489,7 +482,7 @@ class _InvoiceScreenState extends State<InvoiceScreen> {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Descripcion:',
|
||||
L10n.of(context)!.description,
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 12,
|
||||
@@ -515,10 +508,10 @@ class _InvoiceScreenState extends State<InvoiceScreen> {
|
||||
await Clipboard.setData(ClipboardData(text: _invoice!));
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Invoice copiado al portapapeles'),
|
||||
SnackBar(
|
||||
content: Text(L10n.of(context)!.invoiceCopiedToClipboard),
|
||||
backgroundColor: AppColors.success,
|
||||
duration: Duration(seconds: 2),
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:lucide_icons/lucide_icons.dart';
|
||||
import 'package:elcaju/l10n/app_localizations.dart';
|
||||
import '../../core/constants/colors.dart';
|
||||
import '../../core/constants/dimensions.dart';
|
||||
import '../../core/utils/formatters.dart';
|
||||
import '../../widgets/common/gradient_background.dart';
|
||||
import '../../widgets/common/glass_card.dart';
|
||||
import '../../widgets/common/primary_button.dart';
|
||||
import '../../widgets/common/numpad_widget.dart';
|
||||
import '../../providers/wallet_provider.dart';
|
||||
import 'invoice_screen.dart';
|
||||
|
||||
@@ -19,9 +21,9 @@ class MintScreen extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _MintScreenState extends State<MintScreen> {
|
||||
final TextEditingController _amountController = TextEditingController();
|
||||
final TextEditingController _descriptionController = TextEditingController();
|
||||
|
||||
String _amountValue = '';
|
||||
late String _activeUnit;
|
||||
|
||||
bool _isProcessing = false;
|
||||
@@ -35,7 +37,6 @@ class _MintScreenState extends State<MintScreen> {
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_amountController.dispose();
|
||||
_descriptionController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
@@ -43,8 +44,8 @@ class _MintScreenState extends State<MintScreen> {
|
||||
/// Obtiene la etiqueta de la unidad para display
|
||||
String get _unitLabel => UnitFormatter.getUnitLabel(_activeUnit);
|
||||
|
||||
/// Parsea el input del usuario a BigInt según la unidad
|
||||
BigInt get _amount => UnitFormatter.parseUserInput(_amountController.text, _activeUnit);
|
||||
/// Parsea los dígitos crudos a BigInt (ya son centavos para USD/EUR)
|
||||
BigInt get _amount => UnitFormatter.parseRawDigits(_amountValue, _activeUnit);
|
||||
|
||||
bool get _isValidAmount => _amount > BigInt.zero;
|
||||
|
||||
@@ -60,9 +61,9 @@ class _MintScreenState extends State<MintScreen> {
|
||||
icon: const Icon(LucideIcons.arrowLeft, color: Colors.white),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
title: const Text(
|
||||
'Depositar',
|
||||
style: TextStyle(
|
||||
title: Text(
|
||||
L10n.of(context)!.deposit,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.white,
|
||||
@@ -110,62 +111,48 @@ class _MintScreenState extends State<MintScreen> {
|
||||
|
||||
Widget _buildAmountSection() {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Display del monto
|
||||
_buildAmountDisplay(),
|
||||
const SizedBox(height: AppDimensions.paddingMedium),
|
||||
|
||||
// Teclado numérico
|
||||
NumpadWidget(
|
||||
value: _amountValue,
|
||||
onChanged: (newValue) {
|
||||
setState(() {
|
||||
_errorMessage = null;
|
||||
_amountValue = newValue;
|
||||
});
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAmountDisplay() {
|
||||
final displayAmount = UnitFormatter.formatRawDigitsForDisplay(_amountValue, _activeUnit);
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
Text(
|
||||
'Monto a depositar:',
|
||||
displayAmount,
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: AppColors.textSecondary,
|
||||
fontSize: 48,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: _isValidAmount || _amountValue.isEmpty
|
||||
? Colors.white
|
||||
: AppColors.error,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppDimensions.paddingSmall),
|
||||
|
||||
// Input de monto
|
||||
GlassCard(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: AppDimensions.paddingMedium,
|
||||
vertical: AppDimensions.paddingSmall,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _amountController,
|
||||
keyboardType: TextInputType.number,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 32,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.white,
|
||||
),
|
||||
decoration: InputDecoration(
|
||||
hintText: '0',
|
||||
hintStyle: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 32,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.white.withValues(alpha: 0.3),
|
||||
),
|
||||
border: InputBorder.none,
|
||||
),
|
||||
onChanged: (_) => setState(() {
|
||||
_errorMessage = null;
|
||||
}),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
_unitLabel,
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
_unitLabel,
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 18,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -173,11 +160,12 @@ class _MintScreenState extends State<MintScreen> {
|
||||
}
|
||||
|
||||
Widget _buildDescriptionSection() {
|
||||
final l10n = L10n.of(context)!;
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Descripcion (opcional):',
|
||||
l10n.descriptionOptional,
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 16,
|
||||
@@ -199,7 +187,7 @@ class _MintScreenState extends State<MintScreen> {
|
||||
color: Colors.white,
|
||||
),
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Ej: Deposito El Caju',
|
||||
hintText: l10n.depositPlaceholder,
|
||||
hintStyle: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 16,
|
||||
@@ -250,8 +238,9 @@ class _MintScreenState extends State<MintScreen> {
|
||||
}
|
||||
|
||||
Widget _buildGenerateButton() {
|
||||
final l10n = L10n.of(context)!;
|
||||
return PrimaryButton(
|
||||
text: _isProcessing ? 'Generando...' : 'Generar invoice',
|
||||
text: _isProcessing ? l10n.generating : l10n.generateInvoice,
|
||||
onPressed: _isValidAmount && !_isProcessing ? _showConfirmation : null,
|
||||
);
|
||||
}
|
||||
@@ -260,6 +249,7 @@ class _MintScreenState extends State<MintScreen> {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
backgroundColor: Colors.transparent,
|
||||
isScrollControlled: true,
|
||||
builder: (context) => _ConfirmationModal(
|
||||
amount: _amount,
|
||||
unit: _activeUnit,
|
||||
@@ -351,9 +341,9 @@ class _ConfirmationModal extends StatelessWidget {
|
||||
const SizedBox(height: AppDimensions.paddingMedium),
|
||||
|
||||
// Título
|
||||
const Text(
|
||||
'Depositar Lightning',
|
||||
style: TextStyle(
|
||||
Text(
|
||||
L10n.of(context)!.depositLightning,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
@@ -401,10 +391,10 @@ class _ConfirmationModal extends StatelessWidget {
|
||||
color: Colors.white.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: const Center(
|
||||
child: Center(
|
||||
child: Text(
|
||||
'Cancelar',
|
||||
style: TextStyle(
|
||||
L10n.of(context)!.cancel,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
@@ -418,7 +408,7 @@ class _ConfirmationModal extends StatelessWidget {
|
||||
const SizedBox(width: AppDimensions.paddingMedium),
|
||||
Expanded(
|
||||
child: PrimaryButton(
|
||||
text: 'Generar invoice',
|
||||
text: L10n.of(context)!.generateInvoice,
|
||||
onPressed: onConfirm,
|
||||
height: 52,
|
||||
),
|
||||
|
||||
@@ -0,0 +1,640 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:lucide_icons/lucide_icons.dart';
|
||||
import 'package:elcaju/l10n/app_localizations.dart';
|
||||
import '../../core/constants/colors.dart';
|
||||
import '../../core/constants/dimensions.dart';
|
||||
import '../../core/utils/formatters.dart';
|
||||
import '../../core/services/lnurl_service.dart';
|
||||
import '../../widgets/common/gradient_background.dart';
|
||||
import '../../widgets/common/glass_card.dart';
|
||||
import '../../widgets/common/primary_button.dart';
|
||||
import '../../widgets/common/numpad_widget.dart';
|
||||
import '../../providers/wallet_provider.dart';
|
||||
import '../../providers/price_provider.dart';
|
||||
|
||||
/// Pantalla para elegir monto en pagos LNURL/Lightning Address
|
||||
class AmountScreen extends StatefulWidget {
|
||||
/// Destino (LNURL o Lightning Address)
|
||||
final String destination;
|
||||
|
||||
/// Tipo de destino
|
||||
final LnInputType destinationType;
|
||||
|
||||
/// Parámetros LNURL ya resueltos
|
||||
final LnurlPayParams params;
|
||||
|
||||
const AmountScreen({
|
||||
super.key,
|
||||
required this.destination,
|
||||
required this.destinationType,
|
||||
required this.params,
|
||||
});
|
||||
|
||||
@override
|
||||
State<AmountScreen> createState() => _AmountScreenState();
|
||||
}
|
||||
|
||||
class _AmountScreenState extends State<AmountScreen> {
|
||||
String _amount = '';
|
||||
bool _isProcessing = false;
|
||||
String? _errorMessage;
|
||||
BigInt _availableBalance = BigInt.zero;
|
||||
late String _activeUnit;
|
||||
|
||||
// Equivalente en la otra unidad (para mostrar)
|
||||
String? _equivalentDisplay;
|
||||
|
||||
// Contador para evitar race conditions en _updateEquivalent
|
||||
int _equivalentGeneration = 0;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_activeUnit = context.read<WalletProvider>().activeUnit;
|
||||
_loadBalance();
|
||||
}
|
||||
|
||||
Future<void> _loadBalance() async {
|
||||
final walletProvider = context.read<WalletProvider>();
|
||||
final balance = await walletProvider.getBalance();
|
||||
if (mounted) {
|
||||
setState(() => _availableBalance = balance);
|
||||
}
|
||||
}
|
||||
|
||||
/// Obtiene el monto ingresado en la unidad base (centavos para USD, sats para sat)
|
||||
BigInt get _amountInBaseUnit {
|
||||
return UnitFormatter.parseRawDigits(_amount, _activeUnit);
|
||||
}
|
||||
|
||||
/// Verifica si la unidad activa es fiat (necesita conversión)
|
||||
bool get _isFiatUnit {
|
||||
return _activeUnit.toLowerCase() == 'usd' || _activeUnit.toLowerCase() == 'eur';
|
||||
}
|
||||
|
||||
/// Convierte el monto a sats para LNURL
|
||||
Future<BigInt> _getAmountInSats() async {
|
||||
if (!_isFiatUnit) {
|
||||
// Ya está en sats
|
||||
return _amountInBaseUnit;
|
||||
}
|
||||
|
||||
// Convertir fiat a sats usando PriceProvider
|
||||
final priceProvider = context.read<PriceProvider>();
|
||||
return await priceProvider.fiatCentsToSats(_amountInBaseUnit, _activeUnit);
|
||||
}
|
||||
|
||||
/// Actualiza el equivalente mostrado
|
||||
Future<void> _updateEquivalent() async {
|
||||
final gen = ++_equivalentGeneration;
|
||||
|
||||
if (_amount.isEmpty || _amountInBaseUnit == BigInt.zero) {
|
||||
setState(() => _equivalentDisplay = null);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
final priceProvider = context.read<PriceProvider>();
|
||||
|
||||
if (_isFiatUnit) {
|
||||
// Mostrar equivalente en sats
|
||||
final sats = await priceProvider.fiatCentsToSats(_amountInBaseUnit, _activeUnit);
|
||||
if (gen != _equivalentGeneration) return; // Descartar si hay llamada más reciente
|
||||
setState(() {
|
||||
_equivalentDisplay = '≈ ${UnitFormatter.formatBalance(sats, 'sat')} sat';
|
||||
});
|
||||
} else {
|
||||
// Mostrar equivalente en USD si hay precio
|
||||
if (priceProvider.hasPrice) {
|
||||
final usdCents = await priceProvider.satsToFiatCents(_amountInBaseUnit, 'usd');
|
||||
if (gen != _equivalentGeneration) return; // Descartar si hay llamada más reciente
|
||||
setState(() {
|
||||
_equivalentDisplay = '≈ ${UnitFormatter.formatBalance(usdCents, 'usd')} USD';
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (_) {
|
||||
if (gen != _equivalentGeneration) return;
|
||||
setState(() => _equivalentDisplay = null);
|
||||
}
|
||||
}
|
||||
|
||||
bool get _isAmountValid => _amountInBaseUnit > BigInt.zero;
|
||||
|
||||
bool get _canPay {
|
||||
return _isAmountValid && !_isProcessing && _amountInBaseUnit <= _availableBalance;
|
||||
}
|
||||
|
||||
String get _destinationLabel {
|
||||
return widget.destinationType == LnInputType.lightningAddress
|
||||
? 'Lightning Address'
|
||||
: 'LNURL';
|
||||
}
|
||||
|
||||
@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.send,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
body: SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(AppDimensions.paddingMedium),
|
||||
child: Column(
|
||||
children: [
|
||||
// Destino
|
||||
_buildDestinationCard(),
|
||||
const SizedBox(height: AppDimensions.paddingLarge),
|
||||
|
||||
// Display del monto
|
||||
_buildAmountDisplay(),
|
||||
const SizedBox(height: AppDimensions.paddingSmall),
|
||||
|
||||
// Rango permitido (siempre en sats porque es LNURL)
|
||||
_buildRangeInfo(),
|
||||
const SizedBox(height: AppDimensions.paddingLarge),
|
||||
|
||||
// Teclado numérico
|
||||
_buildNumpad(),
|
||||
|
||||
// Error message
|
||||
if (_errorMessage != null) ...[
|
||||
const SizedBox(height: AppDimensions.paddingMedium),
|
||||
_buildErrorMessage(),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Balance y botón pagar
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(AppDimensions.paddingMedium),
|
||||
child: Column(
|
||||
children: [
|
||||
_buildBalanceInfo(),
|
||||
const SizedBox(height: AppDimensions.paddingMedium),
|
||||
_buildPayButton(),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDestinationCard() {
|
||||
return GlassCard(
|
||||
padding: const EdgeInsets.all(AppDimensions.paddingMedium),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(10),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.primaryAction.withValues(alpha: 0.2),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(
|
||||
widget.destinationType == LnInputType.lightningAddress
|
||||
? LucideIcons.atSign
|
||||
: LucideIcons.link,
|
||||
color: AppColors.primaryAction,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
_destinationLabel,
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 12,
|
||||
color: AppColors.textSecondary.withValues(alpha: 0.7),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
'Payment to ${widget.destination}',
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Colors.white,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAmountDisplay() {
|
||||
final displayAmount = UnitFormatter.formatRawDigitsForDisplay(_amount, _activeUnit);
|
||||
final unitLabel = UnitFormatter.getUnitLabel(_activeUnit);
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
Text(
|
||||
displayAmount,
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 56,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: _isAmountValid || _amount.isEmpty
|
||||
? Colors.white
|
||||
: AppColors.error,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
unitLabel,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 18,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
// Mostrar equivalente en la otra unidad
|
||||
if (_equivalentDisplay != null) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
_equivalentDisplay!,
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 14,
|
||||
color: AppColors.textSecondary.withValues(alpha: 0.7),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildRangeInfo() {
|
||||
// LNURL min/max siempre en sats
|
||||
final min = widget.params.minSats;
|
||||
final max = widget.params.maxSats;
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: 0.05),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Text(
|
||||
'Min: $min • Max: $max sat',
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 13,
|
||||
color: AppColors.textSecondary.withValues(alpha: 0.7),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildNumpad() {
|
||||
return NumpadWidget(
|
||||
value: _amount,
|
||||
onChanged: (newValue) {
|
||||
setState(() {
|
||||
_errorMessage = null;
|
||||
_amount = newValue;
|
||||
});
|
||||
// Actualizar equivalente de forma asíncrona
|
||||
_updateEquivalent();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBalanceInfo() {
|
||||
final unitLabel = UnitFormatter.getUnitLabel(_activeUnit);
|
||||
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
'${L10n.of(context)!.available} ',
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 14,
|
||||
color: AppColors.textSecondary.withValues(alpha: 0.7),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'${UnitFormatter.formatBalance(_availableBalance, _activeUnit)} $unitLabel',
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.primaryAction,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPayButton() {
|
||||
final l10n = L10n.of(context)!;
|
||||
|
||||
return PrimaryButton(
|
||||
text: _isProcessing ? l10n.paying : l10n.payInvoice,
|
||||
onPressed: _canPay ? _processPayment : null,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildErrorMessage() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(AppDimensions.paddingMedium),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.error.withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: AppColors.error.withValues(alpha: 0.3),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(LucideIcons.alertCircle, color: AppColors.error, size: 20),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
_errorMessage!,
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 14,
|
||||
color: AppColors.error,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _processPayment() async {
|
||||
if (!_canPay) return;
|
||||
|
||||
setState(() {
|
||||
_isProcessing = true;
|
||||
_errorMessage = null;
|
||||
});
|
||||
|
||||
try {
|
||||
// 1. Convertir monto a sats para LNURL
|
||||
final amountSats = await _getAmountInSats();
|
||||
|
||||
// Validar contra límites LNURL
|
||||
if (!widget.params.isAmountValid(amountSats)) {
|
||||
setState(() {
|
||||
_isProcessing = false;
|
||||
_errorMessage = L10n.of(context)!.amountOutOfRange;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. Obtener invoice desde LNURL callback
|
||||
final invoiceResult = await LnurlService.fetchInvoice(
|
||||
widget.params.callback,
|
||||
amountSats,
|
||||
);
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
// 3. Obtener quote del mint (en la unidad del mint)
|
||||
final walletProvider = context.read<WalletProvider>();
|
||||
final quote = await walletProvider.getMeltQuote(invoiceResult.invoice);
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
final total = quote.amount + quote.feeReserve;
|
||||
|
||||
// 4. Verificar balance suficiente
|
||||
if (total > _availableBalance) {
|
||||
setState(() {
|
||||
_isProcessing = false;
|
||||
_errorMessage = L10n.of(context)!.insufficientBalance;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 5. Mostrar confirmación (montos del mint, en activeUnit)
|
||||
final confirmed = await _showConfirmation(quote.amount, quote.feeReserve, total);
|
||||
|
||||
if (!confirmed) {
|
||||
if (mounted) setState(() => _isProcessing = false);
|
||||
return;
|
||||
}
|
||||
if (!mounted) return;
|
||||
|
||||
// 6. Ejecutar pago
|
||||
final totalPaid = await walletProvider.melt(quote);
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
// 7. Mostrar éxito y volver
|
||||
final l10n = L10n.of(context)!;
|
||||
final unitLabel = UnitFormatter.getUnitLabel(_activeUnit);
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(l10n.sent('-${UnitFormatter.formatBalance(totalPaid, _activeUnit)}', unitLabel)),
|
||||
backgroundColor: AppColors.success,
|
||||
duration: const Duration(seconds: 3),
|
||||
),
|
||||
);
|
||||
|
||||
// Volver a home (pop dos veces: AmountScreen y MeltScreen)
|
||||
Navigator.pop(context);
|
||||
Navigator.pop(context);
|
||||
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isProcessing = false;
|
||||
_errorMessage = _parseError(e.toString());
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
String _parseError(String error) {
|
||||
final lower = error.toLowerCase();
|
||||
final l10n = L10n.of(context)!;
|
||||
|
||||
if (lower.contains('insufficient') || lower.contains('not enough')) {
|
||||
return l10n.insufficientBalance;
|
||||
} else if (lower.contains('expired')) {
|
||||
return l10n.invoiceExpired;
|
||||
} else if (lower.contains('min') || lower.contains('max')) {
|
||||
return l10n.amountOutOfRange;
|
||||
} else if (lower.contains('precio') || lower.contains('price')) {
|
||||
return 'No se pudo obtener el precio de BTC';
|
||||
}
|
||||
|
||||
return error.replaceFirst('Exception: ', '');
|
||||
}
|
||||
|
||||
Future<bool> _showConfirmation(BigInt amount, BigInt fee, BigInt total) async {
|
||||
final unitLabel = UnitFormatter.getUnitLabel(_activeUnit);
|
||||
|
||||
final result = await showModalBottomSheet<bool>(
|
||||
context: context,
|
||||
backgroundColor: Colors.transparent,
|
||||
builder: (context) => Container(
|
||||
padding: const EdgeInsets.all(AppDimensions.paddingMedium),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.deepVoidPurple,
|
||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(24)),
|
||||
border: Border.all(
|
||||
color: Colors.white.withValues(alpha: 0.1),
|
||||
),
|
||||
),
|
||||
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),
|
||||
),
|
||||
),
|
||||
|
||||
// Icono
|
||||
Container(
|
||||
width: 64,
|
||||
height: 64,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.primaryAction.withValues(alpha: 0.2),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Icon(
|
||||
LucideIcons.zap,
|
||||
color: AppColors.primaryAction,
|
||||
size: 32,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppDimensions.paddingMedium),
|
||||
|
||||
// Título
|
||||
Text(
|
||||
L10n.of(context)!.confirmPayment,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppDimensions.paddingSmall),
|
||||
|
||||
// Monto (en la unidad del mint)
|
||||
Text(
|
||||
'${UnitFormatter.formatBalance(amount, _activeUnit)} $unitLabel',
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 28,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'+ ~${UnitFormatter.formatBalance(fee, _activeUnit)} $unitLabel fee',
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 14,
|
||||
color: AppColors.textSecondary.withValues(alpha: 0.7),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppDimensions.paddingSmall),
|
||||
Text(
|
||||
'Total: ${UnitFormatter.formatBalance(total, _activeUnit)} $unitLabel',
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.primaryAction,
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: AppDimensions.paddingLarge),
|
||||
|
||||
// Botones
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: GestureDetector(
|
||||
onTap: () => Navigator.pop(context, false),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
L10n.of(context)!.cancel,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: AppDimensions.paddingMedium),
|
||||
Expanded(
|
||||
child: PrimaryButton(
|
||||
text: L10n.of(context)!.pay,
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
height: 52,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: AppDimensions.paddingSmall),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
return result ?? false;
|
||||
}
|
||||
}
|
||||
@@ -2,18 +2,26 @@ import 'package:flutter/material.dart';
|
||||
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 '../../src/rust/api/wallet.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';
|
||||
import '../../core/services/lnurl_service.dart';
|
||||
import '../../widgets/common/gradient_background.dart';
|
||||
import '../../widgets/common/glass_card.dart';
|
||||
import '../../widgets/common/primary_button.dart';
|
||||
import '../../providers/wallet_provider.dart';
|
||||
import '../10_scanner/scan_screen.dart';
|
||||
import 'amount_screen.dart';
|
||||
|
||||
/// Pantalla para retirar sats a Lightning (Melt)
|
||||
class MeltScreen extends StatefulWidget {
|
||||
const MeltScreen({super.key});
|
||||
/// Invoice inicial (pre-cargado desde QR scanner o deep link)
|
||||
final String? initialInvoice;
|
||||
|
||||
const MeltScreen({super.key, this.initialInvoice});
|
||||
|
||||
@override
|
||||
State<MeltScreen> createState() => _MeltScreenState();
|
||||
@@ -34,11 +42,22 @@ class _MeltScreenState extends State<MeltScreen> {
|
||||
BigInt _availableBalance = BigInt.zero;
|
||||
String? _errorMessage;
|
||||
|
||||
// Estado para LNURL/Lightning Address (solo para mostrar loading)
|
||||
LnInputType _inputType = LnInputType.unknown;
|
||||
bool _isResolvingLnurl = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_activeUnit = context.read<WalletProvider>().activeUnit;
|
||||
_loadBalance();
|
||||
// Pre-cargar invoice inicial si existe
|
||||
if (widget.initialInvoice != null && widget.initialInvoice!.isNotEmpty) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_invoiceController.text = widget.initialInvoice!;
|
||||
_onInvoiceChanged(widget.initialInvoice!);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Obtiene la etiqueta de la unidad para display
|
||||
@@ -72,9 +91,9 @@ class _MeltScreenState extends State<MeltScreen> {
|
||||
icon: const Icon(LucideIcons.arrowLeft, color: Colors.white),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
title: const Text(
|
||||
'Retirar',
|
||||
style: TextStyle(
|
||||
title: Text(
|
||||
L10n.of(context)!.withdraw,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.white,
|
||||
@@ -93,7 +112,7 @@ class _MeltScreenState extends State<MeltScreen> {
|
||||
children: [
|
||||
// Instrucciones
|
||||
Text(
|
||||
'Pega el invoice Lightning:',
|
||||
L10n.of(context)!.pasteLightningInvoice,
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 16,
|
||||
@@ -108,11 +127,14 @@ class _MeltScreenState extends State<MeltScreen> {
|
||||
|
||||
const SizedBox(height: AppDimensions.paddingMedium),
|
||||
|
||||
// Botón pegar del portapapeles
|
||||
_buildPasteButton(),
|
||||
// Botones pegar y escanear
|
||||
_buildActionButtons(),
|
||||
|
||||
const SizedBox(height: AppDimensions.paddingLarge),
|
||||
|
||||
// Loading LNURL resolve
|
||||
if (_isResolvingLnurl) _buildResolvingLnurl(),
|
||||
|
||||
// Loading quote
|
||||
if (_isLoadingQuote) _buildLoadingQuote(),
|
||||
|
||||
@@ -161,7 +183,7 @@ class _MeltScreenState extends State<MeltScreen> {
|
||||
color: Colors.white,
|
||||
),
|
||||
decoration: InputDecoration(
|
||||
hintText: 'lnbc1000n1pj...',
|
||||
hintText: 'lnbc..., lnurl1..., user@domain.com',
|
||||
hintStyle: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 14,
|
||||
@@ -175,6 +197,18 @@ class _MeltScreenState extends State<MeltScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildActionButtons() {
|
||||
return Row(
|
||||
children: [
|
||||
// Botón pegar (expandido)
|
||||
Expanded(child: _buildPasteButton()),
|
||||
const SizedBox(width: 12),
|
||||
// Botón escanear QR
|
||||
_buildScanButton(),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPasteButton() {
|
||||
return GestureDetector(
|
||||
onTap: _pasteFromClipboard,
|
||||
@@ -201,7 +235,7 @@ class _MeltScreenState extends State<MeltScreen> {
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'Pegar del portapapeles',
|
||||
L10n.of(context)!.pasteFromClipboard,
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 16,
|
||||
@@ -215,6 +249,72 @@ class _MeltScreenState extends State<MeltScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildScanButton() {
|
||||
return GestureDetector(
|
||||
onTap: _openScanner,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(AppDimensions.paddingSmall + 4),
|
||||
decoration: BoxDecoration(
|
||||
gradient: const LinearGradient(
|
||||
colors: AppColors.buttonGradient,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: const Icon(
|
||||
LucideIcons.scan,
|
||||
color: Colors.white,
|
||||
size: 24,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _openScanner() async {
|
||||
final result = await Navigator.push<String>(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => const ScanScreen(mode: ScanMode.invoiceOnly),
|
||||
),
|
||||
);
|
||||
|
||||
if (result != null && result.isNotEmpty && mounted) {
|
||||
_invoiceController.text = result;
|
||||
_onInvoiceChanged(result);
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildResolvingLnurl() {
|
||||
final typeLabel = _inputType == LnInputType.lightningAddress
|
||||
? 'Lightning Address'
|
||||
: 'LNURL';
|
||||
|
||||
return GlassCard(
|
||||
padding: const EdgeInsets.all(AppDimensions.paddingMedium),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: AppColors.primaryAction,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Text(
|
||||
L10n.of(context)!.resolvingType(typeLabel),
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 14,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildLoadingQuote() {
|
||||
return GlassCard(
|
||||
padding: const EdgeInsets.all(AppDimensions.paddingMedium),
|
||||
@@ -231,7 +331,7 @@ class _MeltScreenState extends State<MeltScreen> {
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Text(
|
||||
'Obteniendo quote...',
|
||||
L10n.of(context)!.gettingQuote,
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 14,
|
||||
@@ -267,7 +367,7 @@ class _MeltScreenState extends State<MeltScreen> {
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'Invoice válido',
|
||||
L10n.of(context)!.validInvoice,
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 14,
|
||||
@@ -284,7 +384,7 @@ class _MeltScreenState extends State<MeltScreen> {
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
'Monto:',
|
||||
L10n.of(context)!.amount,
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 14,
|
||||
@@ -309,7 +409,7 @@ class _MeltScreenState extends State<MeltScreen> {
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
'Fee reservado:',
|
||||
L10n.of(context)!.feeReserved,
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 14,
|
||||
@@ -336,9 +436,9 @@ class _MeltScreenState extends State<MeltScreen> {
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Text(
|
||||
'Total:',
|
||||
style: TextStyle(
|
||||
Text(
|
||||
L10n.of(context)!.total,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
@@ -362,7 +462,7 @@ class _MeltScreenState extends State<MeltScreen> {
|
||||
if (!hasEnoughBalance) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Balance insuficiente',
|
||||
L10n.of(context)!.insufficientBalance,
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 12,
|
||||
@@ -410,7 +510,7 @@ class _MeltScreenState extends State<MeltScreen> {
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
'Disponible: ',
|
||||
'${L10n.of(context)!.available} ',
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 14,
|
||||
@@ -431,18 +531,46 @@ class _MeltScreenState extends State<MeltScreen> {
|
||||
}
|
||||
|
||||
Widget _buildPayButton() {
|
||||
final canPay = _isValidInvoice &&
|
||||
_quote != null &&
|
||||
!_isProcessing &&
|
||||
!_isLoadingQuote &&
|
||||
_total <= _availableBalance;
|
||||
final l10n = L10n.of(context)!;
|
||||
final hasInput = _invoiceController.text.trim().isNotEmpty;
|
||||
|
||||
// Para BOLT11 con quote válido: botón Pagar
|
||||
final hasValidQuote = _isValidInvoice && _quote != null && _total <= _availableBalance;
|
||||
|
||||
// Para LNURL/Address, unknown, o BOLT11 sin quote válido: botón Continuar
|
||||
final needsProcessing = _inputType == LnInputType.lnurl ||
|
||||
_inputType == LnInputType.lightningAddress ||
|
||||
_inputType == LnInputType.unknown ||
|
||||
(_inputType == LnInputType.bolt11Invoice && !hasValidQuote);
|
||||
|
||||
// Determinar estado del botón
|
||||
final canPay = !_isProcessing && !_isLoadingQuote && !_isResolvingLnurl && hasValidQuote;
|
||||
final canContinue = !_isProcessing && !_isLoadingQuote && !_isResolvingLnurl &&
|
||||
hasInput && needsProcessing;
|
||||
|
||||
// Texto del botón
|
||||
String buttonText;
|
||||
if (_isProcessing) {
|
||||
buttonText = l10n.paying;
|
||||
} else if (needsProcessing && !hasValidQuote) {
|
||||
buttonText = l10n.continue_;
|
||||
} else {
|
||||
buttonText = l10n.payInvoice;
|
||||
}
|
||||
|
||||
return PrimaryButton(
|
||||
text: _isProcessing ? 'Pagando...' : 'Pagar invoice',
|
||||
onPressed: canPay ? _showConfirmation : null,
|
||||
text: buttonText,
|
||||
onPressed: canPay ? _handlePay : (canContinue ? _processInput : null),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _handlePay() async {
|
||||
// Cerrar teclado
|
||||
FocusScope.of(context).unfocus();
|
||||
// Flujo normal con invoice directo
|
||||
_showConfirmation();
|
||||
}
|
||||
|
||||
Future<void> _pasteFromClipboard() async {
|
||||
final clipboardData = await Clipboard.getData(Clipboard.kTextPlain);
|
||||
if (clipboardData?.text != null) {
|
||||
@@ -458,6 +586,7 @@ class _MeltScreenState extends State<MeltScreen> {
|
||||
_isValidInvoice = false;
|
||||
|
||||
if (value.isEmpty) {
|
||||
_inputType = LnInputType.unknown;
|
||||
_invoiceAmount = BigInt.zero;
|
||||
_feeReserve = BigInt.zero;
|
||||
_total = BigInt.zero;
|
||||
@@ -465,19 +594,139 @@ class _MeltScreenState extends State<MeltScreen> {
|
||||
}
|
||||
});
|
||||
|
||||
// Verificar formato básico
|
||||
final trimmed = value.trim().toLowerCase();
|
||||
if (!trimmed.startsWith('lnbc') &&
|
||||
!trimmed.startsWith('lntb') &&
|
||||
!trimmed.startsWith('lnbcrt')) {
|
||||
setState(() {
|
||||
_errorMessage = 'Invoice inválido';
|
||||
});
|
||||
return;
|
||||
// Extract lightning invoice from BIP-321 URI if present
|
||||
var inputValue = value;
|
||||
if (value.trim().toLowerCase().startsWith('bitcoin:')) {
|
||||
final parsed = IncomingDataParser.parse(value.trim());
|
||||
if (parsed.invoiceBolt11 != null) {
|
||||
inputValue = parsed.invoiceBolt11!;
|
||||
_invoiceController.text = inputValue;
|
||||
_invoiceController.selection = TextSelection.fromPosition(
|
||||
TextPosition(offset: inputValue.length),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Obtener quote real
|
||||
_getQuote(value.trim());
|
||||
// Detectar tipo de input (sin mostrar error, solo detectar)
|
||||
final inputType = LnurlService.detectType(inputValue);
|
||||
setState(() => _inputType = inputType);
|
||||
|
||||
// Solo procesar automáticamente invoices BOLT11
|
||||
// LNURL y Lightning Address requieren botón explícito
|
||||
if (inputType == LnInputType.bolt11Invoice) {
|
||||
// BOLT11 invoices son 200+ chars; evitar llamar API con input parcial
|
||||
final cleaned = LnurlService.cleanInput(inputValue);
|
||||
if (cleaned.length > 50) {
|
||||
_getQuote(cleaned);
|
||||
}
|
||||
}
|
||||
// No mostrar error para unknown - el usuario puede estar escribiendo
|
||||
}
|
||||
|
||||
/// Procesa el input actual (llamado por botón Continuar)
|
||||
void _processInput() {
|
||||
final value = _invoiceController.text.trim();
|
||||
if (value.isEmpty) return;
|
||||
|
||||
// Cerrar teclado
|
||||
FocusScope.of(context).unfocus();
|
||||
|
||||
switch (_inputType) {
|
||||
case LnInputType.bolt11Invoice:
|
||||
if (_quote != null) {
|
||||
// Quote válido, mostrar confirmación
|
||||
_showConfirmation();
|
||||
} else {
|
||||
// Sin quote, intentar obtenerlo o mostrar error
|
||||
final cleaned = LnurlService.cleanInput(value);
|
||||
if (cleaned.length > 50) {
|
||||
_getQuote(cleaned);
|
||||
} else {
|
||||
setState(() {
|
||||
_errorMessage = L10n.of(context)!.invalidInvoiceMalformed;
|
||||
});
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case LnInputType.lnurl:
|
||||
_resolveLnurl(value);
|
||||
break;
|
||||
|
||||
case LnInputType.lightningAddress:
|
||||
_resolveLightningAddress(value);
|
||||
break;
|
||||
|
||||
case LnInputType.unknown:
|
||||
setState(() {
|
||||
_errorMessage = L10n.of(context)!.invalidInvoice;
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _resolveLnurl(String lnurl) async {
|
||||
setState(() {
|
||||
_isResolvingLnurl = true;
|
||||
_errorMessage = null;
|
||||
});
|
||||
|
||||
try {
|
||||
final params = await LnurlService.resolveLnurl(lnurl);
|
||||
if (mounted) {
|
||||
setState(() => _isResolvingLnurl = false);
|
||||
// Navegar a AmountScreen
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => AmountScreen(
|
||||
destination: lnurl,
|
||||
destinationType: LnInputType.lnurl,
|
||||
params: params,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isResolvingLnurl = false;
|
||||
_errorMessage = L10n.of(context)!.paymentError(e.toString().replaceFirst('Exception: ', ''));
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _resolveLightningAddress(String address) async {
|
||||
setState(() {
|
||||
_isResolvingLnurl = true;
|
||||
_errorMessage = null;
|
||||
});
|
||||
|
||||
try {
|
||||
final params = await LnurlService.resolveLightningAddress(address);
|
||||
if (mounted) {
|
||||
setState(() => _isResolvingLnurl = false);
|
||||
// Navegar a AmountScreen
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => AmountScreen(
|
||||
destination: address,
|
||||
destinationType: LnInputType.lightningAddress,
|
||||
params: params,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isResolvingLnurl = false;
|
||||
_errorMessage = L10n.of(context)!.paymentError(e.toString().replaceFirst('Exception: ', ''));
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _getQuote(String invoice) async {
|
||||
@@ -502,15 +751,16 @@ class _MeltScreenState extends State<MeltScreen> {
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
final l10n = L10n.of(context)!;
|
||||
setState(() {
|
||||
_isLoadingQuote = false;
|
||||
final errorStr = e.toString().toLowerCase();
|
||||
if (errorStr.contains('expired')) {
|
||||
_errorMessage = 'Invoice expirado';
|
||||
_errorMessage = l10n.invoiceExpired;
|
||||
} else if (errorStr.contains('invalid') || errorStr.contains('decode')) {
|
||||
_errorMessage = 'Invoice inválido o malformado';
|
||||
_errorMessage = l10n.invalidInvoiceMalformed;
|
||||
} else {
|
||||
_errorMessage = 'Error al obtener quote: $e';
|
||||
_errorMessage = l10n.paymentError(e.toString());
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -550,10 +800,11 @@ class _MeltScreenState extends State<MeltScreen> {
|
||||
final totalPaid = await walletProvider.melt(_quote!);
|
||||
|
||||
if (mounted) {
|
||||
final l10n = L10n.of(context)!;
|
||||
// Mostrar éxito y volver
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('-${UnitFormatter.formatBalance(totalPaid, _activeUnit)} $_unitLabel enviados'),
|
||||
content: Text(l10n.sent('-${UnitFormatter.formatBalance(totalPaid, _activeUnit)}', _unitLabel)),
|
||||
backgroundColor: AppColors.success,
|
||||
duration: const Duration(seconds: 3),
|
||||
),
|
||||
@@ -561,17 +812,21 @@ class _MeltScreenState extends State<MeltScreen> {
|
||||
Navigator.pop(context);
|
||||
}
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
final l10n = L10n.of(context)!;
|
||||
final errorStr = e.toString().toLowerCase();
|
||||
String errorMessage;
|
||||
if (errorStr.contains('insufficient') || errorStr.contains('not enough')) {
|
||||
errorMessage = l10n.insufficientBalance;
|
||||
} else if (errorStr.contains('expired')) {
|
||||
errorMessage = l10n.invoiceExpired;
|
||||
} else if (errorStr.contains('already paid')) {
|
||||
errorMessage = l10n.invoiceAlreadyPaid;
|
||||
} else {
|
||||
errorMessage = l10n.paymentError(e.toString());
|
||||
}
|
||||
setState(() {
|
||||
final errorStr = e.toString().toLowerCase();
|
||||
if (errorStr.contains('insufficient') || errorStr.contains('not enough')) {
|
||||
_errorMessage = 'Balance insuficiente';
|
||||
} else if (errorStr.contains('expired')) {
|
||||
_errorMessage = 'Invoice expirado';
|
||||
} else if (errorStr.contains('already paid')) {
|
||||
_errorMessage = 'Invoice ya fue pagado';
|
||||
} else {
|
||||
_errorMessage = 'Error al pagar: $e';
|
||||
}
|
||||
_errorMessage = errorMessage;
|
||||
});
|
||||
} finally {
|
||||
if (mounted) {
|
||||
@@ -644,9 +899,9 @@ class _ConfirmationModal extends StatelessWidget {
|
||||
const SizedBox(height: AppDimensions.paddingMedium),
|
||||
|
||||
// Título
|
||||
const Text(
|
||||
'Confirmar pago',
|
||||
style: TextStyle(
|
||||
Text(
|
||||
L10n.of(context)!.confirmPayment,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
@@ -698,10 +953,10 @@ class _ConfirmationModal extends StatelessWidget {
|
||||
color: Colors.white.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: const Center(
|
||||
child: Center(
|
||||
child: Text(
|
||||
'Cancelar',
|
||||
style: TextStyle(
|
||||
L10n.of(context)!.cancel,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
@@ -715,7 +970,7 @@ class _ConfirmationModal extends StatelessWidget {
|
||||
const SizedBox(width: AppDimensions.paddingMedium),
|
||||
Expanded(
|
||||
child: PrimaryButton(
|
||||
text: 'Pagar',
|
||||
text: L10n.of(context)!.pay,
|
||||
onPressed: onConfirm,
|
||||
height: 52,
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
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 '../../src/rust/api/mint_info.dart' show MintInfo, ContactInfo;
|
||||
import 'package:elcaju/l10n/app_localizations.dart';
|
||||
import '../../core/constants/colors.dart';
|
||||
import '../../core/utils/formatters.dart';
|
||||
import '../../widgets/common/gradient_background.dart';
|
||||
@@ -50,9 +51,10 @@ class _MintDetailScreenState extends State<MintDetailScreen> {
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
),
|
||||
body: SingleChildScrollView(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
body: SafeArea(
|
||||
child: SingleChildScrollView(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
child: Column(
|
||||
children: [
|
||||
// Header: Logo + Nombre
|
||||
@@ -93,6 +95,7 @@ class _MintDetailScreenState extends State<MintDetailScreen> {
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -148,9 +151,9 @@ class _MintDetailScreenState extends State<MintDetailScreen> {
|
||||
color: AppColors.success.withValues(alpha: 0.2),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: const Text(
|
||||
'Mint activo',
|
||||
style: TextStyle(
|
||||
child: Text(
|
||||
L10n.of(context)!.activeMint,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
@@ -187,9 +190,9 @@ class _MintDetailScreenState extends State<MintDetailScreen> {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'Mint Message',
|
||||
style: TextStyle(
|
||||
Text(
|
||||
L10n.of(context)!.mintMessage,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
@@ -296,6 +299,7 @@ class _MintDetailScreenState extends State<MintDetailScreen> {
|
||||
}
|
||||
|
||||
Widget _buildMintDetails(MintInfo? info) {
|
||||
final l10n = L10n.of(context)!;
|
||||
// Obtener currency de los balances
|
||||
final currencies = widget.balances.keys.join(', ').toUpperCase();
|
||||
final currencyDisplay = currencies.isNotEmpty ? currencies : 'SAT';
|
||||
@@ -303,24 +307,24 @@ class _MintDetailScreenState extends State<MintDetailScreen> {
|
||||
// Versión del mint
|
||||
final version = info?.version != null
|
||||
? '${info!.version!.name}/${info.version!.version}'
|
||||
: 'Unknown';
|
||||
: l10n.unknown;
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
_buildDetailRow(
|
||||
icon: LucideIcons.link,
|
||||
label: 'URL',
|
||||
label: l10n.url,
|
||||
value: widget.mintUrl,
|
||||
canCopy: true,
|
||||
),
|
||||
_buildDetailRow(
|
||||
icon: LucideIcons.coins,
|
||||
label: 'Currency',
|
||||
label: l10n.currency,
|
||||
value: currencyDisplay,
|
||||
),
|
||||
_buildDetailRow(
|
||||
icon: LucideIcons.box,
|
||||
label: 'Version',
|
||||
label: l10n.version,
|
||||
value: version,
|
||||
),
|
||||
],
|
||||
@@ -424,13 +428,14 @@ class _MintDetailScreenState extends State<MintDetailScreen> {
|
||||
}
|
||||
|
||||
Widget _buildActions() {
|
||||
final l10n = L10n.of(context)!;
|
||||
return Column(
|
||||
children: [
|
||||
// Usar este mint (si no es el activo)
|
||||
if (!widget.isActive && widget.onSetActive != null)
|
||||
_buildActionButton(
|
||||
icon: LucideIcons.star,
|
||||
label: 'Usar este mint',
|
||||
label: l10n.useThisMint,
|
||||
onTap: () {
|
||||
widget.onSetActive!();
|
||||
Navigator.pop(context);
|
||||
@@ -440,15 +445,15 @@ class _MintDetailScreenState extends State<MintDetailScreen> {
|
||||
// Copiar URL
|
||||
_buildActionButton(
|
||||
icon: LucideIcons.copy,
|
||||
label: 'Copiar URL del mint',
|
||||
onTap: () => _copyToClipboard(widget.mintUrl, 'URL'),
|
||||
label: l10n.copyMintUrl,
|
||||
onTap: () => _copyToClipboard(widget.mintUrl, l10n.url),
|
||||
),
|
||||
|
||||
// Eliminar mint
|
||||
if (widget.onDelete != null)
|
||||
_buildActionButton(
|
||||
icon: LucideIcons.trash2,
|
||||
label: 'Eliminar mint',
|
||||
label: l10n.deleteMint,
|
||||
isDestructive: true,
|
||||
onTap: () => _showDeleteConfirmation(),
|
||||
),
|
||||
@@ -501,7 +506,7 @@ class _MintDetailScreenState extends State<MintDetailScreen> {
|
||||
Clipboard.setData(ClipboardData(text: text));
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('$label copiado'),
|
||||
content: Text(L10n.of(context)!.copied(label)),
|
||||
backgroundColor: AppColors.success,
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
@@ -509,6 +514,7 @@ class _MintDetailScreenState extends State<MintDetailScreen> {
|
||||
}
|
||||
|
||||
void _showDeleteConfirmation() {
|
||||
final l10n = L10n.of(context)!;
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
@@ -516,16 +522,16 @@ class _MintDetailScreenState extends State<MintDetailScreen> {
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
title: const Text(
|
||||
'Eliminar mint',
|
||||
style: TextStyle(
|
||||
title: Text(
|
||||
l10n.deleteMintConfirmTitle,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
content: Text(
|
||||
'Si tienes balance en este mint, se perderá. ¿Estás seguro?',
|
||||
l10n.deleteMintConfirmMessage,
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
color: Colors.white.withValues(alpha: 0.8),
|
||||
@@ -534,9 +540,9 @@ class _MintDetailScreenState extends State<MintDetailScreen> {
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text(
|
||||
'Cancelar',
|
||||
style: TextStyle(color: AppColors.textSecondary),
|
||||
child: Text(
|
||||
l10n.cancel,
|
||||
style: const TextStyle(color: AppColors.textSecondary),
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
@@ -545,9 +551,9 @@ class _MintDetailScreenState extends State<MintDetailScreen> {
|
||||
Navigator.pop(context); // Volver a lista
|
||||
widget.onDelete!();
|
||||
},
|
||||
child: const Text(
|
||||
'Eliminar',
|
||||
style: TextStyle(color: AppColors.error),
|
||||
child: Text(
|
||||
l10n.delete,
|
||||
style: const TextStyle(color: AppColors.error),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
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 '../../src/rust/api/mint_info.dart' show MintInfo;
|
||||
import 'package:elcaju/l10n/app_localizations.dart';
|
||||
import '../../core/constants/colors.dart';
|
||||
import '../../core/constants/dimensions.dart';
|
||||
import '../../core/utils/formatters.dart';
|
||||
@@ -32,9 +33,9 @@ class _MintsScreenState extends State<MintsScreen> {
|
||||
icon: const Icon(LucideIcons.arrowLeft, color: Colors.white),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
title: const Text(
|
||||
'Mints conectados',
|
||||
style: TextStyle(
|
||||
title: Text(
|
||||
L10n.of(context)!.connectedMints,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.white,
|
||||
@@ -100,6 +101,7 @@ class _MintsScreenState extends State<MintsScreen> {
|
||||
}
|
||||
|
||||
Widget _buildEmptyState() {
|
||||
final l10n = L10n.of(context)!;
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
@@ -111,7 +113,7 @@ class _MintsScreenState extends State<MintsScreen> {
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'No hay mints conectados',
|
||||
l10n.noConnectedMints,
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 18,
|
||||
@@ -121,7 +123,7 @@ class _MintsScreenState extends State<MintsScreen> {
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Agrega un mint para comenzar',
|
||||
l10n.addMintToStart,
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 14,
|
||||
@@ -356,13 +358,14 @@ class _MintsScreenState extends State<MintsScreen> {
|
||||
|
||||
/// Elimina un mint (llamado desde pantalla de detalles)
|
||||
Future<void> _deleteMint(String mintUrl, WalletProvider walletProvider) async {
|
||||
final l10n = L10n.of(context)!;
|
||||
try {
|
||||
await walletProvider.removeMint(mintUrl);
|
||||
if (mounted) {
|
||||
setState(() {});
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Mint eliminado'),
|
||||
SnackBar(
|
||||
content: Text(l10n.mintDeleted),
|
||||
backgroundColor: AppColors.success,
|
||||
),
|
||||
);
|
||||
@@ -371,7 +374,7 @@ class _MintsScreenState extends State<MintsScreen> {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Error: $e'),
|
||||
content: Text('${l10n.error}: $e'),
|
||||
backgroundColor: AppColors.error,
|
||||
),
|
||||
);
|
||||
@@ -380,24 +383,26 @@ class _MintsScreenState extends State<MintsScreen> {
|
||||
}
|
||||
|
||||
Widget _buildAddMintButton(WalletProvider walletProvider) {
|
||||
final l10n = L10n.of(context)!;
|
||||
return PrimaryButton(
|
||||
text: 'Agregar mint',
|
||||
text: l10n.addMint,
|
||||
icon: LucideIcons.plus,
|
||||
onPressed: () => _showAddMintDialog(walletProvider),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _setActiveMint(String mintUrl, WalletProvider walletProvider) async {
|
||||
final l10n = L10n.of(context)!;
|
||||
try {
|
||||
await walletProvider.setActiveMint(mintUrl);
|
||||
|
||||
if (mounted) {
|
||||
setState(() {}); // Rebuild para actualizar UI
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Mint activo actualizado'),
|
||||
SnackBar(
|
||||
content: Text(l10n.activeMintUpdated),
|
||||
backgroundColor: AppColors.success,
|
||||
duration: Duration(seconds: 2),
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -405,7 +410,7 @@ class _MintsScreenState extends State<MintsScreen> {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Error: $e'),
|
||||
content: Text('${l10n.error}: $e'),
|
||||
backgroundColor: AppColors.error,
|
||||
duration: const Duration(seconds: 3),
|
||||
),
|
||||
@@ -456,12 +461,13 @@ class _AddMintDialogState extends State<_AddMintDialog> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = L10n.of(context)!;
|
||||
return AlertDialog(
|
||||
backgroundColor: AppColors.deepVoidPurple,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)),
|
||||
title: const Text(
|
||||
'Agregar mint',
|
||||
style: TextStyle(
|
||||
title: Text(
|
||||
l10n.addMint,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
@@ -472,9 +478,9 @@ class _AddMintDialogState extends State<_AddMintDialog> {
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const Text(
|
||||
'URL del mint:',
|
||||
style: TextStyle(
|
||||
Text(
|
||||
l10n.mintUrl,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 14,
|
||||
color: AppColors.textSecondary,
|
||||
@@ -491,7 +497,7 @@ class _AddMintDialogState extends State<_AddMintDialog> {
|
||||
color: Colors.white,
|
||||
),
|
||||
decoration: InputDecoration(
|
||||
hintText: 'https://mint.example.com',
|
||||
hintText: l10n.mintUrlPlaceholder,
|
||||
hintStyle: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 16,
|
||||
@@ -527,7 +533,7 @@ class _AddMintDialogState extends State<_AddMintDialog> {
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Text(
|
||||
'Conectando al mint...',
|
||||
l10n.connectingToMint,
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 14,
|
||||
@@ -543,7 +549,7 @@ class _AddMintDialogState extends State<_AddMintDialog> {
|
||||
TextButton(
|
||||
onPressed: _isAdding ? null : () => Navigator.pop(context),
|
||||
child: Text(
|
||||
'Cancelar',
|
||||
l10n.cancel,
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 16,
|
||||
@@ -563,9 +569,9 @@ class _AddMintDialogState extends State<_AddMintDialog> {
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
child: const Text(
|
||||
'Agregar',
|
||||
style: TextStyle(
|
||||
child: Text(
|
||||
l10n.add,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
@@ -577,6 +583,7 @@ class _AddMintDialogState extends State<_AddMintDialog> {
|
||||
}
|
||||
|
||||
void _validateUrl(String value) {
|
||||
final l10n = L10n.of(context)!;
|
||||
setState(() {
|
||||
_errorMessage = null;
|
||||
});
|
||||
@@ -595,7 +602,7 @@ class _AddMintDialogState extends State<_AddMintDialog> {
|
||||
if (!isValidBasic) {
|
||||
setState(() {
|
||||
_isValid = false;
|
||||
_errorMessage = 'La URL debe comenzar con https://';
|
||||
_errorMessage = l10n.urlMustStartWithHttps;
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -606,6 +613,7 @@ class _AddMintDialogState extends State<_AddMintDialog> {
|
||||
}
|
||||
|
||||
Future<void> _addMint() async {
|
||||
final l10n = L10n.of(context)!;
|
||||
final url = _controller.text.trim();
|
||||
if (url.isEmpty) return;
|
||||
|
||||
@@ -615,7 +623,7 @@ class _AddMintDialogState extends State<_AddMintDialog> {
|
||||
});
|
||||
|
||||
try {
|
||||
// Intentar agregar el mint (cdk_flutter validará la conexión)
|
||||
// Intentar agregar el mint (el bridge Rust validará la conexión)
|
||||
await widget.walletProvider.addMint(url);
|
||||
|
||||
if (mounted) {
|
||||
@@ -623,10 +631,10 @@ class _AddMintDialogState extends State<_AddMintDialog> {
|
||||
widget.onSuccess();
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Mint agregado correctamente'),
|
||||
SnackBar(
|
||||
content: Text(l10n.mintAddedSuccessfully),
|
||||
backgroundColor: AppColors.success,
|
||||
duration: Duration(seconds: 2),
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -634,7 +642,7 @@ class _AddMintDialogState extends State<_AddMintDialog> {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isAdding = false;
|
||||
_errorMessage = 'No se pudo conectar al mint';
|
||||
_errorMessage = l10n.couldNotConnectToMint;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +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:elcaju/l10n/app_localizations.dart';
|
||||
import '../../core/constants/colors.dart';
|
||||
import '../../core/constants/dimensions.dart';
|
||||
import '../../core/utils/formatters.dart';
|
||||
@@ -11,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 {
|
||||
@@ -21,8 +25,28 @@ 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)!;
|
||||
return GradientBackground(
|
||||
child: Scaffold(
|
||||
backgroundColor: Colors.transparent,
|
||||
@@ -33,9 +57,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
icon: const Icon(LucideIcons.arrowLeft, color: Colors.white),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
title: const Text(
|
||||
'Configuración',
|
||||
style: TextStyle(
|
||||
title: Text(
|
||||
l10n.settings,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.white,
|
||||
@@ -51,18 +75,18 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// Sección WALLET
|
||||
_buildSectionHeader('WALLET'),
|
||||
_buildSectionHeader(l10n.walletSection),
|
||||
const SizedBox(height: AppDimensions.paddingSmall),
|
||||
_buildSettingTile(
|
||||
icon: LucideIcons.key,
|
||||
title: 'Backup seed phrase',
|
||||
subtitle: 'Ver tus palabras de recuperación',
|
||||
title: l10n.backupSeedPhrase,
|
||||
subtitle: l10n.viewRecoveryWords,
|
||||
onTap: () => _showBackupSeed(context, settingsProvider),
|
||||
),
|
||||
_buildSettingTile(
|
||||
icon: LucideIcons.landmark,
|
||||
title: 'Mints conectados',
|
||||
subtitle: 'Gestionar tus mints Cashu',
|
||||
title: l10n.connectedMints,
|
||||
subtitle: l10n.manageCashuMints,
|
||||
onTap: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
@@ -74,10 +98,10 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
),
|
||||
_buildSettingTile(
|
||||
icon: LucideIcons.lock,
|
||||
title: 'PIN de acceso',
|
||||
title: l10n.pinAccess,
|
||||
subtitle: settingsProvider.pinEnabled
|
||||
? 'Activado'
|
||||
: 'Proteger la app con PIN',
|
||||
? l10n.pinEnabled
|
||||
: l10n.protectWithPin,
|
||||
trailing: Switch(
|
||||
value: settingsProvider.pinEnabled,
|
||||
onChanged: (value) =>
|
||||
@@ -87,36 +111,44 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
),
|
||||
_buildSettingTile(
|
||||
icon: LucideIcons.refreshCw,
|
||||
title: 'Recuperar tokens',
|
||||
subtitle: 'Escanear mints con seed phrase',
|
||||
title: l10n.recoverTokens,
|
||||
subtitle: l10n.scanMintsWithSeed,
|
||||
onTap: () => _showRecoverTokensDialog(context, settingsProvider),
|
||||
),
|
||||
_buildP2PKTile(l10n),
|
||||
|
||||
const SizedBox(height: AppDimensions.paddingLarge),
|
||||
|
||||
// Sección APARIENCIA
|
||||
_buildSectionHeader('APARIENCIA'),
|
||||
// Sección IDIOMA
|
||||
_buildSectionHeader(l10n.appearanceSection),
|
||||
const SizedBox(height: AppDimensions.paddingSmall),
|
||||
_buildSettingTile(
|
||||
icon: LucideIcons.globe,
|
||||
title: 'Idioma',
|
||||
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),
|
||||
|
||||
// Sección INFORMACIÓN
|
||||
_buildSectionHeader('INFORMACIÓN'),
|
||||
_buildSectionHeader(l10n.informationSection),
|
||||
const SizedBox(height: AppDimensions.paddingSmall),
|
||||
_buildInfoTile(
|
||||
icon: LucideIcons.tag,
|
||||
title: 'Versión',
|
||||
subtitle: '0.0.1',
|
||||
title: l10n.version,
|
||||
subtitle: _appVersion,
|
||||
),
|
||||
_buildSettingTile(
|
||||
icon: LucideIcons.info,
|
||||
title: 'Acerca de',
|
||||
title: l10n.about,
|
||||
onTap: () => _showAboutDialog(context),
|
||||
),
|
||||
_buildSettingTile(
|
||||
@@ -215,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,
|
||||
@@ -267,6 +393,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
}
|
||||
|
||||
Widget _buildDangerButton(BuildContext context, SettingsProvider settingsProvider) {
|
||||
final l10n = L10n.of(context)!;
|
||||
return GestureDetector(
|
||||
onTap: () => _showDeleteWalletDialog(context, settingsProvider),
|
||||
child: Container(
|
||||
@@ -284,9 +411,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
children: [
|
||||
Icon(LucideIcons.trash2, color: AppColors.error, size: 20),
|
||||
const SizedBox(width: 8),
|
||||
const Text(
|
||||
'Borrar wallet',
|
||||
style: TextStyle(
|
||||
Text(
|
||||
l10n.deleteWallet,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
@@ -304,13 +431,32 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
// ============================================================
|
||||
|
||||
String _getLanguageName(String locale) {
|
||||
final l10n = L10n.of(context)!;
|
||||
switch (locale) {
|
||||
case 'es':
|
||||
return 'Español';
|
||||
return l10n.spanish;
|
||||
case 'en':
|
||||
return 'English';
|
||||
return l10n.english;
|
||||
case 'pt':
|
||||
return l10n.portuguese;
|
||||
case 'fr':
|
||||
return l10n.french;
|
||||
case 'ru':
|
||||
return l10n.russian;
|
||||
case 'de':
|
||||
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 'Español';
|
||||
return l10n.spanish;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -328,8 +474,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
if (mnemonic == null) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('No se encontró el mnemonic'),
|
||||
SnackBar(
|
||||
content: Text(L10n.of(context)!.mnemonicNotFound),
|
||||
backgroundColor: AppColors.error,
|
||||
),
|
||||
);
|
||||
@@ -361,8 +507,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
await settingsProvider.setPin(pin);
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('PIN activado'),
|
||||
SnackBar(
|
||||
content: Text(L10n.of(context)!.pinActivated),
|
||||
backgroundColor: AppColors.success,
|
||||
),
|
||||
);
|
||||
@@ -375,8 +521,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
await settingsProvider.removePin();
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('PIN desactivado'),
|
||||
SnackBar(
|
||||
content: Text(L10n.of(context)!.pinDeactivated),
|
||||
backgroundColor: AppColors.success,
|
||||
),
|
||||
);
|
||||
@@ -386,6 +532,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
}
|
||||
|
||||
Future<String?> _showCreatePinDialog(BuildContext context) async {
|
||||
final l10n = L10n.of(context)!;
|
||||
String? firstPin;
|
||||
|
||||
// Primer ingreso
|
||||
@@ -393,8 +540,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (context) => _PinDialog(
|
||||
title: 'Crear PIN',
|
||||
subtitle: 'Ingresa un PIN de 4 dígitos',
|
||||
title: l10n.createPin,
|
||||
subtitle: l10n.enterPinDigits,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -405,8 +552,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (context) => _PinDialog(
|
||||
title: 'Confirmar PIN',
|
||||
subtitle: 'Ingresa el PIN nuevamente',
|
||||
title: l10n.confirmPin,
|
||||
subtitle: l10n.enterPinAgain,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -415,8 +562,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
if (firstPin != confirmPin) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Los PIN no coinciden'),
|
||||
SnackBar(
|
||||
content: Text(l10n.pinMismatch),
|
||||
backgroundColor: AppColors.error,
|
||||
),
|
||||
);
|
||||
@@ -429,12 +576,13 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
|
||||
Future<bool> _verifyPinDialog(
|
||||
BuildContext context, SettingsProvider settingsProvider) async {
|
||||
final l10n = L10n.of(context)!;
|
||||
final pin = await showDialog<String>(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (context) => _PinDialog(
|
||||
title: 'Verificar PIN',
|
||||
subtitle: 'Ingresa tu PIN actual',
|
||||
title: l10n.verifyPin,
|
||||
subtitle: l10n.enterCurrentPin,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -445,8 +593,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
} else {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('PIN incorrecto'),
|
||||
SnackBar(
|
||||
content: Text(l10n.incorrectPin),
|
||||
backgroundColor: AppColors.error,
|
||||
),
|
||||
);
|
||||
@@ -455,131 +603,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
/// 3. Selector de idioma
|
||||
void _showLanguageSelector(
|
||||
BuildContext context, SettingsProvider 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),
|
||||
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),
|
||||
),
|
||||
),
|
||||
const Text(
|
||||
'Seleccionar idioma',
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppDimensions.paddingMedium),
|
||||
_buildLanguageOption(
|
||||
context,
|
||||
settingsProvider,
|
||||
'es',
|
||||
'Español',
|
||||
'🇪🇸',
|
||||
),
|
||||
_buildLanguageOption(
|
||||
context,
|
||||
settingsProvider,
|
||||
'en',
|
||||
'English',
|
||||
'🇺🇸',
|
||||
),
|
||||
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('Idioma cambiado a $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,
|
||||
@@ -616,7 +640,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'v0.0.1',
|
||||
'v$_appVersion',
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 14,
|
||||
@@ -625,7 +649,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Tu wallet de ecash privado',
|
||||
L10n.of(context)!.aboutTagline,
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 16,
|
||||
@@ -634,51 +658,15 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Cashu wallet con identidad cubana, hermana de La Chispa.',
|
||||
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: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text(
|
||||
'Cerrar',
|
||||
style: TextStyle(
|
||||
child: Text(
|
||||
L10n.of(context)!.close,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
@@ -689,16 +677,65 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
/// 5. Abrir GitHub
|
||||
Future<void> _openGitHub() async {
|
||||
final url = Uri.parse('https://github.com/Forte11Cuba/elcaju');
|
||||
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 (e) {
|
||||
} catch (_) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('No se pudo abrir el enlace'),
|
||||
SnackBar(
|
||||
content: Text(L10n.of(context)!.couldNotOpenLink),
|
||||
backgroundColor: AppColors.error,
|
||||
),
|
||||
);
|
||||
@@ -706,7 +743,24 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
/// 6. Recuperar tokens
|
||||
/// 4. Abrir GitHub
|
||||
Future<void> _openGitHub() async {
|
||||
final url = Uri.parse('https://github.com/Forte11Cuba/elcaju');
|
||||
try {
|
||||
await launchUrl(url, mode: LaunchMode.externalApplication);
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(L10n.of(context)!.couldNotOpenLink),
|
||||
backgroundColor: AppColors.error,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 5. Recuperar tokens
|
||||
void _showRecoverTokensDialog(
|
||||
BuildContext context, SettingsProvider settingsProvider) {
|
||||
showModalBottomSheet(
|
||||
@@ -719,7 +773,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
/// 7. Borrar wallet
|
||||
/// 6. Borrar wallet
|
||||
void _showDeleteWalletDialog(
|
||||
BuildContext context, SettingsProvider settingsProvider) {
|
||||
showModalBottomSheet(
|
||||
@@ -757,7 +811,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Error al borrar: $e'),
|
||||
content: Text(L10n.of(context)!.deleteError(e.toString())),
|
||||
backgroundColor: AppColors.error,
|
||||
),
|
||||
);
|
||||
@@ -982,6 +1036,8 @@ class _DeleteWalletModalState extends State<_DeleteWalletModal> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = L10n.of(context)!;
|
||||
final confirmWord = l10n.deleteConfirmWord;
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(
|
||||
bottom: MediaQuery.of(context).viewInsets.bottom,
|
||||
@@ -1027,9 +1083,9 @@ class _DeleteWalletModalState extends State<_DeleteWalletModal> {
|
||||
const SizedBox(height: AppDimensions.paddingMedium),
|
||||
|
||||
// Título
|
||||
const Text(
|
||||
'¿Borrar wallet?',
|
||||
style: TextStyle(
|
||||
Text(
|
||||
l10n.deleteWalletQuestion,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
@@ -1056,10 +1112,10 @@ class _DeleteWalletModalState extends State<_DeleteWalletModal> {
|
||||
size: 16,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
const Expanded(
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Esta acción es irreversible',
|
||||
style: TextStyle(
|
||||
l10n.actionIrreversible,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
@@ -1071,7 +1127,7 @@ class _DeleteWalletModalState extends State<_DeleteWalletModal> {
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Se eliminarán todos los datos incluyendo tu seed phrase y tokens. Asegúrate de tener un backup.',
|
||||
l10n.deleteWalletWarning,
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 12,
|
||||
@@ -1091,7 +1147,7 @@ class _DeleteWalletModalState extends State<_DeleteWalletModal> {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Escribe "BORRAR" para confirmar:',
|
||||
l10n.typeDeleteToConfirm,
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 14,
|
||||
@@ -1107,7 +1163,7 @@ class _DeleteWalletModalState extends State<_DeleteWalletModal> {
|
||||
color: Colors.white,
|
||||
),
|
||||
decoration: InputDecoration(
|
||||
hintText: 'BORRAR',
|
||||
hintText: confirmWord,
|
||||
hintStyle: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 16,
|
||||
@@ -1122,7 +1178,7 @@ class _DeleteWalletModalState extends State<_DeleteWalletModal> {
|
||||
),
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_canDelete = value == 'BORRAR';
|
||||
_canDelete = value == confirmWord;
|
||||
});
|
||||
},
|
||||
),
|
||||
@@ -1144,10 +1200,10 @@ class _DeleteWalletModalState extends State<_DeleteWalletModal> {
|
||||
color: Colors.white.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: const Center(
|
||||
child: Center(
|
||||
child: Text(
|
||||
'Cancelar',
|
||||
style: TextStyle(
|
||||
l10n.cancel,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
@@ -1172,7 +1228,7 @@ class _DeleteWalletModalState extends State<_DeleteWalletModal> {
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
'Borrar wallet',
|
||||
l10n.deleteWallet,
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 16,
|
||||
@@ -1293,9 +1349,9 @@ class _RecoverTokensModalState extends State<_RecoverTokensModal> {
|
||||
const SizedBox(height: AppDimensions.paddingMedium),
|
||||
|
||||
// Título
|
||||
const Text(
|
||||
'Recuperar tokens',
|
||||
style: TextStyle(
|
||||
Text(
|
||||
L10n.of(context)!.recoverTokensTitle,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
@@ -1306,7 +1362,7 @@ class _RecoverTokensModalState extends State<_RecoverTokensModal> {
|
||||
|
||||
// Descripción
|
||||
Text(
|
||||
'Escanea los mints para recuperar tokens asociados a tu seed phrase (NUT-13)',
|
||||
L10n.of(context)!.recoverTokensDescription,
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 14,
|
||||
@@ -1345,6 +1401,7 @@ class _RecoverTokensModalState extends State<_RecoverTokensModal> {
|
||||
}
|
||||
|
||||
Widget _buildMnemonicOptions() {
|
||||
final l10n = L10n.of(context)!;
|
||||
return Column(
|
||||
children: [
|
||||
// Opción: Usar mnemonic actual
|
||||
@@ -1379,9 +1436,9 @@ class _RecoverTokensModalState extends State<_RecoverTokensModal> {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'Usar mi seed phrase actual',
|
||||
style: TextStyle(
|
||||
Text(
|
||||
l10n.useCurrentSeedPhrase,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500,
|
||||
@@ -1389,7 +1446,7 @@ class _RecoverTokensModalState extends State<_RecoverTokensModal> {
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'Escanear mints con las 12 palabras guardadas',
|
||||
l10n.scanWithSavedWords,
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 12,
|
||||
@@ -1438,9 +1495,9 @@ class _RecoverTokensModalState extends State<_RecoverTokensModal> {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'Usar otra seed phrase',
|
||||
style: TextStyle(
|
||||
Text(
|
||||
l10n.useOtherSeedPhrase,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500,
|
||||
@@ -1448,7 +1505,7 @@ class _RecoverTokensModalState extends State<_RecoverTokensModal> {
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'Recuperar tokens de otras 12 palabras',
|
||||
l10n.recoverFromOtherWords,
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 12,
|
||||
@@ -1467,6 +1524,7 @@ class _RecoverTokensModalState extends State<_RecoverTokensModal> {
|
||||
}
|
||||
|
||||
Widget _buildMintSelector() {
|
||||
final l10n = L10n.of(context)!;
|
||||
if (_isLoadingMints) {
|
||||
return const Padding(
|
||||
padding: EdgeInsets.all(16),
|
||||
@@ -1487,7 +1545,7 @@ class _RecoverTokensModalState extends State<_RecoverTokensModal> {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Mints a escanear:',
|
||||
l10n.mintsToScan,
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 14,
|
||||
@@ -1524,7 +1582,7 @@ class _RecoverTokensModalState extends State<_RecoverTokensModal> {
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Text(
|
||||
'Todos los mints (${_availableMints.length})',
|
||||
l10n.allMints(_availableMints.length),
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 14,
|
||||
@@ -1565,9 +1623,9 @@ class _RecoverTokensModalState extends State<_RecoverTokensModal> {
|
||||
size: 18,
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
const Text(
|
||||
'Un mint específico',
|
||||
style: TextStyle(
|
||||
Text(
|
||||
l10n.specificMint,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 14,
|
||||
color: Colors.white,
|
||||
@@ -1639,7 +1697,7 @@ class _RecoverTokensModalState extends State<_RecoverTokensModal> {
|
||||
color: Colors.white,
|
||||
),
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Ingresa las 12 palabras separadas por espacios...',
|
||||
hintText: L10n.of(context)!.enterMnemonicWords,
|
||||
hintStyle: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 14,
|
||||
@@ -1721,9 +1779,9 @@ class _RecoverTokensModalState extends State<_RecoverTokensModal> {
|
||||
strokeWidth: 2,
|
||||
),
|
||||
)
|
||||
: const Text(
|
||||
'Escanear mints',
|
||||
style: TextStyle(
|
||||
: Text(
|
||||
L10n.of(context)!.scanMints,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
@@ -1738,6 +1796,7 @@ class _RecoverTokensModalState extends State<_RecoverTokensModal> {
|
||||
|
||||
Future<void> _startRecover() async {
|
||||
if (!mounted) return;
|
||||
final l10n = L10n.of(context)!;
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
_result = null;
|
||||
@@ -1758,7 +1817,6 @@ class _RecoverTokensModalState extends State<_RecoverTokensModal> {
|
||||
final recoveredDetails = <String>[];
|
||||
|
||||
for (final mintEntry in results.entries) {
|
||||
final mintUrl = mintEntry.key;
|
||||
final unitBalances = mintEntry.value;
|
||||
bool hasError = false;
|
||||
BigInt mintTotal = BigInt.zero;
|
||||
@@ -1788,12 +1846,12 @@ class _RecoverTokensModalState extends State<_RecoverTokensModal> {
|
||||
setState(() {
|
||||
_isSuccess = true;
|
||||
if (recoveredDetails.isNotEmpty) {
|
||||
_result = '¡Recuperados ${recoveredDetails.join(", ")} de $mintsScanned mint(s)!';
|
||||
_result = l10n.recoveredTokens(recoveredDetails.join(", "), mintsScanned);
|
||||
} else {
|
||||
_result = 'Escaneo completado. No se encontraron tokens nuevos.';
|
||||
_result = l10n.scanCompleteNoTokens;
|
||||
}
|
||||
if (mintsWithError > 0) {
|
||||
_result = '$_result ($mintsWithError mint(s) con error)';
|
||||
_result = '$_result ${l10n.mintsWithError(mintsWithError)}';
|
||||
}
|
||||
});
|
||||
} else {
|
||||
@@ -1802,7 +1860,7 @@ class _RecoverTokensModalState extends State<_RecoverTokensModal> {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_isSuccess = false;
|
||||
_result = 'Selecciona un mint para escanear';
|
||||
_result = l10n.selectMintToScan;
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -1825,9 +1883,9 @@ class _RecoverTokensModalState extends State<_RecoverTokensModal> {
|
||||
setState(() {
|
||||
_isSuccess = true;
|
||||
if (recoveredDetails.isNotEmpty) {
|
||||
_result = '¡Recuperados ${recoveredDetails.join(", ")} de $mintHost!';
|
||||
_result = l10n.recoveredFromMint(recoveredDetails.join(", "), mintHost);
|
||||
} else {
|
||||
_result = 'No se encontraron tokens en $mintHost.';
|
||||
_result = l10n.noTokensFoundInMint(mintHost);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1840,7 +1898,7 @@ class _RecoverTokensModalState extends State<_RecoverTokensModal> {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_isSuccess = false;
|
||||
_result = 'El mnemonic debe tener 12 o 24 palabras';
|
||||
_result = l10n.mnemonicMustHaveWords;
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -1852,7 +1910,7 @@ class _RecoverTokensModalState extends State<_RecoverTokensModal> {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_isSuccess = false;
|
||||
_result = 'No hay mints conectados para escanear';
|
||||
_result = l10n.noConnectedMintsToScan;
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -1870,9 +1928,9 @@ class _RecoverTokensModalState extends State<_RecoverTokensModal> {
|
||||
final activeUnit = walletProvider.activeUnit;
|
||||
final formatted = UnitFormatter.formatBalance(recovered, activeUnit);
|
||||
final label = UnitFormatter.getUnitLabel(activeUnit);
|
||||
_result = '¡Recuperados y transferidos $formatted $label a tu wallet!';
|
||||
_result = l10n.recoveredAndTransferred(formatted, label);
|
||||
} else {
|
||||
_result = 'No se encontraron tokens asociados a ese mnemonic.';
|
||||
_result = l10n.noTokensForMnemonic;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1880,7 +1938,7 @@ class _RecoverTokensModalState extends State<_RecoverTokensModal> {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_isSuccess = false;
|
||||
_result = 'Error: $e';
|
||||
_result = '${l10n.error}: $e';
|
||||
});
|
||||
} finally {
|
||||
if (mounted) {
|
||||
|
||||
@@ -4,19 +4,25 @@ import 'package:flutter/services.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:lucide_icons/lucide_icons.dart';
|
||||
import 'package:qr_flutter/qr_flutter.dart';
|
||||
import 'package:cdk_flutter/cdk_flutter.dart' as cdk;
|
||||
import 'package:cdk_flutter/cdk_flutter.dart' show Transaction, TransactionDirection, TransactionStatus;
|
||||
import 'package:intl/intl.dart';
|
||||
import '../../src/rust/api/token.dart' as cdk;
|
||||
import '../../src/rust/api/wallet.dart' show Transaction, TransactionDirection, TransactionStatus;
|
||||
import 'package:elcaju/l10n/app_localizations.dart';
|
||||
import '../../core/constants/colors.dart';
|
||||
import '../../core/constants/dimensions.dart';
|
||||
import '../../core/utils/formatters.dart';
|
||||
import '../../data/transaction_meta_storage.dart';
|
||||
import '../../data/pending_token.dart';
|
||||
import '../../providers/wallet_provider.dart';
|
||||
import '../../providers/p2pk_provider.dart';
|
||||
import '../../core/utils/p2pk_utils.dart';
|
||||
import '../../widgets/common/gradient_background.dart';
|
||||
|
||||
/// Filtros disponibles para el historial
|
||||
enum HistoryFilter {
|
||||
all,
|
||||
pending,
|
||||
toReceive, // Tokens pendientes de reclamar (Receive Later)
|
||||
cashu,
|
||||
lightning,
|
||||
}
|
||||
@@ -46,9 +52,9 @@ class _HistoryScreenState extends State<HistoryScreen> {
|
||||
icon: const Icon(LucideIcons.arrowLeft, color: Colors.white),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
title: const Text(
|
||||
'Historial',
|
||||
style: TextStyle(
|
||||
title: Text(
|
||||
L10n.of(context)!.history,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w600,
|
||||
@@ -73,22 +79,28 @@ class _HistoryScreenState extends State<HistoryScreen> {
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
// Filtros
|
||||
_buildFilters(),
|
||||
body: SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
// Filtros
|
||||
_buildFilters(),
|
||||
|
||||
// Lista de transacciones
|
||||
Expanded(
|
||||
child: _buildTransactionList(),
|
||||
),
|
||||
],
|
||||
// Lista de transacciones
|
||||
Expanded(
|
||||
child: _buildTransactionList(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildFilters() {
|
||||
final l10n = L10n.of(context)!;
|
||||
final walletProvider = context.watch<WalletProvider>();
|
||||
final pendingCount = walletProvider.pendingTokenCount;
|
||||
|
||||
return SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
@@ -98,28 +110,36 @@ class _HistoryScreenState extends State<HistoryScreen> {
|
||||
child: Row(
|
||||
children: [
|
||||
_FilterChip(
|
||||
label: 'Todos',
|
||||
label: l10n.filterAll,
|
||||
icon: LucideIcons.list,
|
||||
isSelected: _currentFilter == HistoryFilter.all,
|
||||
onTap: () => _setFilter(HistoryFilter.all),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_FilterChip(
|
||||
label: 'Pendientes',
|
||||
label: l10n.filterPending,
|
||||
icon: LucideIcons.clock,
|
||||
isSelected: _currentFilter == HistoryFilter.pending,
|
||||
onTap: () => _setFilter(HistoryFilter.pending),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_FilterChipWithBadge(
|
||||
label: l10n.filterToReceive,
|
||||
icon: LucideIcons.download,
|
||||
isSelected: _currentFilter == HistoryFilter.toReceive,
|
||||
badgeCount: pendingCount,
|
||||
onTap: () => _setFilter(HistoryFilter.toReceive),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_FilterChip(
|
||||
label: 'Ecash',
|
||||
label: l10n.filterEcash,
|
||||
icon: LucideIcons.coins,
|
||||
isSelected: _currentFilter == HistoryFilter.cashu,
|
||||
onTap: () => _setFilter(HistoryFilter.cashu),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_FilterChip(
|
||||
label: 'Lightning',
|
||||
label: l10n.filterLightning,
|
||||
icon: LucideIcons.zap,
|
||||
isSelected: _currentFilter == HistoryFilter.lightning,
|
||||
onTap: () => _setFilter(HistoryFilter.lightning),
|
||||
@@ -132,6 +152,11 @@ class _HistoryScreenState extends State<HistoryScreen> {
|
||||
Widget _buildTransactionList() {
|
||||
final walletProvider = context.watch<WalletProvider>();
|
||||
|
||||
// Si el filtro es "toReceive", mostrar pending tokens
|
||||
if (_currentFilter == HistoryFilter.toReceive) {
|
||||
return _buildPendingTokensList(walletProvider);
|
||||
}
|
||||
|
||||
return FutureBuilder<List<Transaction>>(
|
||||
future: walletProvider.getAllTransactions(),
|
||||
builder: (context, snapshot) {
|
||||
@@ -171,6 +196,78 @@ class _HistoryScreenState extends State<HistoryScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPendingTokensList(WalletProvider walletProvider) {
|
||||
final pendingTokens = walletProvider.listPendingTokens();
|
||||
|
||||
if (pendingTokens.isEmpty) {
|
||||
return _buildEmptyState();
|
||||
}
|
||||
|
||||
return RefreshIndicator(
|
||||
onRefresh: _refreshTransactions,
|
||||
color: AppColors.primaryAction,
|
||||
child: ListView.builder(
|
||||
padding: const EdgeInsets.all(AppDimensions.paddingMedium),
|
||||
itemCount: pendingTokens.length,
|
||||
itemBuilder: (context, index) {
|
||||
final token = pendingTokens[index];
|
||||
return _PendingTokenTile(
|
||||
token: token,
|
||||
walletProvider: walletProvider,
|
||||
onClaim: () => _claimPendingToken(token),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _claimPendingToken(PendingToken token) async {
|
||||
final l10n = L10n.of(context)!;
|
||||
final walletProvider = context.read<WalletProvider>();
|
||||
final p2pkProvider = context.read<P2PKProvider>();
|
||||
|
||||
try {
|
||||
// Detectar P2PK y obtener clave privada si es necesario
|
||||
String? p2pkKey;
|
||||
if (P2PKUtils.isP2PKLocked(token.encoded)) {
|
||||
p2pkKey = p2pkProvider.getPrivateKeyForToken(token.encoded);
|
||||
}
|
||||
|
||||
final amount = await walletProvider.claimPendingToken(token.id, p2pkPrivateKey: p2pkKey);
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(l10n.pendingTokenClaimedSuccess(
|
||||
UnitFormatter.formatBalance(amount, token.unit ?? 'sat'),
|
||||
token.unit ?? 'sat',
|
||||
)),
|
||||
backgroundColor: AppColors.success,
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
final errorStr = e.toString().toLowerCase();
|
||||
String message;
|
||||
Color bgColor = AppColors.error;
|
||||
if (errorStr.contains('already spent') || errorStr.contains('token already')) {
|
||||
message = l10n.tokenAlreadyClaimed;
|
||||
} else if (errorStr.contains('no connection')) {
|
||||
message = l10n.noConnectionTryLater;
|
||||
bgColor = AppColors.warning;
|
||||
} else {
|
||||
message = l10n.claimError(e.toString());
|
||||
}
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(message),
|
||||
backgroundColor: bgColor,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
List<Transaction> _applyFilter(List<Transaction> transactions, WalletProvider walletProvider) {
|
||||
switch (_currentFilter) {
|
||||
case HistoryFilter.all:
|
||||
@@ -181,6 +278,10 @@ class _HistoryScreenState extends State<HistoryScreen> {
|
||||
.where((tx) => tx.status == TransactionStatus.pending)
|
||||
.toList();
|
||||
|
||||
case HistoryFilter.toReceive:
|
||||
// Pending tokens se manejan por separado en _buildPendingTokensList
|
||||
return [];
|
||||
|
||||
case HistoryFilter.cashu:
|
||||
return transactions
|
||||
.where((tx) => walletProvider.getTransactionType(tx) == TransactionType.cashu)
|
||||
@@ -194,25 +295,30 @@ class _HistoryScreenState extends State<HistoryScreen> {
|
||||
}
|
||||
|
||||
Widget _buildEmptyState() {
|
||||
final l10n = L10n.of(context)!;
|
||||
String message;
|
||||
String submessage;
|
||||
|
||||
switch (_currentFilter) {
|
||||
case HistoryFilter.all:
|
||||
message = 'Sin transacciones aún';
|
||||
submessage = 'Recibe tokens Cashu para empezar';
|
||||
message = l10n.noTransactions;
|
||||
submessage = l10n.receiveTokensToStart;
|
||||
break;
|
||||
case HistoryFilter.pending:
|
||||
message = 'Sin transacciones pendientes';
|
||||
submessage = 'Todas tus transacciones están completadas';
|
||||
message = l10n.noPendingTransactions;
|
||||
submessage = l10n.allTransactionsCompleted;
|
||||
break;
|
||||
case HistoryFilter.toReceive:
|
||||
message = l10n.noPendingTokens;
|
||||
submessage = l10n.noPendingTokensHint;
|
||||
break;
|
||||
case HistoryFilter.cashu:
|
||||
message = 'Sin transacciones Ecash';
|
||||
submessage = 'Envía o recibe tokens Cashu';
|
||||
message = l10n.noEcashTransactions;
|
||||
submessage = l10n.sendOrReceiveTokens;
|
||||
break;
|
||||
case HistoryFilter.lightning:
|
||||
message = 'Sin transacciones Lightning';
|
||||
submessage = 'Deposita o retira via Lightning';
|
||||
message = l10n.noLightningTransactions;
|
||||
submessage = l10n.depositOrWithdrawLightning;
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -351,7 +457,7 @@ class _HistoryTransactionTile extends StatelessWidget {
|
||||
);
|
||||
|
||||
// Formatear fecha
|
||||
final dateStr = _formatDate(timestamp);
|
||||
final dateStr = _formatDate(timestamp, context);
|
||||
|
||||
// Estado (pending o settled)
|
||||
final isPending = transaction.status == TransactionStatus.pending;
|
||||
@@ -425,9 +531,9 @@ class _HistoryTransactionTile extends StatelessWidget {
|
||||
color: AppColors.warning.withValues(alpha: 0.2),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: const Text(
|
||||
'Pendiente',
|
||||
style: TextStyle(
|
||||
child: Text(
|
||||
L10n.of(context)!.pendingStatus,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.w500,
|
||||
@@ -449,7 +555,7 @@ class _HistoryTransactionTile extends StatelessWidget {
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
isIncoming ? 'Recibido' : 'Enviado',
|
||||
isIncoming ? L10n.of(context)!.receivedStatus : L10n.of(context)!.sentStatus,
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 12,
|
||||
@@ -543,20 +649,22 @@ class _HistoryTransactionTile extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
String _formatDate(DateTime date) {
|
||||
String _formatDate(DateTime date, BuildContext context) {
|
||||
final l10n = L10n.of(context)!;
|
||||
final now = DateTime.now();
|
||||
final diff = now.difference(date);
|
||||
|
||||
if (diff.inMinutes < 1) {
|
||||
return 'Ahora';
|
||||
return l10n.now;
|
||||
} else if (diff.inHours < 1) {
|
||||
return 'Hace ${diff.inMinutes} min';
|
||||
return l10n.agoMinutes(diff.inMinutes);
|
||||
} else if (diff.inDays < 1) {
|
||||
return 'Hace ${diff.inHours} h';
|
||||
return l10n.agoHours(diff.inHours);
|
||||
} else if (diff.inDays < 7) {
|
||||
return 'Hace ${diff.inDays} días';
|
||||
return l10n.agoDays(diff.inDays);
|
||||
} else {
|
||||
return '${date.day}/${date.month}/${date.year}';
|
||||
final locale = Localizations.localeOf(context).toString();
|
||||
return DateFormat.yMd(locale).format(date);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -592,8 +700,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() {
|
||||
@@ -605,6 +714,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;
|
||||
|
||||
@@ -614,6 +728,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();
|
||||
@@ -677,9 +811,10 @@ class _TransactionDetailScreenState extends State<_TransactionDetailScreen> {
|
||||
final isPending = widget.transaction.status == TransactionStatus.pending;
|
||||
|
||||
// Título del estado (como cashu.me)
|
||||
final l10n = L10n.of(context)!;
|
||||
final statusTitle = _isLightning
|
||||
? 'Lightning Invoice'
|
||||
: (_isIncoming ? 'Received Ecash' : 'Sent Ecash');
|
||||
? l10n.lightningInvoice
|
||||
: (_isIncoming ? l10n.receivedEcash : l10n.sentEcash);
|
||||
|
||||
return GradientBackground(
|
||||
child: Scaffold(
|
||||
@@ -738,7 +873,7 @@ class _TransactionDetailScreenState extends State<_TransactionDetailScreen> {
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'Pago Lightning Saliente',
|
||||
l10n.outgoingLightningPayment,
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 16,
|
||||
@@ -751,8 +886,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(
|
||||
@@ -768,7 +903,7 @@ class _TransactionDetailScreenState extends State<_TransactionDetailScreen> {
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
_isLightning ? 'Invoice no disponible' : 'Token no disponible',
|
||||
_isLightning ? l10n.invoiceNotAvailable : l10n.tokenNotAvailable,
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 14,
|
||||
@@ -878,7 +1013,7 @@ class _TransactionDetailScreenState extends State<_TransactionDetailScreen> {
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
'SPEED: $_speedLabel',
|
||||
'${L10n.of(context)!.speed} $_speedLabel',
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 13,
|
||||
@@ -898,6 +1033,7 @@ class _TransactionDetailScreenState extends State<_TransactionDetailScreen> {
|
||||
required String mintDisplay,
|
||||
required bool isPending,
|
||||
}) {
|
||||
final l10n = L10n.of(context)!;
|
||||
final fee = widget.transaction.fee;
|
||||
final feeFormatted = fee > BigInt.zero
|
||||
? UnitFormatter.formatBalance(fee, widget.transaction.unit)
|
||||
@@ -909,34 +1045,34 @@ class _TransactionDetailScreenState extends State<_TransactionDetailScreen> {
|
||||
if (feeFormatted != null)
|
||||
_MinimalDetailRow(
|
||||
icon: LucideIcons.arrowUpDown,
|
||||
label: 'Fee',
|
||||
label: l10n.fee,
|
||||
value: feeFormatted,
|
||||
),
|
||||
// Unit
|
||||
_MinimalDetailRow(
|
||||
icon: LucideIcons.coins,
|
||||
label: 'Unit',
|
||||
label: l10n.unit,
|
||||
value: widget.transaction.unit.toUpperCase(),
|
||||
),
|
||||
// Mint
|
||||
_MinimalDetailRow(
|
||||
icon: LucideIcons.landmark,
|
||||
label: 'Mint',
|
||||
label: l10n.mint,
|
||||
value: mintDisplay,
|
||||
),
|
||||
// Estado (si pendiente)
|
||||
if (isPending)
|
||||
_MinimalDetailRow(
|
||||
icon: LucideIcons.clock,
|
||||
label: 'Status',
|
||||
value: 'Pending',
|
||||
label: l10n.status,
|
||||
value: l10n.pending,
|
||||
valueColor: AppColors.warning,
|
||||
),
|
||||
// Memo (si existe)
|
||||
if (widget.transaction.memo != null && widget.transaction.memo!.isNotEmpty)
|
||||
_MinimalDetailRow(
|
||||
icon: LucideIcons.messageSquare,
|
||||
label: 'Memo',
|
||||
label: l10n.memo,
|
||||
value: widget.transaction.memo!,
|
||||
),
|
||||
],
|
||||
@@ -944,7 +1080,8 @@ class _TransactionDetailScreenState extends State<_TransactionDetailScreen> {
|
||||
}
|
||||
|
||||
Widget _buildCopyButton() {
|
||||
final label = _isLightning ? 'COPY INVOICE' : 'COPY';
|
||||
final l10n = L10n.of(context)!;
|
||||
final label = _isLightning ? l10n.copyInvoiceButton : l10n.copyButton;
|
||||
|
||||
return SizedBox(
|
||||
width: double.infinity,
|
||||
@@ -953,7 +1090,7 @@ class _TransactionDetailScreenState extends State<_TransactionDetailScreen> {
|
||||
Clipboard.setData(ClipboardData(text: _tokenOrInvoice!));
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(_isLightning ? 'Invoice copiado' : 'Token copiado'),
|
||||
content: Text(_isLightning ? l10n.invoiceCopied : l10n.tokenCopied),
|
||||
backgroundColor: AppColors.success,
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
@@ -982,6 +1119,327 @@ class _TransactionDetailScreenState extends State<_TransactionDetailScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Chip de filtro con badge
|
||||
class _FilterChipWithBadge extends StatelessWidget {
|
||||
final String label;
|
||||
final IconData icon;
|
||||
final bool isSelected;
|
||||
final int badgeCount;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _FilterChipWithBadge({
|
||||
required this.label,
|
||||
required this.icon,
|
||||
required this.isSelected,
|
||||
required this.badgeCount,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected
|
||||
? AppColors.warning.withValues(alpha: 0.2)
|
||||
: Colors.white.withValues(alpha: 0.05),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(
|
||||
color: isSelected
|
||||
? AppColors.warning.withValues(alpha: 0.5)
|
||||
: Colors.white.withValues(alpha: 0.1),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
icon,
|
||||
size: 16,
|
||||
color: isSelected ? AppColors.warning : Colors.white70,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 14,
|
||||
fontWeight: isSelected ? FontWeight.w600 : FontWeight.w500,
|
||||
color: isSelected ? AppColors.warning : Colors.white70,
|
||||
),
|
||||
),
|
||||
if (badgeCount > 0) ...[
|
||||
const SizedBox(width: 6),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.warning,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Text(
|
||||
badgeCount > 9 ? '9+' : badgeCount.toString(),
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.black,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Tile para un token pendiente de reclamar
|
||||
class _PendingTokenTile extends StatefulWidget {
|
||||
final PendingToken token;
|
||||
final WalletProvider walletProvider;
|
||||
final Future<void> Function() onClaim;
|
||||
|
||||
const _PendingTokenTile({
|
||||
required this.token,
|
||||
required this.walletProvider,
|
||||
required this.onClaim,
|
||||
});
|
||||
|
||||
@override
|
||||
State<_PendingTokenTile> createState() => _PendingTokenTileState();
|
||||
}
|
||||
|
||||
class _PendingTokenTileState extends State<_PendingTokenTile> {
|
||||
bool _isClaiming = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = L10n.of(context)!;
|
||||
final token = widget.token;
|
||||
final unit = token.unit ?? 'sat';
|
||||
final formattedAmount = UnitFormatter.formatBalance(token.amount, unit);
|
||||
final unitLabel = UnitFormatter.getUnitLabel(unit);
|
||||
final mintDisplay = UnitFormatter.getMintDisplayName(token.mintUrl);
|
||||
final daysRemaining = token.daysRemaining;
|
||||
|
||||
// Color según días restantes
|
||||
Color daysColor;
|
||||
if (daysRemaining <= 3) {
|
||||
daysColor = AppColors.error;
|
||||
} else if (daysRemaining <= 7) {
|
||||
daysColor = AppColors.warning;
|
||||
} else {
|
||||
daysColor = AppColors.success;
|
||||
}
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: AppDimensions.paddingSmall),
|
||||
padding: const EdgeInsets.all(AppDimensions.paddingMedium),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: 0.05),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: AppColors.warning.withValues(alpha: 0.3),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
// Primera fila: icono, info, monto
|
||||
Row(
|
||||
children: [
|
||||
// Icono con badge PENDING
|
||||
Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
Container(
|
||||
width: 44,
|
||||
height: 44,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.warning.withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: const Icon(
|
||||
LucideIcons.clock,
|
||||
color: AppColors.warning,
|
||||
size: 22,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(width: AppDimensions.paddingMedium),
|
||||
|
||||
// Info del token
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 6,
|
||||
vertical: 2,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.warning.withValues(alpha: 0.2),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Text(
|
||||
l10n.pendingBadge,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.warning,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
mintDisplay,
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 14,
|
||||
color: AppColors.textSecondary.withValues(alpha: 0.7),
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
LucideIcons.timer,
|
||||
size: 12,
|
||||
color: daysColor,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
l10n.expiresInDays(daysRemaining),
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 12,
|
||||
color: daysColor,
|
||||
),
|
||||
),
|
||||
if (token.hasError) ...[
|
||||
const SizedBox(width: 8),
|
||||
Icon(
|
||||
LucideIcons.alertTriangle,
|
||||
size: 12,
|
||||
color: AppColors.error,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
l10n.retryCount(token.retryCount),
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 12,
|
||||
color: AppColors.error,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Monto
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Text(
|
||||
formattedAmount,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
unitLabel,
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 12,
|
||||
color: AppColors.textSecondary.withValues(alpha: 0.6),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: AppDimensions.paddingMedium),
|
||||
|
||||
// Botón reclamar (ancho completo)
|
||||
GestureDetector(
|
||||
onTap: _isClaiming
|
||||
? null
|
||||
: () async {
|
||||
setState(() => _isClaiming = true);
|
||||
try {
|
||||
await widget.onClaim();
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() => _isClaiming = false);
|
||||
}
|
||||
}
|
||||
},
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
gradient: _isClaiming
|
||||
? null
|
||||
: const LinearGradient(colors: AppColors.buttonGradient),
|
||||
color: _isClaiming ? Colors.grey : null,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
if (_isClaiming)
|
||||
const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: Colors.white,
|
||||
),
|
||||
)
|
||||
else
|
||||
const Icon(
|
||||
LucideIcons.download,
|
||||
size: 16,
|
||||
color: Colors.white,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
_isClaiming ? l10n.claiming : l10n.claimNow,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Fila de detalle minimalista (estilo cashu.me)
|
||||
class _MinimalDetailRow extends StatelessWidget {
|
||||
final IconData icon;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
// 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`: `extract_creq_from_uri`, `parse_payment_request_inner`, `percent_decode`, `wait_for_nostr_payment_inner`
|
||||
// These function are ignored because they are on traits that is not defined in current crate (put an empty `#[frb]` on it to unignore): `clone`
|
||||
|
||||
// Rust type: RustOpaqueMoi<flutter_rust_bridge::for_generated::RustAutoOpaqueInner<CreatedPaymentRequest>>
|
||||
abstract class CreatedPaymentRequest implements RustOpaqueInterface {
|
||||
String get creqA;
|
||||
|
||||
String get creqB;
|
||||
|
||||
NostrListenerHandle get listenerHandle;
|
||||
|
||||
set creqA(String creqA);
|
||||
|
||||
set creqB(String creqB);
|
||||
|
||||
set listenerHandle(NostrListenerHandle listenerHandle);
|
||||
}
|
||||
|
||||
// Rust type: RustOpaqueMoi<flutter_rust_bridge::for_generated::RustAutoOpaqueInner<NostrListenerHandle>>
|
||||
abstract class NostrListenerHandle implements RustOpaqueInterface {
|
||||
/// Reconstruct a handle from persisted data (e.g. after app restart).
|
||||
static NostrListenerHandle fromPersisted({
|
||||
required PersistedRequestData data,
|
||||
}) => RustLib.instance.api
|
||||
.crateApiPaymentRequestNostrListenerHandleFromPersisted(data: data);
|
||||
|
||||
/// Export handle data for persistence (e.g. SharedPreferences).
|
||||
/// The secret key is exposed as hex — the caller is responsible
|
||||
/// for storing it securely.
|
||||
PersistedRequestData toPersisted();
|
||||
}
|
||||
|
||||
/// Parameters for creating a payment request.
|
||||
class CreateRequestParams {
|
||||
/// Amount to request (in smallest unit, e.g. sats)
|
||||
final BigInt? amount;
|
||||
|
||||
/// Currency unit ("sat", "usd", etc.)
|
||||
final String unit;
|
||||
|
||||
/// Human-readable description
|
||||
final String? description;
|
||||
|
||||
/// Nostr relay URLs for the transport
|
||||
final List<String> nostrRelays;
|
||||
|
||||
const CreateRequestParams({
|
||||
this.amount,
|
||||
required this.unit,
|
||||
this.description,
|
||||
required this.nostrRelays,
|
||||
});
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
amount.hashCode ^
|
||||
unit.hashCode ^
|
||||
description.hashCode ^
|
||||
nostrRelays.hashCode;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is CreateRequestParams &&
|
||||
runtimeType == other.runtimeType &&
|
||||
amount == other.amount &&
|
||||
unit == other.unit &&
|
||||
description == other.description &&
|
||||
nostrRelays == other.nostrRelays;
|
||||
}
|
||||
|
||||
/// Event emitted by the Nostr payment listener.
|
||||
class NostrPaymentEvent {
|
||||
final NostrPaymentState state;
|
||||
final BigInt? amount;
|
||||
final String? error;
|
||||
|
||||
const NostrPaymentEvent({required this.state, this.amount, this.error});
|
||||
|
||||
@override
|
||||
int get hashCode => state.hashCode ^ amount.hashCode ^ error.hashCode;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is NostrPaymentEvent &&
|
||||
runtimeType == other.runtimeType &&
|
||||
state == other.state &&
|
||||
amount == other.amount &&
|
||||
error == other.error;
|
||||
}
|
||||
|
||||
/// State of a Nostr payment listener.
|
||||
enum NostrPaymentState {
|
||||
/// Connected to relays, waiting for payment
|
||||
waiting,
|
||||
|
||||
/// Payment received and tokens claimed
|
||||
received,
|
||||
|
||||
/// Error occurred
|
||||
error,
|
||||
}
|
||||
|
||||
/// Parsed payment request info exposed to Flutter.
|
||||
/// CDK's FromStr auto-detects creqA (CBOR) vs creqB (Bech32m).
|
||||
class PaymentRequestInfo {
|
||||
/// Raw encoded string (creqA... or CREQB1...)
|
||||
final String raw;
|
||||
|
||||
/// Payment id
|
||||
final String? paymentId;
|
||||
|
||||
/// Requested amount (in base unit)
|
||||
final BigInt? amount;
|
||||
|
||||
/// Currency unit ("sat", "usd", etc.)
|
||||
final String? unit;
|
||||
|
||||
/// Whether this is a single-use request
|
||||
final bool? singleUse;
|
||||
|
||||
/// Accepted mints (empty = any mint)
|
||||
final List<String> mints;
|
||||
|
||||
/// Human-readable description
|
||||
final String? description;
|
||||
|
||||
/// Available transports
|
||||
final List<TransportInfo> transports;
|
||||
|
||||
/// Whether NUT-10 spending conditions are required
|
||||
final bool hasNut10;
|
||||
|
||||
const PaymentRequestInfo({
|
||||
required this.raw,
|
||||
this.paymentId,
|
||||
this.amount,
|
||||
this.unit,
|
||||
this.singleUse,
|
||||
required this.mints,
|
||||
this.description,
|
||||
required this.transports,
|
||||
required this.hasNut10,
|
||||
});
|
||||
|
||||
/// Parse a payment request string (creqA, CREQB1, or bitcoin:?creq=).
|
||||
/// Supports NUT-18 (CBOR+base64) and NUT-26 (Bech32m) automatically.
|
||||
static PaymentRequestInfo parse({required String encoded}) => RustLib
|
||||
.instance
|
||||
.api
|
||||
.crateApiPaymentRequestPaymentRequestInfoParse(encoded: encoded);
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
raw.hashCode ^
|
||||
paymentId.hashCode ^
|
||||
amount.hashCode ^
|
||||
unit.hashCode ^
|
||||
singleUse.hashCode ^
|
||||
mints.hashCode ^
|
||||
description.hashCode ^
|
||||
transports.hashCode ^
|
||||
hasNut10.hashCode;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is PaymentRequestInfo &&
|
||||
runtimeType == other.runtimeType &&
|
||||
raw == other.raw &&
|
||||
paymentId == other.paymentId &&
|
||||
amount == other.amount &&
|
||||
unit == other.unit &&
|
||||
singleUse == other.singleUse &&
|
||||
mints == other.mints &&
|
||||
description == other.description &&
|
||||
transports == other.transports &&
|
||||
hasNut10 == other.hasNut10;
|
||||
}
|
||||
|
||||
/// Serializable data for persisting a NostrListenerHandle across app restarts.
|
||||
class PersistedRequestData {
|
||||
final String secretHex;
|
||||
final String pubkeyHex;
|
||||
final List<String> relays;
|
||||
final BigInt? amount;
|
||||
final String unit;
|
||||
final String mintUrl;
|
||||
|
||||
const PersistedRequestData({
|
||||
required this.secretHex,
|
||||
required this.pubkeyHex,
|
||||
required this.relays,
|
||||
this.amount,
|
||||
required this.unit,
|
||||
required this.mintUrl,
|
||||
});
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
secretHex.hashCode ^
|
||||
pubkeyHex.hashCode ^
|
||||
relays.hashCode ^
|
||||
amount.hashCode ^
|
||||
unit.hashCode ^
|
||||
mintUrl.hashCode;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is PersistedRequestData &&
|
||||
runtimeType == other.runtimeType &&
|
||||
secretHex == other.secretHex &&
|
||||
pubkeyHex == other.pubkeyHex &&
|
||||
relays == other.relays &&
|
||||
amount == other.amount &&
|
||||
unit == other.unit &&
|
||||
mintUrl == other.mintUrl;
|
||||
}
|
||||
|
||||
/// Transport info exposed to Flutter
|
||||
class TransportInfo {
|
||||
/// "nostr" or "post"
|
||||
final String transportType;
|
||||
|
||||
/// Target (nprofile or URL)
|
||||
final String target;
|
||||
|
||||
const TransportInfo({required this.transportType, required this.target});
|
||||
|
||||
@override
|
||||
int get hashCode => transportType.hashCode ^ target.hashCode;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is TransportInfo &&
|
||||
runtimeType == other.runtimeType &&
|
||||
transportType == other.transportType &&
|
||||
target == other.target;
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
// 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`
|
||||
|
||||
/// Create a token from raw proof data without network access.
|
||||
/// Uses CDK's CdkToken::new to produce a valid V4 (cashuB) token.
|
||||
/// `proofs_json` is a JSON array of proofs: [{"id":"...","amount":1,"secret":"...","C":"..."}, ...]
|
||||
Token createOfflineToken({
|
||||
required String mintUrl,
|
||||
required String proofsJson,
|
||||
String? memo,
|
||||
String? unit,
|
||||
}) => RustLib.instance.api.crateApiTokenCreateOfflineToken(
|
||||
mintUrl: mintUrl,
|
||||
proofsJson: proofsJson,
|
||||
memo: memo,
|
||||
unit: unit,
|
||||
);
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,324 @@
|
||||
// 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 'payment_request.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();
|
||||
|
||||
/// Create a NUT-18 payment request with Nostr transport.
|
||||
///
|
||||
/// Builds a PaymentRequest with the wallet's mint URL and unit,
|
||||
/// generates ephemeral Nostr keys, and returns both creqA and creqB
|
||||
/// encodings plus the keys needed for the Nostr listener.
|
||||
Future<CreatedPaymentRequest> createPaymentRequest({
|
||||
required CreateRequestParams params,
|
||||
});
|
||||
|
||||
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,
|
||||
);
|
||||
|
||||
/// Pay a NUT-18 payment request.
|
||||
/// Uses CDK's pay_request() which handles:
|
||||
/// - NUT-10 spending conditions
|
||||
/// - Transport selection (Nostr preferred, HTTP POST fallback)
|
||||
/// - Token preparation and delivery
|
||||
Future<void> payPaymentRequest({
|
||||
required String encoded,
|
||||
BigInt? customAmount,
|
||||
});
|
||||
|
||||
Future<PreparedSend> prepareSend({required BigInt amount, SendOptions? opts});
|
||||
|
||||
Future<BigInt> receive({required Token token, ReceiveOptions? opts});
|
||||
|
||||
/// Check pending-spent proofs with the mint and revert unspent ones.
|
||||
/// Returns the number of proofs recovered.
|
||||
Future<BigInt> reclaimPendingProofs();
|
||||
|
||||
Future<void> recoverIncompleteSagas();
|
||||
|
||||
Future<void> restore();
|
||||
|
||||
Future<Token> send({
|
||||
required PreparedSend send,
|
||||
String? memo,
|
||||
bool? includeMemo,
|
||||
});
|
||||
|
||||
Stream<BigInt> streamBalance();
|
||||
|
||||
/// Wait for an incoming Nostr payment (NIP-17 gift-wrap).
|
||||
///
|
||||
/// Takes the opaque NostrListenerHandle returned by create_payment_request().
|
||||
/// The secret key never leaves Rust memory.
|
||||
Stream<NostrPaymentEvent> waitForNostrPayment({
|
||||
required NostrListenerHandle handle,
|
||||
});
|
||||
}
|
||||
|
||||
// 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
@@ -0,0 +1,177 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:lucide_icons/lucide_icons.dart';
|
||||
import '../../core/constants/colors.dart';
|
||||
|
||||
/// Callback cuando el valor cambia
|
||||
typedef NumpadCallback = void Function(String value);
|
||||
|
||||
/// Teclado numérico reutilizable
|
||||
class NumpadWidget extends StatelessWidget {
|
||||
/// Valor actual
|
||||
final String value;
|
||||
|
||||
/// Callback cuando el valor cambia
|
||||
final NumpadCallback onChanged;
|
||||
|
||||
/// Máximo de dígitos permitidos
|
||||
final int maxDigits;
|
||||
|
||||
/// Mostrar botón MAX (para enviar todo el balance)
|
||||
final bool showMaxButton;
|
||||
|
||||
/// Callback para botón MAX
|
||||
final VoidCallback? onMaxPressed;
|
||||
|
||||
const NumpadWidget({
|
||||
super.key,
|
||||
required this.value,
|
||||
required this.onChanged,
|
||||
this.maxDigits = 12,
|
||||
this.showMaxButton = false,
|
||||
this.onMaxPressed,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Detectar si es móvil
|
||||
final screenWidth = MediaQuery.of(context).size.width;
|
||||
final isMobile = screenWidth < 768;
|
||||
|
||||
final verticalSpacing = isMobile ? 8.0 : 12.0;
|
||||
final horizontalSpacing = isMobile ? 8.0 : 12.0;
|
||||
final containerPadding = isMobile ? 12.0 : 16.0;
|
||||
|
||||
return Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: containerPadding),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Fila 1: 1, 2, 3, del
|
||||
_buildRow(['1', '2', '3', 'del'], horizontalSpacing, isMobile),
|
||||
SizedBox(height: verticalSpacing),
|
||||
// Fila 2: 4, 5, 6, 00
|
||||
_buildRow(['4', '5', '6', '00'], horizontalSpacing, isMobile),
|
||||
SizedBox(height: verticalSpacing),
|
||||
// Fila 3: 7, 8, 9, 000
|
||||
_buildRow(['7', '8', '9', '000'], horizontalSpacing, isMobile),
|
||||
SizedBox(height: verticalSpacing),
|
||||
// Fila 4: MAX/vacío, 0, vacío, C
|
||||
_buildRow([showMaxButton ? 'MAX' : '', '0', '', 'C'], horizontalSpacing, isMobile),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildRow(List<String> keys, double spacing, bool isMobile) {
|
||||
final List<Widget> children = [];
|
||||
|
||||
for (int i = 0; i < keys.length; i++) {
|
||||
if (i > 0) {
|
||||
children.add(SizedBox(width: spacing));
|
||||
}
|
||||
|
||||
if (keys[i].isEmpty) {
|
||||
children.add(Expanded(child: Container()));
|
||||
} else {
|
||||
children.add(Expanded(
|
||||
child: _buildKey(keys[i], isMobile),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
return Row(children: children);
|
||||
}
|
||||
|
||||
Widget _buildKey(String key, bool isMobile) {
|
||||
final isDelete = key == 'del';
|
||||
final isClear = key == 'C';
|
||||
final isMax = key == 'MAX';
|
||||
final isZeroShortcut = key == '00' || key == '000';
|
||||
|
||||
final buttonHeight = isMobile ? 48.0 : 56.0;
|
||||
|
||||
Color bgColor;
|
||||
Color textColor;
|
||||
|
||||
if (isDelete || isClear) {
|
||||
bgColor = Colors.white.withValues(alpha: 0.08);
|
||||
textColor = isClear ? AppColors.error : AppColors.textSecondary;
|
||||
} else if (isZeroShortcut || isMax) {
|
||||
bgColor = AppColors.primaryAction.withValues(alpha: 0.15);
|
||||
textColor = AppColors.primaryAction;
|
||||
} else {
|
||||
bgColor = Colors.white.withValues(alpha: 0.05);
|
||||
textColor = Colors.white;
|
||||
}
|
||||
|
||||
return GestureDetector(
|
||||
onTap: () => _onKeyPress(key),
|
||||
child: Container(
|
||||
height: buttonHeight,
|
||||
decoration: BoxDecoration(
|
||||
color: bgColor,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Center(
|
||||
child: isDelete
|
||||
? Icon(
|
||||
LucideIcons.delete,
|
||||
color: textColor,
|
||||
size: isMobile ? 20 : 24,
|
||||
)
|
||||
: Text(
|
||||
key,
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: (isZeroShortcut || isMax)
|
||||
? (isMobile ? 16 : 18)
|
||||
: (isMobile ? 24 : 28),
|
||||
fontWeight: FontWeight.w500,
|
||||
color: textColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _onKeyPress(String key) {
|
||||
String newValue = value;
|
||||
|
||||
if (key == 'del') {
|
||||
if (newValue.isNotEmpty) {
|
||||
newValue = newValue.substring(0, newValue.length - 1);
|
||||
}
|
||||
} else if (key == 'C') {
|
||||
newValue = '';
|
||||
} else if (key == 'MAX') {
|
||||
onMaxPressed?.call();
|
||||
return;
|
||||
} else if (key == '00') {
|
||||
if (newValue.isNotEmpty && newValue != '0') {
|
||||
newValue += '00';
|
||||
} else if (newValue.isEmpty) {
|
||||
newValue = '0';
|
||||
}
|
||||
} else if (key == '000') {
|
||||
if (newValue.isNotEmpty && newValue != '0') {
|
||||
newValue += '000';
|
||||
} else if (newValue.isEmpty) {
|
||||
newValue = '0';
|
||||
}
|
||||
} else {
|
||||
if (newValue == '0' && key == '0') return;
|
||||
if (newValue == '0' && key != '0') {
|
||||
newValue = key;
|
||||
} else {
|
||||
newValue += key;
|
||||
}
|
||||
}
|
||||
|
||||
if (newValue.length > maxDigits) {
|
||||
newValue = newValue.substring(0, maxDigits);
|
||||
}
|
||||
|
||||
onChanged(newValue);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,451 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:mobile_scanner/mobile_scanner.dart';
|
||||
import 'package:lucide_icons/lucide_icons.dart';
|
||||
import 'package:elcaju/l10n/app_localizations.dart';
|
||||
import '../../src/rust/api/token.dart';
|
||||
import '../../core/constants/colors.dart';
|
||||
import '../../core/utils/incoming_data_parser.dart';
|
||||
|
||||
/// Widget reutilizable para escanear QR codes
|
||||
/// Soporta QR estáticos y animados (UR multipartes)
|
||||
class QrScannerWidget extends StatefulWidget {
|
||||
/// Callback cuando se detecta un código completo
|
||||
final void Function(String data) onDetect;
|
||||
|
||||
/// Callback para mostrar errores
|
||||
final void Function(String error)? onError;
|
||||
|
||||
/// Mostrar controles de flash (default: true)
|
||||
final bool showFlashControl;
|
||||
|
||||
/// Mostrar botón para cambiar cámara (default: false)
|
||||
final bool showCameraSwitch;
|
||||
|
||||
const QrScannerWidget({
|
||||
super.key,
|
||||
required this.onDetect,
|
||||
this.onError,
|
||||
this.showFlashControl = true,
|
||||
this.showCameraSwitch = false,
|
||||
});
|
||||
|
||||
@override
|
||||
State<QrScannerWidget> createState() => _QrScannerWidgetState();
|
||||
}
|
||||
|
||||
class _QrScannerWidgetState extends State<QrScannerWidget> {
|
||||
late MobileScannerController _controller;
|
||||
|
||||
// Estado para QR animados (UR multipartes) — TokenDecoder (Rust bridge)
|
||||
TokenDecoder? _urDecoder;
|
||||
bool _isCapturingUr = false;
|
||||
final Set<String> _urFragmentsSeen = {}; // Trackear fragmentos únicos
|
||||
|
||||
// Evitar procesar el mismo código múltiples veces
|
||||
String? _lastProcessedCode;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = MobileScannerController(
|
||||
detectionSpeed: DetectionSpeed.noDuplicates,
|
||||
facing: CameraFacing.back,
|
||||
torchEnabled: false,
|
||||
// Usar resolución más alta para QRs densos
|
||||
cameraResolution: const Size(1920, 1080),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onDetect(BarcodeCapture capture) {
|
||||
final List<Barcode> barcodes = capture.barcodes;
|
||||
if (barcodes.isEmpty) return;
|
||||
|
||||
final barcode = barcodes.first;
|
||||
final String? rawValue = barcode.rawValue;
|
||||
if (rawValue == null || rawValue.isEmpty) return;
|
||||
|
||||
// Evitar procesar el mismo código consecutivamente
|
||||
if (rawValue == _lastProcessedCode) return;
|
||||
_lastProcessedCode = rawValue;
|
||||
|
||||
// Verificar si es un fragmento UR (ur:bytes/, ur:cashu/, etc.)
|
||||
if (rawValue.toLowerCase().startsWith('ur:')) {
|
||||
_handleUrFragment(rawValue);
|
||||
} else {
|
||||
// QR simple - emitir directamente
|
||||
_resetUrState();
|
||||
widget.onDetect(rawValue);
|
||||
}
|
||||
}
|
||||
|
||||
void _handleUrFragment(String fragment) {
|
||||
// Inicializar decoder si es necesario
|
||||
if (_urDecoder == null) {
|
||||
_urDecoder = TokenDecoder();
|
||||
_isCapturingUr = true;
|
||||
_urFragmentsSeen.clear();
|
||||
}
|
||||
|
||||
// Ignorar fragmentos ya vistos
|
||||
if (_urFragmentsSeen.contains(fragment)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Pasar fragmento al decoder (usa fountain codes internamente)
|
||||
try {
|
||||
_urDecoder!.receive(input: fragment);
|
||||
_urFragmentsSeen.add(fragment);
|
||||
} catch (e) {
|
||||
// Fragmento inválido o incompatible, ignorar
|
||||
return;
|
||||
}
|
||||
|
||||
// Actualizar UI
|
||||
if (mounted) setState(() {});
|
||||
|
||||
// Verificar si está completo
|
||||
if (_urDecoder!.isComplete()) {
|
||||
try {
|
||||
final token = _urDecoder!.value();
|
||||
if (token != null) {
|
||||
final encodedToken = token.encoded;
|
||||
_resetUrState();
|
||||
widget.onDetect(encodedToken);
|
||||
} else {
|
||||
widget.onError?.call('Error: token vacío');
|
||||
_resetUrState();
|
||||
}
|
||||
} catch (e) {
|
||||
widget.onError?.call('Error decodificando UR: $e');
|
||||
_resetUrState();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _resetUrState() {
|
||||
_urDecoder = null;
|
||||
_isCapturingUr = false;
|
||||
_urFragmentsSeen.clear();
|
||||
_lastProcessedCode = null;
|
||||
}
|
||||
|
||||
void _toggleFlash() async {
|
||||
await _controller.toggleTorch();
|
||||
}
|
||||
|
||||
void _switchCamera() async {
|
||||
await _controller.switchCamera();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Stack(
|
||||
children: [
|
||||
// Scanner
|
||||
MobileScanner(
|
||||
controller: _controller,
|
||||
onDetect: _onDetect,
|
||||
errorBuilder: (context, error, child) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
LucideIcons.cameraOff,
|
||||
color: AppColors.error,
|
||||
size: 48,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
error.errorDetails?.message ??
|
||||
L10n.of(context)!.cameraPermissionDenied,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 16,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
// Overlay con visor
|
||||
_buildOverlay(),
|
||||
|
||||
// Indicador de progreso UR (si está capturando)
|
||||
if (_isCapturingUr) _buildUrProgress(),
|
||||
|
||||
// Controles
|
||||
_buildControls(),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildOverlay() {
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final scannerSize = constraints.maxWidth * 0.7;
|
||||
final horizontalPadding = (constraints.maxWidth - scannerSize) / 2;
|
||||
final verticalPadding = (constraints.maxHeight - scannerSize) / 2;
|
||||
|
||||
return Stack(
|
||||
children: [
|
||||
// Sombra superior
|
||||
Positioned(
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: verticalPadding,
|
||||
child: Container(color: Colors.black54),
|
||||
),
|
||||
// Sombra inferior
|
||||
Positioned(
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: verticalPadding,
|
||||
child: Container(color: Colors.black54),
|
||||
),
|
||||
// Sombra izquierda
|
||||
Positioned(
|
||||
top: verticalPadding,
|
||||
left: 0,
|
||||
width: horizontalPadding,
|
||||
height: scannerSize,
|
||||
child: Container(color: Colors.black54),
|
||||
),
|
||||
// Sombra derecha
|
||||
Positioned(
|
||||
top: verticalPadding,
|
||||
right: 0,
|
||||
width: horizontalPadding,
|
||||
height: scannerSize,
|
||||
child: Container(color: Colors.black54),
|
||||
),
|
||||
// Marco del visor
|
||||
Positioned(
|
||||
top: verticalPadding,
|
||||
left: horizontalPadding,
|
||||
child: Container(
|
||||
width: scannerSize,
|
||||
height: scannerSize,
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(
|
||||
color: _isCapturingUr
|
||||
? AppColors.warning
|
||||
: AppColors.primaryAction,
|
||||
width: 2,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Stack(
|
||||
children: [
|
||||
// Esquinas decorativas
|
||||
_buildCorner(Alignment.topLeft),
|
||||
_buildCorner(Alignment.topRight),
|
||||
_buildCorner(Alignment.bottomLeft),
|
||||
_buildCorner(Alignment.bottomRight),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCorner(Alignment alignment) {
|
||||
final color = _isCapturingUr ? AppColors.warning : AppColors.primaryAction;
|
||||
const size = 24.0;
|
||||
const thickness = 4.0;
|
||||
|
||||
return Positioned(
|
||||
top: alignment == Alignment.topLeft || alignment == Alignment.topRight
|
||||
? 0
|
||||
: null,
|
||||
bottom:
|
||||
alignment == Alignment.bottomLeft || alignment == Alignment.bottomRight
|
||||
? 0
|
||||
: null,
|
||||
left: alignment == Alignment.topLeft || alignment == Alignment.bottomLeft
|
||||
? 0
|
||||
: null,
|
||||
right:
|
||||
alignment == Alignment.topRight || alignment == Alignment.bottomRight
|
||||
? 0
|
||||
: null,
|
||||
child: SizedBox(
|
||||
width: size,
|
||||
height: size,
|
||||
child: CustomPaint(
|
||||
painter: _CornerPainter(
|
||||
color: color,
|
||||
thickness: thickness,
|
||||
alignment: alignment,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildUrProgress() {
|
||||
return Positioned(
|
||||
top: 16,
|
||||
left: 16,
|
||||
right: 16,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.warning.withValues(alpha: 0.9),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Text(
|
||||
'QR Animado',
|
||||
style: TextStyle(
|
||||
color: Colors.black,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'${_urFragmentsSeen.length} fragmentos',
|
||||
style: const TextStyle(
|
||||
color: Colors.black,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
// Indicador indeterminado ya que TokenDecoder no expone progreso
|
||||
const LinearProgressIndicator(
|
||||
backgroundColor: Colors.black26,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(Colors.black),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildControls() {
|
||||
return Positioned(
|
||||
bottom: 24,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
if (widget.showFlashControl)
|
||||
ValueListenableBuilder(
|
||||
valueListenable: _controller,
|
||||
builder: (context, state, child) {
|
||||
final torchEnabled = state.torchState == TorchState.on;
|
||||
return GestureDetector(
|
||||
onTap: _toggleFlash,
|
||||
child: Container(
|
||||
width: 56,
|
||||
height: 56,
|
||||
decoration: BoxDecoration(
|
||||
color: torchEnabled
|
||||
? AppColors.warning
|
||||
: Colors.white.withValues(alpha: 0.2),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(
|
||||
torchEnabled ? LucideIcons.zapOff : LucideIcons.zap,
|
||||
color: torchEnabled ? Colors.black : Colors.white,
|
||||
size: 24,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
if (widget.showFlashControl && widget.showCameraSwitch)
|
||||
const SizedBox(width: 24),
|
||||
if (widget.showCameraSwitch)
|
||||
GestureDetector(
|
||||
onTap: _switchCamera,
|
||||
child: Container(
|
||||
width: 56,
|
||||
height: 56,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: 0.2),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Icon(
|
||||
LucideIcons.switchCamera,
|
||||
color: Colors.white,
|
||||
size: 24,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Painter para dibujar esquinas decorativas
|
||||
class _CornerPainter extends CustomPainter {
|
||||
final Color color;
|
||||
final double thickness;
|
||||
final Alignment alignment;
|
||||
|
||||
_CornerPainter({
|
||||
required this.color,
|
||||
required this.thickness,
|
||||
required this.alignment,
|
||||
});
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final paint = Paint()
|
||||
..color = color
|
||||
..strokeWidth = thickness
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeCap = StrokeCap.round;
|
||||
|
||||
final path = Path();
|
||||
|
||||
if (alignment == Alignment.topLeft) {
|
||||
path.moveTo(0, size.height);
|
||||
path.lineTo(0, 0);
|
||||
path.lineTo(size.width, 0);
|
||||
} else if (alignment == Alignment.topRight) {
|
||||
path.moveTo(0, 0);
|
||||
path.lineTo(size.width, 0);
|
||||
path.lineTo(size.width, size.height);
|
||||
} else if (alignment == Alignment.bottomLeft) {
|
||||
path.moveTo(0, 0);
|
||||
path.lineTo(0, size.height);
|
||||
path.lineTo(size.width, size.height);
|
||||
} else if (alignment == Alignment.bottomRight) {
|
||||
path.moveTo(size.width, 0);
|
||||
path.lineTo(size.width, size.height);
|
||||
path.lineTo(0, size.height);
|
||||
}
|
||||
|
||||
canvas.drawPath(path, paint);
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(covariant _CornerPainter oldDelegate) =>
|
||||
color != oldDelegate.color;
|
||||
}
|
||||
@@ -8,7 +8,7 @@ list(APPEND FLUTTER_PLUGIN_LIST
|
||||
)
|
||||
|
||||
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
||||
cdk_flutter
|
||||
rust_builder
|
||||
)
|
||||
|
||||
set(PLUGIN_BUNDLED_LIBRARIES)
|
||||
|
||||
@@ -6,6 +6,8 @@ import FlutterMacOS
|
||||
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
|
||||
@@ -14,6 +16,8 @@ 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"))
|
||||
|
||||
+363
-45
@@ -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:
|
||||
@@ -33,6 +49,22 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.11.0"
|
||||
bech32:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: bech32
|
||||
sha256: "156cbace936f7720c79a79d16a03efad343b1ef17106716e04b8b8e39f99f7f7"
|
||||
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:
|
||||
@@ -41,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:
|
||||
@@ -49,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:
|
||||
@@ -57,23 +161,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.3.5"
|
||||
cdk_flutter:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
path: "."
|
||||
ref: HEAD
|
||||
resolved-ref: "3e0c77c22c807459d5d355e270ef4b5ad6a490c1"
|
||||
url: "https://github.com/cashubtc/cdk_flutter.git"
|
||||
source: git
|
||||
version: "0.1.0"
|
||||
characters:
|
||||
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:
|
||||
@@ -94,18 +189,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:
|
||||
@@ -146,14 +249,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:
|
||||
@@ -213,7 +324,7 @@ packages:
|
||||
source: hosted
|
||||
version: "2.4.4"
|
||||
flutter_rust_bridge:
|
||||
dependency: transitive
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: flutter_rust_bridge
|
||||
sha256: "37ef40bc6f863652e865f0b2563ea07f0d3c58d8efad803cc01933a4b2ee067e"
|
||||
@@ -278,14 +389,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:
|
||||
@@ -294,6 +429,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:
|
||||
@@ -318,6 +461,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:
|
||||
@@ -346,10 +497,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:
|
||||
@@ -370,26 +529,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:
|
||||
@@ -398,6 +557,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:
|
||||
@@ -410,26 +577,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:
|
||||
@@ -438,6 +605,22 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.0"
|
||||
mobile_scanner:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: mobile_scanner
|
||||
sha256: d234581c090526676fd8fab4ada92f35c6746e3fb4f05a399665d75a399fb760
|
||||
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:
|
||||
@@ -446,14 +629,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:
|
||||
@@ -526,6 +741,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:
|
||||
@@ -542,6 +773,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:
|
||||
@@ -558,6 +805,13 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.1.0"
|
||||
rust_builder:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
path: rust_builder
|
||||
relative: true
|
||||
source: path
|
||||
version: "0.1.0"
|
||||
share_plus:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -630,11 +884,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:
|
||||
@@ -703,18 +981,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:
|
||||
@@ -743,10 +1029,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:
|
||||
@@ -828,7 +1122,7 @@ packages:
|
||||
source: hosted
|
||||
version: "3.1.4"
|
||||
uuid:
|
||||
dependency: transitive
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: uuid
|
||||
sha256: a11b666489b1954e01d992f3d601b1804a33937b5a8fe677bd26b8a9f96f96e8
|
||||
@@ -839,10 +1133,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:
|
||||
@@ -851,6 +1145,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:
|
||||
@@ -859,6 +1161,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:
|
||||
@@ -892,5 +1210,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"
|
||||
|
||||
+24
-6
@@ -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:
|
||||
@@ -15,10 +15,9 @@ dependencies:
|
||||
|
||||
cupertino_icons: ^1.0.8
|
||||
|
||||
# CDK Flutter (Cashu backend)
|
||||
cdk_flutter:
|
||||
git:
|
||||
url: https://github.com/cashubtc/cdk_flutter.git
|
||||
# Internal Rust bridge (Cashu backend via cdk)
|
||||
rust_builder:
|
||||
path: rust_builder
|
||||
|
||||
# State Management
|
||||
provider: ^6.1.2
|
||||
@@ -39,6 +38,7 @@ dependencies:
|
||||
# QR Code
|
||||
qr_flutter: ^4.1.0
|
||||
share_plus: ^12.0.1
|
||||
mobile_scanner: ^5.1.1
|
||||
|
||||
# URL Launcher
|
||||
url_launcher: ^6.2.0
|
||||
@@ -49,12 +49,30 @@ dependencies:
|
||||
# CBOR decoder for V4 tokens
|
||||
cbor: ^6.3.0
|
||||
|
||||
# Bech32 decoder for LNURL
|
||||
bech32: ^0.2.2
|
||||
|
||||
# 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:
|
||||
|
||||
Generated
+4213
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,65 @@
|
||||
[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", "nostr"] }
|
||||
cdk-common = { version = "0.15.1", default-features = false, features = ["nostr"] }
|
||||
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"] }
|
||||
|
||||
# Nostr keys (same version as CDK 0.15.1 — CDK does not re-export Keys)
|
||||
nostr-sdk = { version = "0.44.1", default-features = false, features = ["nip04", "nip44", "nip59"] }
|
||||
|
||||
# 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)'] }
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
pub mod error;
|
||||
pub mod keys;
|
||||
pub mod mint_info;
|
||||
pub mod payment_request;
|
||||
pub mod token;
|
||||
pub mod wallet;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user