Compare commits
79
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
55560a28fc | ||
|
|
db2f393ff0 | ||
|
|
9691dda1bc | ||
|
|
be72926a25 | ||
|
|
ce4ef20177 | ||
|
|
f101f7098b | ||
|
|
6cf9e5b69c | ||
|
|
b14acba5b7 | ||
|
|
0a932bdd8e | ||
|
|
5934a1db8a | ||
|
|
9d643af39d | ||
|
|
a5dc4d6160 | ||
|
|
922fabc784 | ||
|
|
7540e7e413 | ||
|
|
9ee020ccb2 | ||
|
|
aeaebee37b | ||
|
|
52812c85c6 | ||
|
|
dac5693d5c |
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Forte11
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -1,17 +1,350 @@
|
||||
# elcaju
|
||||
## ElCaju 🥜
|
||||
|
||||
A new Flutter project.
|
||||
[](https://deepwiki.com/Forte11Cuba/elcaju)
|
||||
*Ask questions about this project using DeepWiki AI*
|
||||
|
||||
## Getting Started
|
||||
<p align="center">
|
||||
<img src="assets/img/elcajucubano.png" alt="ElCaju Logo" width="200"/>
|
||||
</p>
|
||||
|
||||
This project is a starting point for a Flutter application.
|
||||
<p align="center">
|
||||
<strong>Your private ecash wallet on Bitcoin</strong>
|
||||
</p>
|
||||
|
||||
A few resources to get you started if this is your first Flutter project:
|
||||
<p align="center">
|
||||
<img src="https://img.shields.io/badge/Flutter-02569B?style=for-the-badge&logo=flutter&logoColor=white" alt="Flutter"/>
|
||||
<img src="https://img.shields.io/badge/Dart-0175C2?style=for-the-badge&logo=dart&logoColor=white" alt="Dart"/>
|
||||
<img src="https://img.shields.io/badge/Rust-000000?style=for-the-badge&logo=rust&logoColor=white" alt="Rust"/>
|
||||
<img src="https://img.shields.io/badge/Bitcoin-F7931A?style=for-the-badge&logo=bitcoin&logoColor=white" alt="Bitcoin"/>
|
||||
<img src="https://img.shields.io/badge/Cashu-4A1259?style=for-the-badge" alt="Cashu"/>
|
||||
</p>
|
||||
|
||||
- [Lab: Write your first Flutter app](https://docs.flutter.dev/get-started/codelab)
|
||||
- [Cookbook: Useful Flutter samples](https://docs.flutter.dev/cookbook)
|
||||
<p align="center">
|
||||
<a href="https://elcaju.me">elcaju.me</a> •
|
||||
<a href="#-installation">Installation</a> •
|
||||
<a href="#-features">Features</a> •
|
||||
<a href="#-technologies">Technologies</a>
|
||||
</p>
|
||||
|
||||
For help getting started with Flutter development, view the
|
||||
[online documentation](https://docs.flutter.dev/), which offers tutorials,
|
||||
samples, guidance on mobile development, and a full API reference.
|
||||
# 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.
|
||||
|
||||
### What is Cashu?
|
||||
|
||||
Cashu is an ecash protocol that enables Bitcoin transactions with maximum privacy. Cashu tokens are:
|
||||
- **Private**: Fungible and untraceable
|
||||
- **Offline**: Store and send tokens without internet connection
|
||||
- **Instant**: No waiting for blockchain confirmations
|
||||
|
||||
---
|
||||
|
||||
## ✨ 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 |
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Screenshots
|
||||
|
||||
<p align="center">
|
||||
<img src="assets/img/Captura1.png" width="180"/>
|
||||
<img src="assets/img/Captura2.png" width="180"/>
|
||||
<img src="assets/img/Captura3.png" width="180"/>
|
||||
<img src="assets/img/Captura4.png" width="180"/>
|
||||
<img src="assets/img/Captura5.png" width="180"/>
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Technologies
|
||||
|
||||
### Frontend
|
||||
- **Flutter 3.27+**: Cross-platform framework
|
||||
- **Dart**: Programming language
|
||||
- **Provider**: State management
|
||||
- **QR Flutter**: QR code generation
|
||||
|
||||
### Backend & Core
|
||||
- **[cdk-flutter](https://github.com/cashubtc/cdk_flutter)**: Cashu Development Kit (Rust FFI)
|
||||
- **SQLite**: Local persistence via cdk-sqlite
|
||||
- **Flutter Secure Storage**: Secure storage for seed and PIN
|
||||
|
||||
### Protocols
|
||||
- **Cashu**: NUT-00 to NUT-13 (ecash protocol)
|
||||
- **Lightning Network**: Deposits and withdrawals via BOLT11
|
||||
- **BIP39**: Seed phrase generation
|
||||
|
||||
---
|
||||
|
||||
## 📦 Installation
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Flutter SDK (>=3.27.0)
|
||||
- Dart SDK (>=3.6.0)
|
||||
- Android Studio with Android SDK
|
||||
- Android NDK 26.3+ (install via Android Studio > SDK Manager > SDK Tools)
|
||||
- Rust toolchain (>=1.85.0)
|
||||
|
||||
### Clone the Repository
|
||||
|
||||
```bash
|
||||
git clone https://github.com/Forte11Cuba/elcaju.git
|
||||
cd elcaju
|
||||
```
|
||||
|
||||
### Install Dependencies
|
||||
|
||||
```bash
|
||||
flutter pub get
|
||||
```
|
||||
|
||||
### Configure Rust for Android (Linux/macOS)
|
||||
|
||||
```bash
|
||||
# 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)
|
||||
rustup update stable
|
||||
|
||||
# Install Android targets
|
||||
rustup target add aarch64-linux-android # arm64-v8a (modern devices)
|
||||
rustup target add armv7-linux-androideabi # armeabi-v7a (older devices)
|
||||
rustup target add x86_64-linux-android # x86_64 (emulators)
|
||||
```
|
||||
|
||||
### Configure Android NDK Linkers
|
||||
|
||||
Create or edit `~/.cargo/config.toml` with your NDK path:
|
||||
|
||||
```toml
|
||||
[target.x86_64-unknown-linux-gnu]
|
||||
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"
|
||||
|
||||
[target.armv7-linux-androideabi]
|
||||
linker = "/path/to/Android/Sdk/ndk/26.3.11579264/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"
|
||||
|
||||
[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"
|
||||
```
|
||||
|
||||
> **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
|
||||
|
||||
```bash
|
||||
flutter run
|
||||
```
|
||||
|
||||
### Build for Production
|
||||
|
||||
```bash
|
||||
# Android APK (includes all architectures, ~55MB)
|
||||
flutter build apk --release
|
||||
|
||||
# Android App Bundle (recommended for Play Store)
|
||||
flutter build appbundle --release
|
||||
```
|
||||
|
||||
### Quick Build (arm64 only)
|
||||
|
||||
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
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🏗️ Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ Flutter UI (Dart) │
|
||||
│ Provider + Streams + Intl │
|
||||
├─────────────────────────────────────────┤
|
||||
│ WalletProvider / SettingsProvider │
|
||||
│ (Multi-unit: Map<mintUrl:unit>) │
|
||||
├─────────────────────────────────────────┤
|
||||
│ cdk-flutter (FFI) │
|
||||
│ flutter_rust_bridge v2.11.1 │
|
||||
├─────────────────────────────────────────┤
|
||||
│ Rust Core (CDK) │
|
||||
│ Cashu 0.13.4 + SQLite + Bitcoin │
|
||||
└─────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Project Structure
|
||||
|
||||
```
|
||||
elcaju/
|
||||
├── lib/
|
||||
│ ├── main.dart # Entry point
|
||||
│ ├── core/
|
||||
│ │ ├── constants/ # Colors, dimensions
|
||||
│ │ ├── theme/ # App theme
|
||||
│ │ └── utils/ # Formatters, validators
|
||||
│ ├── providers/
|
||||
│ │ ├── wallet_provider.dart # Wallet logic
|
||||
│ │ └── settings_provider.dart# Preferences and PIN
|
||||
│ ├── 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
|
||||
│ ├── widgets/
|
||||
│ │ ├── common/ # Buttons, cards, etc.
|
||||
│ │ └── effects/ # Confetti and animations
|
||||
│ └── l10n/ # Translations ES/EN
|
||||
└── assets/
|
||||
└── img/ # Logo and assets
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔐 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 |
|
||||
| **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.
|
||||
|
||||
---
|
||||
|
||||
## 📱 Compatibility
|
||||
|
||||
| Platform | Status | Minimum Version |
|
||||
|----------|--------|-----------------|
|
||||
| ✅ Android | Supported | API 24 (Android 7.0) |
|
||||
| 🚧 iOS | Coming soon | iOS 12.0+ |
|
||||
| 🚧 Web | Coming soon | - |
|
||||
|
||||
---
|
||||
|
||||
## 🌐 Default Mint
|
||||
|
||||
ElCaju comes preconfigured with the Cuba Bitcoin mint:
|
||||
|
||||
```
|
||||
https://mint.cubabitcoin.org
|
||||
```
|
||||
|
||||
You can add other Cashu mints from the settings.
|
||||
|
||||
---
|
||||
|
||||
## 🤝 Contributing
|
||||
|
||||
Contributions are welcome!
|
||||
|
||||
1. Fork the repository
|
||||
2. Create a branch for your feature (`git checkout -b feature/new-feature`)
|
||||
3. Commit your changes (`git commit -m 'Add new feature'`)
|
||||
4. Push to the branch (`git push origin feature/new-feature`)
|
||||
5. Open a Pull Request
|
||||
|
||||
### Report Bugs
|
||||
|
||||
Open an [issue](https://github.com/Forte11Cuba/elcaju/issues) with:
|
||||
- Problem description
|
||||
- Steps to reproduce
|
||||
- Android version/device
|
||||
- Relevant logs
|
||||
|
||||
---
|
||||
|
||||
## 📄 License
|
||||
|
||||
This project is under the MIT License - see the [LICENSE](LICENSE) file for more details.
|
||||
|
||||
---
|
||||
|
||||
## 🙏 Credits
|
||||
|
||||
- **[Cashu](https://cashu.space)** - Ecash protocol
|
||||
- **[cdk-flutter](https://github.com/cashubtc/cdk_flutter)** - Cashu Development Kit
|
||||
- **[Cashu4Community](https://cashu4community.xyz)** - Global Cashu community
|
||||
- **[Cuba Bitcoin](https://cubabitcoin.org)** - Cuban Bitcoin community
|
||||
|
||||
---
|
||||
|
||||
<p align="center">
|
||||
<strong>Made with 🧡 by <a href="https://github.com/Forte11Cuba">Forte11</a></strong>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://cashu4community.xyz">Cashu4Community</a> •
|
||||
<a href="https://cubabitcoin.org">Cuba Bitcoin</a> •
|
||||
<a href="https://elcaju.me">elcaju.me</a>
|
||||
</p>
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
plugins {
|
||||
id "com.android.application"
|
||||
id "kotlin-android"
|
||||
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
|
||||
id "dev.flutter.flutter-gradle-plugin"
|
||||
}
|
||||
|
||||
android {
|
||||
namespace "me.elcaju"
|
||||
compileSdk flutter.compileSdkVersion
|
||||
ndkVersion "27.0.12077973"
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility JavaVersion.VERSION_11
|
||||
targetCompatibility JavaVersion.VERSION_11
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = JavaVersion.VERSION_11.toString()
|
||||
}
|
||||
|
||||
defaultConfig {
|
||||
applicationId "me.elcaju"
|
||||
minSdk 21 // Required for FlutterSecureStorage (EncryptedSharedPreferences)
|
||||
targetSdk flutter.targetSdkVersion
|
||||
versionCode flutter.versionCode
|
||||
versionName flutter.versionName
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
// TODO: Add your own signing config for the release build.
|
||||
// Signing with the debug keys for now, so `flutter run --release` works.
|
||||
signingConfig signingConfigs.debug
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
flutter {
|
||||
source "../.."
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
id("kotlin-android")
|
||||
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
|
||||
id("dev.flutter.flutter-gradle-plugin")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "me.elcaju"
|
||||
compileSdk = flutter.compileSdkVersion
|
||||
ndkVersion = "27.0.12077973"
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_11
|
||||
targetCompatibility = JavaVersion.VERSION_11
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = JavaVersion.VERSION_11.toString()
|
||||
}
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "me.elcaju"
|
||||
// You can update the following values to match your application needs.
|
||||
// For more information, see: https://flutter.dev/to/review-gradle-config.
|
||||
minSdk = flutter.minSdkVersion
|
||||
targetSdk = flutter.targetSdkVersion
|
||||
versionCode = flutter.versionCode
|
||||
versionName = flutter.versionName
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
// TODO: Add your own signing config for the release build.
|
||||
// Signing with the debug keys for now, so `flutter run --release` works.
|
||||
signingConfig = signingConfigs.getByName("debug")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
flutter {
|
||||
source = "../.."
|
||||
}
|
||||
@@ -1,4 +1,13 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<!-- 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"/>
|
||||
|
||||
<application
|
||||
android:label="ElCaju"
|
||||
android:name="${applicationName}"
|
||||
|
||||
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
@@ -5,17 +5,18 @@ allprojects {
|
||||
}
|
||||
}
|
||||
|
||||
val newBuildDir: Directory = rootProject.layout.buildDirectory.dir("../../build").get()
|
||||
def newBuildDir = rootProject.layout.buildDirectory.dir("../../build").get()
|
||||
rootProject.layout.buildDirectory.value(newBuildDir)
|
||||
|
||||
subprojects {
|
||||
val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name)
|
||||
def newSubprojectBuildDir = newBuildDir.dir(project.name)
|
||||
project.layout.buildDirectory.value(newSubprojectBuildDir)
|
||||
}
|
||||
|
||||
subprojects {
|
||||
project.evaluationDependsOn(":app")
|
||||
}
|
||||
|
||||
tasks.register<Delete>("clean") {
|
||||
delete(rootProject.layout.buildDirectory)
|
||||
tasks.register("clean", Delete) {
|
||||
delete rootProject.layout.buildDirectory
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
pluginManagement {
|
||||
def flutterSdkPath = {
|
||||
def properties = new Properties()
|
||||
file("local.properties").withInputStream { properties.load(it) }
|
||||
def flutterSdkPath = properties.getProperty("flutter.sdk")
|
||||
assert flutterSdkPath != null, "flutter.sdk not set in local.properties"
|
||||
return flutterSdkPath
|
||||
}()
|
||||
|
||||
includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
|
||||
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
gradlePluginPortal()
|
||||
}
|
||||
}
|
||||
|
||||
plugins {
|
||||
id "dev.flutter.flutter-plugin-loader" version "1.0.0"
|
||||
id "com.android.application" version "8.7.3" apply false
|
||||
id "org.jetbrains.kotlin.android" version "2.1.0" apply false
|
||||
}
|
||||
|
||||
include ":app"
|
||||
@@ -1,25 +0,0 @@
|
||||
pluginManagement {
|
||||
val flutterSdkPath = run {
|
||||
val properties = java.util.Properties()
|
||||
file("local.properties").inputStream().use { properties.load(it) }
|
||||
val flutterSdkPath = properties.getProperty("flutter.sdk")
|
||||
require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" }
|
||||
flutterSdkPath
|
||||
}
|
||||
|
||||
includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
|
||||
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
gradlePluginPortal()
|
||||
}
|
||||
}
|
||||
|
||||
plugins {
|
||||
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
|
||||
id("com.android.application") version "8.7.3" apply false
|
||||
id("org.jetbrains.kotlin.android") version "2.1.0" apply false
|
||||
}
|
||||
|
||||
include(":app")
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 197 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 441 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 208 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 257 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 283 KiB |
@@ -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,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());
|
||||
}
|
||||
}
|
||||
@@ -34,16 +34,34 @@ class UnitFormatter {
|
||||
}
|
||||
|
||||
/// Formatea un balance con su unidad.
|
||||
/// Ejemplo: 1000 sat → "1,000 BTC", 500 usd → "5.00 USD"
|
||||
/// Ejemplo: 1000 sat → "1,000 sat", 500 usd → "5.00 USD"
|
||||
static String formatBalanceWithUnit(BigInt amount, String unit) {
|
||||
final formatted = formatBalance(amount, unit);
|
||||
final label = getUnitLabel(unit);
|
||||
return '$formatted $label';
|
||||
}
|
||||
|
||||
/// Obtiene la etiqueta de display para una unidad.
|
||||
/// sat → BTC, usd → USD, eur → EUR, msat → MSAT
|
||||
/// Obtiene la etiqueta de display para una unidad (para balances).
|
||||
/// Siguiendo el estándar de cashu.me: minúsculas para sat/msat
|
||||
/// Ejemplo: "855 sat", "5.00 USD"
|
||||
static String getUnitLabel(String unit) {
|
||||
switch (unit.toLowerCase()) {
|
||||
case 'sat':
|
||||
return 'sat';
|
||||
case 'usd':
|
||||
return 'USD';
|
||||
case 'eur':
|
||||
return 'EUR';
|
||||
case 'msat':
|
||||
return 'msat';
|
||||
default:
|
||||
return unit;
|
||||
}
|
||||
}
|
||||
|
||||
/// Obtiene la etiqueta para el botón toggle de unidad.
|
||||
/// sat → "BTC" (indica Bitcoin), otros → mayúsculas
|
||||
static String getToggleLabel(String unit) {
|
||||
switch (unit.toLowerCase()) {
|
||||
case 'sat':
|
||||
return 'BTC';
|
||||
@@ -52,7 +70,24 @@ class UnitFormatter {
|
||||
case 'eur':
|
||||
return 'EUR';
|
||||
case 'msat':
|
||||
return 'MSAT';
|
||||
return 'mSAT';
|
||||
default:
|
||||
return unit.toUpperCase();
|
||||
}
|
||||
}
|
||||
|
||||
/// Obtiene la etiqueta en mayúsculas (para badges, estados).
|
||||
/// Ejemplo: "PENDING: 536 SAT"
|
||||
static String getUnitLabelUppercase(String unit) {
|
||||
switch (unit.toLowerCase()) {
|
||||
case 'sat':
|
||||
return 'SAT';
|
||||
case 'usd':
|
||||
return 'USD';
|
||||
case 'eur':
|
||||
return 'EUR';
|
||||
case 'msat':
|
||||
return 'mSAT';
|
||||
default:
|
||||
return unit.toUpperCase();
|
||||
}
|
||||
@@ -76,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,188 @@
|
||||
// Parser para detectar el tipo de dato entrante (QR, clipboard, etc.)
|
||||
// Soporta: tokens Cashu (A/B), invoices Lightning, URLs de mint, payment requests
|
||||
|
||||
/// 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... (post-MVP, Cashu 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();
|
||||
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,
|
||||
);
|
||||
}
|
||||
|
||||
// Payment Request (creqA...)
|
||||
if (lower.startsWith('creqa')) {
|
||||
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:
|
||||
return data.type == IncomingDataType.lightningInvoice;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,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,166 @@
|
||||
import 'dart:convert';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
/// Tipos de transacción
|
||||
enum TransactionType {
|
||||
cashu,
|
||||
lightning,
|
||||
}
|
||||
|
||||
/// Metadata adicional de una transacción.
|
||||
/// Se usa para guardar datos que el CDK no persiste (token, invoice, tipo).
|
||||
class TransactionMeta {
|
||||
final TransactionType type;
|
||||
final String? token; // Solo para Cashu send
|
||||
final String? invoice; // Solo para Lightning (mint/melt)
|
||||
final DateTime createdAt;
|
||||
|
||||
TransactionMeta({
|
||||
required this.type,
|
||||
this.token,
|
||||
this.invoice,
|
||||
DateTime? createdAt,
|
||||
}) : createdAt = createdAt ?? DateTime.now();
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'type': type.name,
|
||||
'token': token,
|
||||
'invoice': invoice,
|
||||
'createdAt': createdAt.toIso8601String(),
|
||||
};
|
||||
|
||||
factory TransactionMeta.fromJson(Map<String, dynamic> json) {
|
||||
return TransactionMeta(
|
||||
type: TransactionType.values.firstWhere(
|
||||
(t) => t.name == json['type'],
|
||||
orElse: () => TransactionType.cashu,
|
||||
),
|
||||
token: json['token'] as String?,
|
||||
invoice: json['invoice'] as String?,
|
||||
createdAt: json['createdAt'] != null
|
||||
? DateTime.parse(json['createdAt'] as String)
|
||||
: DateTime.now(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Storage para metadata de transacciones.
|
||||
/// Complementa el historial del CDK con datos adicionales.
|
||||
///
|
||||
/// Uso:
|
||||
/// ```dart
|
||||
/// final storage = TransactionMetaStorage();
|
||||
/// await storage.init();
|
||||
///
|
||||
/// // Guardar metadata
|
||||
/// await storage.save('tx_123', TransactionMeta(type: TransactionType.cashu, token: 'cashuA...'));
|
||||
///
|
||||
/// // Obtener metadata
|
||||
/// final meta = storage.get('tx_123');
|
||||
/// ```
|
||||
class TransactionMetaStorage {
|
||||
static const _storageKey = 'transaction_meta';
|
||||
|
||||
SharedPreferences? _prefs;
|
||||
final Map<String, TransactionMeta> _cache = {};
|
||||
|
||||
/// Singleton
|
||||
static final TransactionMetaStorage _instance = TransactionMetaStorage._internal();
|
||||
factory TransactionMetaStorage() => _instance;
|
||||
TransactionMetaStorage._internal();
|
||||
|
||||
/// Inicializa el storage. Llamar antes de usar.
|
||||
Future<void> init() async {
|
||||
_prefs = await SharedPreferences.getInstance();
|
||||
await _loadFromDisk();
|
||||
}
|
||||
|
||||
/// Carga datos desde SharedPreferences al cache en memoria.
|
||||
Future<void> _loadFromDisk() async {
|
||||
final jsonStr = _prefs?.getString(_storageKey);
|
||||
if (jsonStr == null || jsonStr.isEmpty) return;
|
||||
|
||||
try {
|
||||
final decoded = jsonDecode(jsonStr) as Map<String, dynamic>;
|
||||
_cache.clear();
|
||||
|
||||
for (final entry in decoded.entries) {
|
||||
_cache[entry.key] = TransactionMeta.fromJson(
|
||||
entry.value as Map<String, dynamic>,
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
// Si hay error de parsing, empezar limpio
|
||||
_cache.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/// Guarda el cache en SharedPreferences.
|
||||
Future<void> _saveToDisk() async {
|
||||
final jsonMap = <String, dynamic>{};
|
||||
for (final entry in _cache.entries) {
|
||||
jsonMap[entry.key] = entry.value.toJson();
|
||||
}
|
||||
await _prefs?.setString(_storageKey, jsonEncode(jsonMap));
|
||||
}
|
||||
|
||||
/// Guarda metadata para una transacción.
|
||||
Future<void> save(String transactionId, TransactionMeta meta) async {
|
||||
_cache[transactionId] = meta;
|
||||
await _saveToDisk();
|
||||
}
|
||||
|
||||
/// Obtiene metadata de una transacción.
|
||||
TransactionMeta? get(String transactionId) {
|
||||
return _cache[transactionId];
|
||||
}
|
||||
|
||||
/// Verifica si existe metadata para una transacción.
|
||||
bool has(String transactionId) {
|
||||
return _cache.containsKey(transactionId);
|
||||
}
|
||||
|
||||
/// Elimina metadata de una transacción.
|
||||
Future<void> remove(String transactionId) async {
|
||||
_cache.remove(transactionId);
|
||||
await _saveToDisk();
|
||||
}
|
||||
|
||||
/// Elimina todas las metadata.
|
||||
Future<void> clear() async {
|
||||
_cache.clear();
|
||||
await _prefs?.remove(_storageKey);
|
||||
}
|
||||
|
||||
/// Obtiene el tipo de una transacción.
|
||||
/// Primero busca en metadata del CDK, luego en storage local.
|
||||
TransactionType getType(String transactionId, Map<String, String>? cdkMetadata) {
|
||||
// 1. Buscar en metadata del CDK
|
||||
final cdkType = cdkMetadata?['type'];
|
||||
if (cdkType == 'cashu') return TransactionType.cashu;
|
||||
if (cdkType == 'lightning') return TransactionType.lightning;
|
||||
|
||||
// 2. Buscar en storage local
|
||||
final localMeta = get(transactionId);
|
||||
if (localMeta != null) return localMeta.type;
|
||||
|
||||
// 3. Default: cashu (la mayoría de transacciones son cashu)
|
||||
return TransactionType.cashu;
|
||||
}
|
||||
|
||||
/// Limpia entradas huérfanas (IDs que ya no existen en el CDK).
|
||||
/// Llamar periódicamente para no acumular basura.
|
||||
Future<int> cleanupOrphans(Set<String> validIds) async {
|
||||
final orphans = _cache.keys.where((id) => !validIds.contains(id)).toList();
|
||||
|
||||
for (final id in orphans) {
|
||||
_cache.remove(id);
|
||||
}
|
||||
|
||||
if (orphans.isNotEmpty) {
|
||||
await _saveToDisk();
|
||||
}
|
||||
|
||||
return orphans.length;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,520 @@
|
||||
{
|
||||
"@@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)",
|
||||
"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",
|
||||
|
||||
"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 La Chispa.",
|
||||
"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",
|
||||
"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",
|
||||
"paymentRequestNotSupported": "Zahlungsanfragen werden noch nicht unterstützt",
|
||||
"p2pkTitle": "P2PK-Schlüssel",
|
||||
"p2pkSettingsDescription": "Gesperrtes ecash empfangen",
|
||||
"p2pkExperimental": "P2PK ist experimentell. Mit Vorsicht verwenden.",
|
||||
"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": "An öffentlichen Schlüssel sperren",
|
||||
"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"
|
||||
}
|
||||
+334
-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",
|
||||
@@ -44,31 +52,350 @@
|
||||
"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",
|
||||
|
||||
"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 La Chispa.",
|
||||
"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",
|
||||
"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",
|
||||
"paymentRequestNotSupported": "Payment requests are not yet supported",
|
||||
|
||||
"p2pkTitle": "P2PK Keys",
|
||||
"p2pkSettingsDescription": "Receive locked ecash",
|
||||
"p2pkExperimental": "P2PK is experimental. Use with caution.",
|
||||
"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": "Lock to public key",
|
||||
"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"
|
||||
}
|
||||
|
||||
+446
-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,474 @@
|
||||
"wordCount": "{count} palabras",
|
||||
"@wordCount": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
"count": { "type": "int" }
|
||||
}
|
||||
},
|
||||
"needWords": "(necesitas 12 o 24)",
|
||||
"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",
|
||||
|
||||
"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 La Chispa.",
|
||||
"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",
|
||||
"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",
|
||||
"paymentRequestNotSupported": "Los payment requests aún no están soportados",
|
||||
|
||||
"p2pkTitle": "Claves P2PK",
|
||||
"p2pkSettingsDescription": "Recibir ecash bloqueado",
|
||||
"p2pkExperimental": "P2PK es experimental. Úsala con precaución.",
|
||||
"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": "Bloquear a clave pública",
|
||||
"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"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,520 @@
|
||||
{
|
||||
"@@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)",
|
||||
"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",
|
||||
|
||||
"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 La Chispa.",
|
||||
"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",
|
||||
"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",
|
||||
"paymentRequestNotSupported": "Les demandes de paiement ne sont pas encore prises en charge",
|
||||
"p2pkTitle": "Clés P2PK",
|
||||
"p2pkSettingsDescription": "Recevoir ecash verrouillé",
|
||||
"p2pkExperimental": "P2PK est expérimental. Utiliser avec prudence.",
|
||||
"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": "Verrouiller à la clé publique",
|
||||
"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"
|
||||
}
|
||||
@@ -0,0 +1,520 @@
|
||||
{
|
||||
"@@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)",
|
||||
"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",
|
||||
|
||||
"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 La Chispa.",
|
||||
"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",
|
||||
"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",
|
||||
"paymentRequestNotSupported": "Le richieste di pagamento non sono ancora supportate",
|
||||
"p2pkTitle": "Chiavi P2PK",
|
||||
"p2pkSettingsDescription": "Ricevi ecash bloccato",
|
||||
"p2pkExperimental": "P2PK è sperimentale. Usare con cautela.",
|
||||
"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": "Blocca a chiave pubblica",
|
||||
"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"
|
||||
}
|
||||
@@ -0,0 +1,520 @@
|
||||
{
|
||||
"@@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語が必要)",
|
||||
"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": "トークンをクリップボードにコピーしました",
|
||||
|
||||
"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ウォレット。La Chispaの兄弟。",
|
||||
"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": "オフライン送金",
|
||||
"selectNotesToSend": "送信するノートを選択:",
|
||||
"totalToSend": "送金合計",
|
||||
"notesSelected": "{count}件のノートを選択",
|
||||
"@notesSelected": {
|
||||
"placeholders": {
|
||||
"count": { "type": "int" }
|
||||
}
|
||||
},
|
||||
"loadingProofsError": "プルーフ読み込みエラー:{error}",
|
||||
"@loadingProofsError": {
|
||||
"placeholders": {
|
||||
"error": { "type": "String" }
|
||||
}
|
||||
},
|
||||
"creatingTokenError": "トークン作成エラー:{error}",
|
||||
"@creatingTokenError": {
|
||||
"placeholders": {
|
||||
"error": { "type": "String" }
|
||||
}
|
||||
},
|
||||
|
||||
"unknownState": "不明な状態",
|
||||
"depositAmountTitle": "{amount} {unit}を入金",
|
||||
"@depositAmountTitle": {
|
||||
"placeholders": {
|
||||
"amount": { "type": "String" },
|
||||
"unit": { "type": "String" }
|
||||
}
|
||||
},
|
||||
|
||||
"receiveNow": "今すぐ受け取る",
|
||||
"receiveLater": "後で受け取る",
|
||||
"tokenSavedForLater": "トークンを保存しました。後で請求できます",
|
||||
"noConnectionTokenSaved": "接続なし。トークンを保存しました。後で請求できます。",
|
||||
"unknownMintOffline": "このトークンは不明なMintからのものです。インターネットに接続してMintを追加し、トークンを請求してください。",
|
||||
"noConnectionTryLater": "Mintに接続できません。後でもう一度お試しください。",
|
||||
"saveTokenError": "トークンの保存エラー。もう一度お試しください。",
|
||||
"pendingTokenLimitReached": "保留中トークンの上限に達しました(最大50件)",
|
||||
"filterToReceive": "受取予定",
|
||||
"noPendingTokens": "保留中のトークンはありません",
|
||||
"noPendingTokensHint": "トークンを保存して後で請求",
|
||||
"pendingBadge": "保留中",
|
||||
"expiresInDays": "{days, plural, =1{1日後に期限切れ} other{{days}日後に期限切れ}}",
|
||||
"@expiresInDays": {
|
||||
"placeholders": {
|
||||
"days": { "type": "int" }
|
||||
}
|
||||
},
|
||||
"retryCount": "{count}回リトライ",
|
||||
"@retryCount": {
|
||||
"placeholders": {
|
||||
"count": { "type": "int" }
|
||||
}
|
||||
},
|
||||
"claimNow": "今すぐ請求",
|
||||
"pendingTokenClaimedSuccess": "{amount} {unit}を請求しました",
|
||||
"@pendingTokenClaimedSuccess": {
|
||||
"placeholders": {
|
||||
"amount": { "type": "String" },
|
||||
"unit": { "type": "String" }
|
||||
}
|
||||
},
|
||||
"pendingTokensClaimed": "{count, plural, =1{1件のトークン({amount} {unit})を請求しました} other{{count}件のトークン({amount} {unit})を請求しました}}",
|
||||
"@pendingTokensClaimed": {
|
||||
"placeholders": {
|
||||
"count": { "type": "int" },
|
||||
"amount": { "type": "String" },
|
||||
"unit": { "type": "String" }
|
||||
}
|
||||
},
|
||||
|
||||
"scan": "スキャン",
|
||||
"scanQrCode": "QRコードをスキャン",
|
||||
"scanCashuToken": "Cashuトークンをスキャン",
|
||||
"scanLightningInvoice": "インボイスをスキャン",
|
||||
"scanningAnimatedQr": "アニメーションQRをスキャン中...",
|
||||
"pointCameraAtQr": "カメラをQRコードに向けてください",
|
||||
"pointCameraAtCashuQr": "カメラをCashuトークンのQRに向けてください",
|
||||
"pointCameraAtInvoiceQr": "カメラをインボイスのQRに向けてください",
|
||||
"unrecognizedQrCode": "認識できないQRコード",
|
||||
"scanCashuTokenHint": "Cashuトークンをスキャン(cashuA...またはcashuB...)",
|
||||
"scanLightningInvoiceHint": "Lightningインボイスをスキャン(lnbc...)",
|
||||
"addMintQuestion": "このMintを追加しますか?",
|
||||
"cameraPermissionDenied": "カメラの許可が拒否されました",
|
||||
"paymentRequestNotSupported": "支払いリクエストはまだサポートされていません",
|
||||
"p2pkTitle": "P2PK鍵",
|
||||
"p2pkSettingsDescription": "ロックされたecashを受け取る",
|
||||
"p2pkExperimental": "P2PKは実験的機能です。注意してご使用ください。",
|
||||
"p2pkExperimentalShort": "実験的",
|
||||
"p2pkPrimaryKey": "プライマリ鍵",
|
||||
"p2pkDerived": "派生",
|
||||
"p2pkImported": "インポート済み",
|
||||
"p2pkImportedKeys": "インポートした鍵",
|
||||
"p2pkNoImportedKeys": "インポートした鍵はありません",
|
||||
"p2pkShowQR": "QRを表示",
|
||||
"p2pkCopy": "コピー",
|
||||
"p2pkImportNsec": "nsecをインポート",
|
||||
"p2pkImport": "インポート",
|
||||
"p2pkEnterLabel": "この鍵の名前",
|
||||
"p2pkLockToKey": "公開鍵にロック",
|
||||
"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": "プライマリ鍵は削除できません"
|
||||
}
|
||||
@@ -0,0 +1,520 @@
|
||||
{
|
||||
"@@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개 필요)",
|
||||
"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": "토큰이 클립보드에 복사되었습니다",
|
||||
|
||||
"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 지갑. La Chispa의 형제.",
|
||||
"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": "오프라인 전송",
|
||||
"selectNotesToSend": "보낼 노트를 선택하세요:",
|
||||
"totalToSend": "보낼 총액",
|
||||
"notesSelected": "{count}개 노트 선택됨",
|
||||
"@notesSelected": {
|
||||
"placeholders": {
|
||||
"count": { "type": "int" }
|
||||
}
|
||||
},
|
||||
"loadingProofsError": "증명 로드 오류: {error}",
|
||||
"@loadingProofsError": {
|
||||
"placeholders": {
|
||||
"error": { "type": "String" }
|
||||
}
|
||||
},
|
||||
"creatingTokenError": "토큰 생성 오류: {error}",
|
||||
"@creatingTokenError": {
|
||||
"placeholders": {
|
||||
"error": { "type": "String" }
|
||||
}
|
||||
},
|
||||
|
||||
"unknownState": "알 수 없는 상태",
|
||||
"depositAmountTitle": "{amount} {unit} 입금",
|
||||
"@depositAmountTitle": {
|
||||
"placeholders": {
|
||||
"amount": { "type": "String" },
|
||||
"unit": { "type": "String" }
|
||||
}
|
||||
},
|
||||
|
||||
"receiveNow": "지금 받기",
|
||||
"receiveLater": "나중에 받기",
|
||||
"tokenSavedForLater": "나중에 청구하기 위해 토큰이 저장되었습니다",
|
||||
"noConnectionTokenSaved": "연결 없음. 나중에 청구하기 위해 토큰이 저장되었습니다.",
|
||||
"unknownMintOffline": "이 토큰은 알 수 없는 mint의 것입니다. 인터넷에 연결하여 추가하고 토큰을 청구하세요.",
|
||||
"noConnectionTryLater": "Mint에 연결할 수 없음. 나중에 다시 시도하세요.",
|
||||
"saveTokenError": "토큰 저장 오류. 다시 시도해 주세요.",
|
||||
"pendingTokenLimitReached": "대기 중 토큰 한도 도달 (최대 50개)",
|
||||
"filterToReceive": "받을 항목",
|
||||
"noPendingTokens": "대기 중인 토큰 없음",
|
||||
"noPendingTokensHint": "나중에 청구할 토큰을 저장하세요",
|
||||
"pendingBadge": "대기 중",
|
||||
"expiresInDays": "{days, plural, =1{1일 후 만료} other{{days}일 후 만료}}",
|
||||
"@expiresInDays": {
|
||||
"placeholders": {
|
||||
"days": { "type": "int" }
|
||||
}
|
||||
},
|
||||
"retryCount": "{count}회 재시도",
|
||||
"@retryCount": {
|
||||
"placeholders": {
|
||||
"count": { "type": "int" }
|
||||
}
|
||||
},
|
||||
"claimNow": "지금 청구",
|
||||
"pendingTokenClaimedSuccess": "{amount} {unit} 청구됨",
|
||||
"@pendingTokenClaimedSuccess": {
|
||||
"placeholders": {
|
||||
"amount": { "type": "String" },
|
||||
"unit": { "type": "String" }
|
||||
}
|
||||
},
|
||||
"pendingTokensClaimed": "{count, plural, =1{1개 토큰 청구됨 ({amount} {unit})} other{{count}개 토큰 청구됨 ({amount} {unit})}}",
|
||||
"@pendingTokensClaimed": {
|
||||
"placeholders": {
|
||||
"count": { "type": "int" },
|
||||
"amount": { "type": "String" },
|
||||
"unit": { "type": "String" }
|
||||
}
|
||||
},
|
||||
|
||||
"scan": "스캔",
|
||||
"scanQrCode": "QR 스캔",
|
||||
"scanCashuToken": "Cashu 토큰 스캔",
|
||||
"scanLightningInvoice": "인보이스 스캔",
|
||||
"scanningAnimatedQr": "애니메이션 QR 스캔 중...",
|
||||
"pointCameraAtQr": "카메라를 QR 코드에 맞추세요",
|
||||
"pointCameraAtCashuQr": "카메라를 Cashu 토큰 QR에 맞추세요",
|
||||
"pointCameraAtInvoiceQr": "카메라를 인보이스 QR에 맞추세요",
|
||||
"unrecognizedQrCode": "인식할 수 없는 QR 코드",
|
||||
"scanCashuTokenHint": "Cashu 토큰 스캔 (cashuA... 또는 cashuB...)",
|
||||
"scanLightningInvoiceHint": "Lightning 인보이스 스캔 (lnbc...)",
|
||||
"addMintQuestion": "이 mint를 추가하시겠습니까?",
|
||||
"cameraPermissionDenied": "카메라 권한이 거부되었습니다",
|
||||
"paymentRequestNotSupported": "결제 요청은 아직 지원되지 않습니다",
|
||||
"p2pkTitle": "P2PK 키",
|
||||
"p2pkSettingsDescription": "잠긴 ecash 받기",
|
||||
"p2pkExperimental": "P2PK는 실험적 기능입니다. 주의하여 사용하세요.",
|
||||
"p2pkExperimentalShort": "실험적",
|
||||
"p2pkPrimaryKey": "기본 키",
|
||||
"p2pkDerived": "파생됨",
|
||||
"p2pkImported": "가져옴",
|
||||
"p2pkImportedKeys": "가져온 키",
|
||||
"p2pkNoImportedKeys": "가져온 키가 없습니다",
|
||||
"p2pkShowQR": "QR 표시",
|
||||
"p2pkCopy": "복사",
|
||||
"p2pkImportNsec": "nsec 가져오기",
|
||||
"p2pkImport": "가져오기",
|
||||
"p2pkEnterLabel": "이 키의 이름",
|
||||
"p2pkLockToKey": "공개 키로 잠금",
|
||||
"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": "기본 키는 삭제할 수 없습니다"
|
||||
}
|
||||
@@ -0,0 +1,520 @@
|
||||
{
|
||||
"@@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)",
|
||||
"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",
|
||||
|
||||
"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 La Chispa.",
|
||||
"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",
|
||||
"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",
|
||||
"paymentRequestNotSupported": "Solicitações de pagamento ainda não são suportadas",
|
||||
"p2pkTitle": "Chaves P2PK",
|
||||
"p2pkSettingsDescription": "Receber ecash bloqueado",
|
||||
"p2pkExperimental": "P2PK é experimental. Use com cautela.",
|
||||
"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": "Bloquear para chave pública",
|
||||
"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"
|
||||
}
|
||||
@@ -0,0 +1,520 @@
|
||||
{
|
||||
"@@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)",
|
||||
"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": "Токен скопирован в буфер обмена",
|
||||
|
||||
"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 кошелёк с кубинской ДНК для всего мира. Брат La Chispa.",
|
||||
"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": "Офлайн отправка",
|
||||
"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": "Доступ к камере запрещён",
|
||||
"paymentRequestNotSupported": "Запросы на оплату пока не поддерживаются",
|
||||
"p2pkTitle": "Ключи P2PK",
|
||||
"p2pkSettingsDescription": "Получить заблокированный ecash",
|
||||
"p2pkExperimental": "P2PK экспериментальный. Используйте с осторожностью.",
|
||||
"p2pkExperimentalShort": "Экспериментальный",
|
||||
"p2pkPrimaryKey": "Основной ключ",
|
||||
"p2pkDerived": "Производный",
|
||||
"p2pkImported": "Импортированный",
|
||||
"p2pkImportedKeys": "Импортированные ключи",
|
||||
"p2pkNoImportedKeys": "Нет импортированных ключей",
|
||||
"p2pkShowQR": "Показать QR",
|
||||
"p2pkCopy": "Копировать",
|
||||
"p2pkImportNsec": "Импортировать nsec",
|
||||
"p2pkImport": "Импортировать",
|
||||
"p2pkEnterLabel": "Имя для этого ключа",
|
||||
"p2pkLockToKey": "Заблокировать на публичный ключ",
|
||||
"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": "Невозможно удалить основной ключ"
|
||||
}
|
||||
@@ -0,0 +1,520 @@
|
||||
{
|
||||
"@@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)",
|
||||
"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",
|
||||
|
||||
"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 La Chispa.",
|
||||
"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",
|
||||
"selectNotesToSend": "Chagua noti unazotaka kutuma:",
|
||||
"totalToSend": "Jumla ya kutuma",
|
||||
"notesSelected": "noti {count} zimechaguliwa",
|
||||
"@notesSelected": {
|
||||
"placeholders": {
|
||||
"count": { "type": "int" }
|
||||
}
|
||||
},
|
||||
"loadingProofsError": "Hitilafu ya kupakia uthibitisho: {error}",
|
||||
"@loadingProofsError": {
|
||||
"placeholders": {
|
||||
"error": { "type": "String" }
|
||||
}
|
||||
},
|
||||
"creatingTokenError": "Hitilafu ya kuunda tokeni: {error}",
|
||||
"@creatingTokenError": {
|
||||
"placeholders": {
|
||||
"error": { "type": "String" }
|
||||
}
|
||||
},
|
||||
|
||||
"unknownState": "Hali isiyojulikana",
|
||||
"depositAmountTitle": "Weka {amount} {unit}",
|
||||
"@depositAmountTitle": {
|
||||
"placeholders": {
|
||||
"amount": { "type": "String" },
|
||||
"unit": { "type": "String" }
|
||||
}
|
||||
},
|
||||
|
||||
"receiveNow": "Pokea sasa",
|
||||
"receiveLater": "Pokea baadaye",
|
||||
"tokenSavedForLater": "Tokeni imehifadhiwa kudai baadaye",
|
||||
"noConnectionTokenSaved": "Hakuna muunganisho. Tokeni imehifadhiwa kudai baadaye.",
|
||||
"unknownMintOffline": "Tokeni hii ni kutoka mint isiyojulikana. Unganisha na mtandao kuongeza na kudai tokeni.",
|
||||
"noConnectionTryLater": "Hakuna muunganisho na mint. Jaribu baadaye.",
|
||||
"saveTokenError": "Hitilafu ya kuhifadhi tokeni. Jaribu tena.",
|
||||
"pendingTokenLimitReached": "Kikomo cha tokeni zinazosubiri kimefikiwa (upeo 50)",
|
||||
"filterToReceive": "Za kupokea",
|
||||
"noPendingTokens": "Hakuna tokeni zinazosubiri",
|
||||
"noPendingTokensHint": "Hifadhi tokeni kudai baadaye",
|
||||
"pendingBadge": "INASUBIRI",
|
||||
"expiresInDays": "{days, plural, =1{Inaisha siku 1} other{Inaisha siku {days}}}",
|
||||
"@expiresInDays": {
|
||||
"placeholders": {
|
||||
"days": { "type": "int" }
|
||||
}
|
||||
},
|
||||
"retryCount": "majaribio {count}",
|
||||
"@retryCount": {
|
||||
"placeholders": {
|
||||
"count": { "type": "int" }
|
||||
}
|
||||
},
|
||||
"claimNow": "Dai sasa",
|
||||
"pendingTokenClaimedSuccess": "Zimedaiwa {amount} {unit}",
|
||||
"@pendingTokenClaimedSuccess": {
|
||||
"placeholders": {
|
||||
"amount": { "type": "String" },
|
||||
"unit": { "type": "String" }
|
||||
}
|
||||
},
|
||||
"pendingTokensClaimed": "{count, plural, =1{Imedaiwa tokeni 1 ({amount} {unit})} other{Zimedaiwa tokeni {count} ({amount} {unit})}}",
|
||||
"@pendingTokensClaimed": {
|
||||
"placeholders": {
|
||||
"count": { "type": "int" },
|
||||
"amount": { "type": "String" },
|
||||
"unit": { "type": "String" }
|
||||
}
|
||||
},
|
||||
|
||||
"scan": "Changanua",
|
||||
"scanQrCode": "Changanua QR",
|
||||
"scanCashuToken": "Changanua tokeni ya Cashu",
|
||||
"scanLightningInvoice": "Changanua ankara",
|
||||
"scanningAnimatedQr": "Kuchanganua QR inayosogea...",
|
||||
"pointCameraAtQr": "Elekeza kamera kwenye msimbo wa QR",
|
||||
"pointCameraAtCashuQr": "Elekeza kamera kwenye QR ya tokeni ya Cashu",
|
||||
"pointCameraAtInvoiceQr": "Elekeza kamera kwenye QR ya ankara",
|
||||
"unrecognizedQrCode": "Msimbo wa QR haujatambuliwa",
|
||||
"scanCashuTokenHint": "Changanua tokeni ya Cashu (cashuA... au cashuB...)",
|
||||
"scanLightningInvoiceHint": "Changanua ankara ya Lightning (lnbc...)",
|
||||
"addMintQuestion": "Ongeza mint hii?",
|
||||
"cameraPermissionDenied": "Ruhusa ya kamera imekataliwa",
|
||||
"paymentRequestNotSupported": "Maombi ya malipo bado hayatumiki",
|
||||
"p2pkTitle": "Funguo za P2PK",
|
||||
"p2pkSettingsDescription": "Pokea ecash iliyofungwa",
|
||||
"p2pkExperimental": "P2PK ni ya majaribio. Tumia kwa uangalifu.",
|
||||
"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": "Funga kwa ufunguo wa umma",
|
||||
"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"
|
||||
}
|
||||
@@ -0,0 +1,520 @@
|
||||
{
|
||||
"@@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个)",
|
||||
"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": "代币已复制到剪贴板",
|
||||
|
||||
"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 钱包,面向全世界。La Chispa 的兄弟项目。",
|
||||
"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": "离线发送",
|
||||
"selectNotesToSend": "选择要发送的票据:",
|
||||
"totalToSend": "发送总额",
|
||||
"notesSelected": "已选择 {count} 张票据",
|
||||
"@notesSelected": {
|
||||
"placeholders": {
|
||||
"count": { "type": "int" }
|
||||
}
|
||||
},
|
||||
"loadingProofsError": "加载证明错误:{error}",
|
||||
"@loadingProofsError": {
|
||||
"placeholders": {
|
||||
"error": { "type": "String" }
|
||||
}
|
||||
},
|
||||
"creatingTokenError": "创建代币错误:{error}",
|
||||
"@creatingTokenError": {
|
||||
"placeholders": {
|
||||
"error": { "type": "String" }
|
||||
}
|
||||
},
|
||||
|
||||
"unknownState": "未知状态",
|
||||
"depositAmountTitle": "存入 {amount} {unit}",
|
||||
"@depositAmountTitle": {
|
||||
"placeholders": {
|
||||
"amount": { "type": "String" },
|
||||
"unit": { "type": "String" }
|
||||
}
|
||||
},
|
||||
|
||||
"receiveNow": "立即接收",
|
||||
"receiveLater": "稍后接收",
|
||||
"tokenSavedForLater": "代币已保存,稍后领取",
|
||||
"noConnectionTokenSaved": "无连接。代币已保存,稍后领取。",
|
||||
"unknownMintOffline": "此代币来自未知铸造厂。请连接互联网以添加并领取代币。",
|
||||
"noConnectionTryLater": "无法连接铸造厂。请稍后重试。",
|
||||
"saveTokenError": "保存代币错误。请重试。",
|
||||
"pendingTokenLimitReached": "待处理代币达到上限(最多50个)",
|
||||
"filterToReceive": "待接收",
|
||||
"noPendingTokens": "没有待处理代币",
|
||||
"noPendingTokensHint": "保存代币稍后领取",
|
||||
"pendingBadge": "待处理",
|
||||
"expiresInDays": "{days, plural, =1{1天后过期} other{{days}天后过期}}",
|
||||
"@expiresInDays": {
|
||||
"placeholders": {
|
||||
"days": { "type": "int" }
|
||||
}
|
||||
},
|
||||
"retryCount": "{count} 次重试",
|
||||
"@retryCount": {
|
||||
"placeholders": {
|
||||
"count": { "type": "int" }
|
||||
}
|
||||
},
|
||||
"claimNow": "立即领取",
|
||||
"pendingTokenClaimedSuccess": "已领取 {amount} {unit}",
|
||||
"@pendingTokenClaimedSuccess": {
|
||||
"placeholders": {
|
||||
"amount": { "type": "String" },
|
||||
"unit": { "type": "String" }
|
||||
}
|
||||
},
|
||||
"pendingTokensClaimed": "{count, plural, =1{已领取1个代币({amount} {unit})} other{已领取{count}个代币({amount} {unit})}}",
|
||||
"@pendingTokensClaimed": {
|
||||
"placeholders": {
|
||||
"count": { "type": "int" },
|
||||
"amount": { "type": "String" },
|
||||
"unit": { "type": "String" }
|
||||
}
|
||||
},
|
||||
|
||||
"scan": "扫描",
|
||||
"scanQrCode": "扫描二维码",
|
||||
"scanCashuToken": "扫描 Cashu 代币",
|
||||
"scanLightningInvoice": "扫描发票",
|
||||
"scanningAnimatedQr": "正在扫描动态二维码...",
|
||||
"pointCameraAtQr": "将相机对准二维码",
|
||||
"pointCameraAtCashuQr": "将相机对准 Cashu 代币二维码",
|
||||
"pointCameraAtInvoiceQr": "将相机对准发票二维码",
|
||||
"unrecognizedQrCode": "无法识别的二维码",
|
||||
"scanCashuTokenHint": "扫描 Cashu 代币(cashuA... 或 cashuB...)",
|
||||
"scanLightningInvoiceHint": "扫描闪电发票(lnbc...)",
|
||||
"addMintQuestion": "添加此铸造厂?",
|
||||
"cameraPermissionDenied": "相机权限被拒绝",
|
||||
"paymentRequestNotSupported": "付款请求尚不支持",
|
||||
"p2pkTitle": "P2PK密钥",
|
||||
"p2pkSettingsDescription": "接收锁定的ecash",
|
||||
"p2pkExperimental": "P2PK是实验性功能。请谨慎使用。",
|
||||
"p2pkExperimentalShort": "实验性",
|
||||
"p2pkPrimaryKey": "主密钥",
|
||||
"p2pkDerived": "派生的",
|
||||
"p2pkImported": "已导入",
|
||||
"p2pkImportedKeys": "已导入的密钥",
|
||||
"p2pkNoImportedKeys": "没有已导入的密钥",
|
||||
"p2pkShowQR": "显示二维码",
|
||||
"p2pkCopy": "复制",
|
||||
"p2pkImportNsec": "导入nsec",
|
||||
"p2pkImport": "导入",
|
||||
"p2pkEnterLabel": "此密钥的名称",
|
||||
"p2pkLockToKey": "锁定到公钥",
|
||||
"p2pkLockDescription": "只有接收者可以领取",
|
||||
"p2pkReceiverPubkey": "npub1... 或 hex(64/66字符)",
|
||||
"p2pkInvalidPubkey": "无效的公钥",
|
||||
"p2pkInvalidPrivateKey": "无效的私钥",
|
||||
"p2pkLockedToYou": "已锁定给您",
|
||||
"p2pkLockedToOther": "已锁定给其他密钥",
|
||||
"p2pkCannotUnlock": "您没有解锁此代币的密钥",
|
||||
"p2pkEnterPrivateKey": "输入私钥(nsec)",
|
||||
"p2pkDeleteTitle": "删除密钥",
|
||||
"p2pkDeleteConfirm": "删除此密钥?您将无法接收锁定给它的代币。",
|
||||
"p2pkRequiresConnection": "P2PK需要连接到铸造厂",
|
||||
"p2pkErrorMaxKeysReached": "已达到导入密钥的最大数量(10)",
|
||||
"p2pkErrorInvalidNsec": "无效的nsec",
|
||||
"p2pkErrorKeyAlreadyExists": "此密钥已存在",
|
||||
"p2pkErrorKeyNotFound": "未找到密钥",
|
||||
"p2pkErrorCannotDeletePrimary": "无法删除主密钥"
|
||||
}
|
||||
+20
-1
@@ -7,6 +7,8 @@ import 'package:flutter_gen/gen_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 'screens/1_splash/splash_screen.dart';
|
||||
|
||||
void main() async {
|
||||
@@ -30,11 +32,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 +54,8 @@ class ElCajuApp extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final settingsProvider = context.watch<SettingsProvider>();
|
||||
|
||||
return MaterialApp(
|
||||
title: 'ElCaju',
|
||||
debugShowCheckedModeBanner: false,
|
||||
@@ -61,8 +71,17 @@ 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),
|
||||
|
||||
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 'package:cdk_flutter/cdk_flutter.dart' as cdk;
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
import '../models/p2pk_key.dart';
|
||||
import '../core/utils/nostr_utils.dart';
|
||||
import '../core/utils/p2pk_utils.dart';
|
||||
|
||||
/// Códigos de error para operaciones P2PK
|
||||
enum P2PKErrorCode {
|
||||
maxKeysReached,
|
||||
invalidNsec,
|
||||
keyAlreadyExists,
|
||||
keyNotFound,
|
||||
cannotDeletePrimaryKey,
|
||||
}
|
||||
|
||||
/// Excepción tipada para errores P2PK (traducir en UI con L10n)
|
||||
class P2PKException implements Exception {
|
||||
final P2PKErrorCode code;
|
||||
|
||||
const P2PKException(this.code);
|
||||
|
||||
@override
|
||||
String toString() => 'P2PKException: $code';
|
||||
}
|
||||
|
||||
class P2PKProvider extends ChangeNotifier {
|
||||
static const _storageKey = 'p2pk_keys';
|
||||
static const _maxImportedKeys = 10;
|
||||
|
||||
final FlutterSecureStorage _secureStorage;
|
||||
|
||||
List<P2PKKey> _keys = [];
|
||||
P2PKKey? _primaryKey;
|
||||
bool _isInitialized = false;
|
||||
|
||||
P2PKProvider({FlutterSecureStorage? secureStorage})
|
||||
: _secureStorage = secureStorage ?? const FlutterSecureStorage();
|
||||
|
||||
// ============ GETTERS ============
|
||||
|
||||
/// Todas las claves (principal + importadas)
|
||||
List<P2PKKey> get keys => List.unmodifiable(_keys);
|
||||
|
||||
/// Clave principal derivada del mnemonic
|
||||
P2PKKey? get primaryKey => _primaryKey;
|
||||
|
||||
/// Solo claves importadas (no derivadas)
|
||||
List<P2PKKey> get importedKeys =>
|
||||
_keys.where((k) => !k.isDerived).toList();
|
||||
|
||||
/// Si el provider está inicializado
|
||||
bool get isInitialized => _isInitialized;
|
||||
|
||||
/// Cantidad de claves importadas
|
||||
int get importedCount => importedKeys.length;
|
||||
|
||||
/// Si se pueden importar más claves
|
||||
bool get canImportMore => importedCount < _maxImportedKeys;
|
||||
|
||||
// ============ INICIALIZACIÓN ============
|
||||
|
||||
/// Inicializa el provider derivando la clave principal del mnemonic
|
||||
Future<void> initialize(String mnemonic) async {
|
||||
// Cargar claves guardadas
|
||||
await _loadKeys();
|
||||
|
||||
// Siempre derivar para comparar con la almacenada
|
||||
final derived = await _deriveFromMnemonic(mnemonic);
|
||||
|
||||
if (_primaryKey == null) {
|
||||
// Primera vez: guardar clave derivada
|
||||
_primaryKey = derived;
|
||||
_keys.insert(0, _primaryKey!);
|
||||
await _saveKeys();
|
||||
} else if (_primaryKey!.publicKey != derived.publicKey) {
|
||||
// Mnemonic cambió (ej: wallet restore) — reemplazar clave primaria
|
||||
_keys.removeWhere((k) => k.isDerived);
|
||||
_primaryKey = derived;
|
||||
_keys.insert(0, _primaryKey!);
|
||||
await _saveKeys();
|
||||
}
|
||||
|
||||
_isInitialized = true;
|
||||
notifyListeners();
|
||||
|
||||
debugPrint('[P2PKProvider] Inicializado con ${_keys.length} claves');
|
||||
debugPrint('[P2PKProvider] Clave principal: ${_primaryKey?.npub.substring(0, 20)}...');
|
||||
}
|
||||
|
||||
// ============ GESTIÓN DE CLAVES ============
|
||||
|
||||
/// Importa una clave desde nsec
|
||||
Future<P2PKKey> importFromNsec(String nsec, String label) async {
|
||||
if (!canImportMore) {
|
||||
throw const P2PKException(P2PKErrorCode.maxKeysReached);
|
||||
}
|
||||
|
||||
// Limpiar input
|
||||
String cleanNsec = nsec.trim();
|
||||
if (cleanNsec.startsWith('nostr:')) {
|
||||
cleanNsec = cleanNsec.substring(6);
|
||||
}
|
||||
|
||||
final privateKeyHex = NostrUtils.nsecToHex(cleanNsec);
|
||||
if (privateKeyHex == null) {
|
||||
throw const P2PKException(P2PKErrorCode.invalidNsec);
|
||||
}
|
||||
|
||||
// Obtener pubkey usando cdk-flutter
|
||||
final publicKeyHex = cdk.getPubKey(secret: privateKeyHex);
|
||||
|
||||
// Verificar que no exista ya
|
||||
if (_keys.any((k) => k.publicKey == publicKeyHex)) {
|
||||
throw const P2PKException(P2PKErrorCode.keyAlreadyExists);
|
||||
}
|
||||
|
||||
final key = P2PKKey(
|
||||
id: const Uuid().v4(),
|
||||
publicKey: publicKeyHex,
|
||||
privateKey: privateKeyHex,
|
||||
isDerived: false,
|
||||
label: label.isEmpty ? 'Importada ${importedCount + 1}' : label,
|
||||
createdAt: DateTime.now(),
|
||||
);
|
||||
|
||||
_keys.add(key);
|
||||
await _saveKeys();
|
||||
notifyListeners();
|
||||
|
||||
debugPrint('[P2PKProvider] Clave importada: ${key.npub.substring(0, 20)}...');
|
||||
return key;
|
||||
}
|
||||
|
||||
/// Elimina una clave importada (no la principal)
|
||||
Future<void> removeKey(String keyId) async {
|
||||
final key = _keys.cast<P2PKKey?>().firstWhere(
|
||||
(k) => k?.id == keyId,
|
||||
orElse: () => null,
|
||||
);
|
||||
|
||||
if (key == null) {
|
||||
throw const P2PKException(P2PKErrorCode.keyNotFound);
|
||||
}
|
||||
|
||||
if (key.isDerived) {
|
||||
throw const P2PKException(P2PKErrorCode.cannotDeletePrimaryKey);
|
||||
}
|
||||
|
||||
_keys.removeWhere((k) => k.id == keyId);
|
||||
await _saveKeys();
|
||||
notifyListeners();
|
||||
|
||||
debugPrint('[P2PKProvider] Clave eliminada: $keyId');
|
||||
}
|
||||
|
||||
/// Actualiza el label de una clave
|
||||
Future<void> updateLabel(String keyId, String newLabel) async {
|
||||
final index = _keys.indexWhere((k) => k.id == keyId);
|
||||
if (index < 0) return;
|
||||
|
||||
_keys[index] = _keys[index].copyWith(label: newLabel);
|
||||
await _saveKeys();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
// ============ DETECCIÓN DE TOKENS P2PK ============
|
||||
|
||||
/// Verifica si un token está bloqueado con P2PK
|
||||
bool isTokenLocked(String encodedToken) {
|
||||
return P2PKUtils.isP2PKLocked(encodedToken);
|
||||
}
|
||||
|
||||
/// Verifica si un token está bloqueado para alguna de nuestras claves
|
||||
bool isTokenLockedToUs(String encodedToken) {
|
||||
final lockedPubkey = P2PKUtils.extractLockedPubkey(encodedToken);
|
||||
if (lockedPubkey == null) return false;
|
||||
|
||||
// Normalizar a x-only (64 chars) para comparación
|
||||
final normalizedLocked = _normalizeToXOnly(lockedPubkey);
|
||||
return _keys.any((k) => _normalizeToXOnly(k.publicKey) == normalizedLocked);
|
||||
}
|
||||
|
||||
/// Obtiene la clave privada para desbloquear un token
|
||||
/// Prueba todas las claves automáticamente
|
||||
String? getPrivateKeyForToken(String encodedToken) {
|
||||
final lockedPubkey = P2PKUtils.extractLockedPubkey(encodedToken);
|
||||
if (lockedPubkey == null) return null;
|
||||
|
||||
// Normalizar a x-only (64 chars) para comparación
|
||||
final normalizedLocked = _normalizeToXOnly(lockedPubkey);
|
||||
final key = _keys.cast<P2PKKey?>().firstWhere(
|
||||
(k) => k != null && _normalizeToXOnly(k.publicKey) == normalizedLocked,
|
||||
orElse: () => null,
|
||||
);
|
||||
|
||||
return key?.privateKey;
|
||||
}
|
||||
|
||||
/// Normaliza pubkey a formato x-only (64 chars) para comparación
|
||||
/// Si es SEC1 (66 chars con prefijo 02/03), quita el prefijo
|
||||
String _normalizeToXOnly(String pubkey) {
|
||||
final lower = pubkey.toLowerCase();
|
||||
if (lower.length == 66 && (lower.startsWith('02') || lower.startsWith('03'))) {
|
||||
return lower.substring(2);
|
||||
}
|
||||
return lower;
|
||||
}
|
||||
|
||||
/// Extrae la pubkey bloqueada de un token
|
||||
String? extractLockedPubkey(String encodedToken) {
|
||||
return P2PKUtils.extractLockedPubkey(encodedToken);
|
||||
}
|
||||
|
||||
// ============ VALIDACIONES ============
|
||||
|
||||
/// Valida si un input es una pubkey válida (npub o hex)
|
||||
bool isValidPubkey(String input) {
|
||||
return NostrUtils.isValidPubkey(input);
|
||||
}
|
||||
|
||||
/// Normaliza input (npub/hex/nostr:npub) a hex
|
||||
String? normalizeToHex(String input) {
|
||||
return NostrUtils.normalizeToHex(input);
|
||||
}
|
||||
|
||||
// ============ MÉTODOS PRIVADOS ============
|
||||
|
||||
/// Deriva la clave principal del mnemonic usando NIP-06
|
||||
/// Path: m/44'/1237'/0'/0/0
|
||||
Future<P2PKKey> _deriveFromMnemonic(String mnemonic) async {
|
||||
// 1. Mnemonic -> Seed (64 bytes) usando cdk-flutter
|
||||
final seed = cdk.mnemonicToSeed(mnemonic: mnemonic);
|
||||
|
||||
// 2. Seed -> BIP32 root
|
||||
final root = bip32.BIP32.fromSeed(seed);
|
||||
|
||||
// 3. Derivar con path NIP-06: m/44'/1237'/0'/0/0
|
||||
final child = root.derivePath("m/44'/1237'/0'/0/0");
|
||||
|
||||
// 4. Obtener private key hex
|
||||
final privateKeyHex = _bytesToHex(child.privateKey!);
|
||||
|
||||
// 5. Obtener public key usando cdk-flutter
|
||||
final publicKeyHex = cdk.getPubKey(secret: privateKeyHex);
|
||||
|
||||
return P2PKKey(
|
||||
id: 'primary',
|
||||
publicKey: publicKeyHex,
|
||||
privateKey: privateKeyHex,
|
||||
isDerived: true,
|
||||
label: 'Principal',
|
||||
createdAt: DateTime.now(),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _loadKeys() async {
|
||||
try {
|
||||
final json = await _secureStorage.read(key: _storageKey);
|
||||
if (json != null) {
|
||||
final List<dynamic> list = jsonDecode(json);
|
||||
_keys = list.map((e) => P2PKKey.fromJson(e as Map<String, dynamic>)).toList();
|
||||
_primaryKey = _keys.cast<P2PKKey?>().firstWhere(
|
||||
(k) => k?.isDerived == true,
|
||||
orElse: () => null,
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('[P2PKProvider] Error cargando claves: $e');
|
||||
_keys = [];
|
||||
_primaryKey = null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _saveKeys() async {
|
||||
final json = jsonEncode(_keys.map((k) => k.toJson()).toList());
|
||||
await _secureStorage.write(key: _storageKey, value: json);
|
||||
}
|
||||
|
||||
String _bytesToHex(List<int> bytes) {
|
||||
return bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join();
|
||||
}
|
||||
|
||||
/// Limpia todas las claves (usado al borrar wallet)
|
||||
Future<void> clear() async {
|
||||
_keys = [];
|
||||
_primaryKey = null;
|
||||
_isInitialized = false;
|
||||
await _secureStorage.delete(key: _storageKey);
|
||||
notifyListeners();
|
||||
debugPrint('[P2PKProvider] Claves eliminadas');
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,10 @@ import 'package:cdk_flutter/cdk_flutter.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
import '../data/transaction_meta_storage.dart';
|
||||
import '../data/pending_token.dart';
|
||||
import '../data/pending_token_storage.dart';
|
||||
|
||||
/// Helper class para info de token parseado
|
||||
class TokenInfo {
|
||||
@@ -29,6 +33,15 @@ class WalletProvider extends ChangeNotifier {
|
||||
WalletDatabase? _db;
|
||||
String? _mnemonic;
|
||||
|
||||
/// Storage para metadata de transacciones (tipo, token, invoice)
|
||||
final TransactionMetaStorage _txMetaStorage = TransactionMetaStorage();
|
||||
|
||||
/// Storage para tokens pendientes de reclamar (Receive Later)
|
||||
final PendingTokenStorage _pendingTokenStorage = PendingTokenStorage();
|
||||
|
||||
/// Generador de UUIDs
|
||||
static const _uuid = Uuid();
|
||||
|
||||
/// Mints conocidos con sus unidades soportadas.
|
||||
/// Ejemplo: {'mint.cubabitcoin.org': ['sat', 'usd']}
|
||||
final Map<String, List<String>> _mintUnits = {};
|
||||
@@ -41,6 +54,9 @@ class WalletProvider extends ChangeNotifier {
|
||||
/// Ejemplo: {'00abc123': 'sat', '00def456': 'usd'}
|
||||
final Map<String, String> _keysetUnits = {};
|
||||
|
||||
/// Caché de MintInfo por URL (nombre, logo, contactos, etc.)
|
||||
final Map<String, MintInfo> _mintInfoCache = {};
|
||||
|
||||
/// Mint activo actualmente
|
||||
String? _activeMintUrl;
|
||||
|
||||
@@ -79,6 +95,19 @@ class WalletProvider extends ChangeNotifier {
|
||||
/// Lista de URLs de mints conocidos (orden de inserción)
|
||||
List<String> get mintUrls => _mintUnits.keys.toList();
|
||||
|
||||
// ============================================================
|
||||
// PENDING TOKENS GETTERS
|
||||
// ============================================================
|
||||
|
||||
/// Cantidad de tokens pendientes de reclamar
|
||||
int get pendingTokenCount => _pendingTokenStorage.count;
|
||||
|
||||
/// Verifica si hay tokens pendientes
|
||||
bool get hasPendingTokens => _pendingTokenStorage.hasPendingTokens;
|
||||
|
||||
/// Stream de cambios en pending tokens
|
||||
Stream<void> get pendingTokenChanges => _pendingTokenStorage.changes;
|
||||
|
||||
/// Lista de mints ordenados por balance.
|
||||
/// Cuba Bitcoin siempre primero, luego ordenados por: sats → usd → eur → otros.
|
||||
Future<List<String>> getSortedMintUrls() async {
|
||||
@@ -136,6 +165,81 @@ class WalletProvider extends ChangeNotifier {
|
||||
return result;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// CONNECTIVITY
|
||||
// ============================================================
|
||||
|
||||
/// Verifica si podemos alcanzar un mint específico.
|
||||
/// Hace ping HTTP GET a {mintUrl}/v1/info con timeout de 3 segundos.
|
||||
/// Retorna true si responde 200, false en cualquier otro caso.
|
||||
Future<bool> canReachMint(String mintUrl) async {
|
||||
try {
|
||||
final uri = Uri.parse('$mintUrl/v1/info');
|
||||
final response = await http.get(uri).timeout(
|
||||
const Duration(seconds: 3),
|
||||
);
|
||||
return response.statusCode == 200;
|
||||
} catch (e) {
|
||||
debugPrint('Ping to mint failed: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// MINT INFO
|
||||
// ============================================================
|
||||
|
||||
/// Obtiene la info de un mint (nombre, logo, contactos, etc.)
|
||||
/// Usa caché para evitar llamadas repetidas.
|
||||
Future<MintInfo?> fetchMintInfo(String mintUrl) async {
|
||||
// Revisar caché primero
|
||||
if (_mintInfoCache.containsKey(mintUrl)) {
|
||||
return _mintInfoCache[mintUrl];
|
||||
}
|
||||
|
||||
try {
|
||||
final info = await getMintInfo(mintUrl: mintUrl);
|
||||
_mintInfoCache[mintUrl] = info;
|
||||
return info;
|
||||
} catch (e) {
|
||||
debugPrint('Error fetching mint info for $mintUrl: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Obtiene MintInfo del caché (sin fetch)
|
||||
MintInfo? getCachedMintInfo(String mintUrl) {
|
||||
return _mintInfoCache[mintUrl];
|
||||
}
|
||||
|
||||
/// Obtiene el nombre del mint (del caché o display name)
|
||||
String getMintName(String mintUrl) {
|
||||
final cached = _mintInfoCache[mintUrl];
|
||||
if (cached?.name != null && cached!.name!.isNotEmpty) {
|
||||
return cached.name!;
|
||||
}
|
||||
// Fallback: extraer nombre del URL
|
||||
return _getMintDisplayName(mintUrl);
|
||||
}
|
||||
|
||||
/// Obtiene el URL del icono del mint (del caché)
|
||||
String? getMintIconUrl(String mintUrl) {
|
||||
return _mintInfoCache[mintUrl]?.iconUrl;
|
||||
}
|
||||
|
||||
/// Helper: extrae nombre legible del URL del mint
|
||||
String _getMintDisplayName(String mintUrl) {
|
||||
try {
|
||||
final uri = Uri.parse(mintUrl);
|
||||
var host = uri.host;
|
||||
host = host.replaceFirst('mint.', '');
|
||||
host = host.replaceFirst('www.', '');
|
||||
return host;
|
||||
} catch (e) {
|
||||
return mintUrl;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// PERSISTENCIA DE MINTS
|
||||
// ============================================================
|
||||
@@ -210,6 +314,12 @@ class WalletProvider extends ChangeNotifier {
|
||||
Future<void> initialize(String mnemonic) async {
|
||||
_mnemonic = mnemonic;
|
||||
|
||||
// Inicializar storage de metadata de transacciones
|
||||
await _txMetaStorage.init();
|
||||
|
||||
// Inicializar storage de tokens pendientes
|
||||
await _pendingTokenStorage.init();
|
||||
|
||||
// Obtener directorio de documentos (path absoluto requerido)
|
||||
final dir = await getApplicationDocumentsDirectory();
|
||||
final dbPath = '${dir.path}/elcaju_wallet.sqlite';
|
||||
@@ -674,8 +784,9 @@ class WalletProvider extends ChangeNotifier {
|
||||
/// Reclama un token Cashu detectando automáticamente su unidad.
|
||||
/// Si el mint del token es diferente al activo, lo agrega.
|
||||
/// NO cambia la unidad activa (comportamiento similar a cashu.me).
|
||||
/// Si el token es P2PK, pasar la clave privada en p2pkPrivateKey.
|
||||
/// Retorna monto recibido.
|
||||
Future<BigInt> receiveToken(String encodedToken) async {
|
||||
Future<BigInt> receiveToken(String encodedToken, {String? p2pkPrivateKey}) async {
|
||||
// Parsear token (incluye detección de unidad)
|
||||
final tokenInfo = parseToken(encodedToken);
|
||||
if (tokenInfo == null) {
|
||||
@@ -692,13 +803,13 @@ class WalletProvider extends ChangeNotifier {
|
||||
// Intentar detectar unidad de nuevo ahora que tenemos keysets
|
||||
final detectedUnit = detectTokenUnit(encodedToken);
|
||||
if (detectedUnit != null) {
|
||||
return await _receiveWithUnit(encodedToken, tokenMint, detectedUnit);
|
||||
return await _receiveWithUnit(encodedToken, tokenMint, detectedUnit, p2pkPrivateKey);
|
||||
}
|
||||
}
|
||||
|
||||
// Si tenemos unidad detectada, usarla
|
||||
if (tokenInfo.unit != null) {
|
||||
return await _receiveWithUnit(encodedToken, tokenMint, tokenInfo.unit!);
|
||||
return await _receiveWithUnit(encodedToken, tokenMint, tokenInfo.unit!, p2pkPrivateKey);
|
||||
}
|
||||
|
||||
// Fallback: intentar con cada unidad del mint hasta que funcione
|
||||
@@ -707,7 +818,7 @@ class WalletProvider extends ChangeNotifier {
|
||||
|
||||
for (final unit in units) {
|
||||
try {
|
||||
return await _receiveWithUnit(encodedToken, tokenMint, unit);
|
||||
return await _receiveWithUnit(encodedToken, tokenMint, unit, p2pkPrivateKey);
|
||||
} catch (e) {
|
||||
lastError = e as Exception;
|
||||
debugPrint('Receive falló con unidad $unit: $e');
|
||||
@@ -720,21 +831,57 @@ class WalletProvider extends ChangeNotifier {
|
||||
}
|
||||
|
||||
/// Recibe un token con una unidad específica.
|
||||
/// Guarda metadata type=cashu para identificar en historial.
|
||||
Future<BigInt> _receiveWithUnit(
|
||||
String encodedToken,
|
||||
String mintUrl,
|
||||
String unit,
|
||||
String? p2pkPrivateKey,
|
||||
) async {
|
||||
final wallet = await getWallet(mintUrl, unit);
|
||||
final token = Token.parse(encoded: encodedToken);
|
||||
final amount = await wallet.receive(token: token);
|
||||
|
||||
// Si hay clave P2PK, usarla para desbloquear el token
|
||||
final opts = p2pkPrivateKey != null
|
||||
? ReceiveOptions(signingKeys: [p2pkPrivateKey])
|
||||
: null;
|
||||
|
||||
final amount = await wallet.receive(token: token, opts: opts);
|
||||
|
||||
// Guardar metadata para la transacción recién creada
|
||||
await _saveMetaForRecentReceive(wallet, encodedToken);
|
||||
|
||||
debugPrint('Token recibido: $amount $unit en $mintUrl');
|
||||
notifyListeners();
|
||||
return amount;
|
||||
}
|
||||
|
||||
/// Guarda metadata para la transacción de receive más reciente.
|
||||
Future<void> _saveMetaForRecentReceive(Wallet wallet, String tokenEncoded) async {
|
||||
try {
|
||||
final txs = await wallet.listTransactions(
|
||||
direction: TransactionDirection.incoming,
|
||||
);
|
||||
|
||||
if (txs.isNotEmpty) {
|
||||
final recentTx = txs.first;
|
||||
|
||||
await _txMetaStorage.save(
|
||||
recentTx.id,
|
||||
TransactionMeta(
|
||||
type: TransactionType.cashu,
|
||||
token: tokenEncoded,
|
||||
),
|
||||
);
|
||||
debugPrint('Receive metadata guardada para tx ${recentTx.id}');
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Error guardando receive metadata: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Reclama un token P2PK (bloqueado a una clave pública).
|
||||
/// Usa la unidad del token automáticamente (no requiere cambiar unidad activa).
|
||||
Future<BigInt> receiveP2pkToken(
|
||||
String encodedToken,
|
||||
List<String> signingKeys,
|
||||
@@ -744,20 +891,14 @@ class WalletProvider extends ChangeNotifier {
|
||||
throw Exception('Token inválido');
|
||||
}
|
||||
|
||||
// Cambiar al mint del token
|
||||
// Agregar mint si no existe
|
||||
if (!_mintUnits.containsKey(tokenInfo.mintUrl)) {
|
||||
await addMint(tokenInfo.mintUrl);
|
||||
}
|
||||
|
||||
_activeMintUrl = tokenInfo.mintUrl;
|
||||
|
||||
// Verificar si la unidad activa es soportada
|
||||
final units = _mintUnits[tokenInfo.mintUrl]!;
|
||||
if (!units.contains(_activeUnit)) {
|
||||
_activeUnit = units.first;
|
||||
}
|
||||
|
||||
final wallet = await getWallet(tokenInfo.mintUrl, _activeUnit);
|
||||
// Usar la unidad del token (fallback a 'sat' si no especificada)
|
||||
final tokenUnit = tokenInfo.unit ?? 'sat';
|
||||
final wallet = await getWallet(tokenInfo.mintUrl, tokenUnit);
|
||||
final token = Token.parse(encoded: encodedToken);
|
||||
|
||||
final amount = await wallet.receive(
|
||||
@@ -781,14 +922,24 @@ class WalletProvider extends ChangeNotifier {
|
||||
|
||||
/// Prepara un envío P2PK (bloqueado a una clave pública).
|
||||
Future<PreparedSend> prepareSendP2pk(BigInt amount, String pubkey) async {
|
||||
debugPrint('[WalletProvider] prepareSendP2pk: amount=$amount, pubkey=${pubkey.length > 16 ? pubkey.substring(0, 16) : pubkey}...');
|
||||
final wallet = await getActiveWallet();
|
||||
return await wallet.prepareSend(
|
||||
amount: amount,
|
||||
opts: SendOptions(pubkey: pubkey),
|
||||
);
|
||||
debugPrint('[WalletProvider] Got wallet, calling prepareSend with P2PK...');
|
||||
try {
|
||||
final prepared = await wallet.prepareSend(
|
||||
amount: amount,
|
||||
opts: SendOptions(pubkey: pubkey),
|
||||
);
|
||||
debugPrint('[WalletProvider] prepareSendP2pk SUCCESS');
|
||||
return prepared;
|
||||
} catch (e) {
|
||||
debugPrint('[WalletProvider] prepareSendP2pk ERROR: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Confirma un envío preparado y retorna el token encoded.
|
||||
/// Guarda metadata type=cashu para identificar en historial.
|
||||
Future<String> confirmSend(PreparedSend prepared, String? memo) async {
|
||||
final wallet = await getActiveWallet();
|
||||
|
||||
@@ -798,10 +949,41 @@ class WalletProvider extends ChangeNotifier {
|
||||
includeMemo: memo != null && memo.isNotEmpty,
|
||||
);
|
||||
|
||||
// Guardar token en storage local para mostrar en detalles del historial.
|
||||
// Usamos hash del token como key temporal; después buscaremos la transacción.
|
||||
await _saveTokenForRecentTransaction(token.encoded);
|
||||
|
||||
notifyListeners();
|
||||
return token.encoded;
|
||||
}
|
||||
|
||||
/// Guarda el token para la transacción más reciente de tipo send.
|
||||
Future<void> _saveTokenForRecentTransaction(String tokenEncoded) async {
|
||||
try {
|
||||
// Obtener transacciones outgoing más recientes
|
||||
final wallet = await getActiveWallet();
|
||||
final txs = await wallet.listTransactions(
|
||||
direction: TransactionDirection.outgoing,
|
||||
);
|
||||
|
||||
if (txs.isNotEmpty) {
|
||||
// La más reciente debería ser la que acabamos de crear
|
||||
final recentTx = txs.first;
|
||||
|
||||
await _txMetaStorage.save(
|
||||
recentTx.id,
|
||||
TransactionMeta(
|
||||
type: TransactionType.cashu,
|
||||
token: tokenEncoded,
|
||||
),
|
||||
);
|
||||
debugPrint('Token guardado para tx ${recentTx.id}');
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Error guardando token metadata: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Cancela un envío preparado (libera proofs reservados).
|
||||
Future<void> cancelSend(PreparedSend prepared) async {
|
||||
final wallet = await getActiveWallet();
|
||||
@@ -815,6 +997,8 @@ class WalletProvider extends ChangeNotifier {
|
||||
}
|
||||
|
||||
/// Envía tokens P2PK.
|
||||
/// NOTA: Bug conocido en CDK - solo funciona el primer envío después de iniciar la app.
|
||||
/// Ver project_context/P2PK_SEND_BUG.md
|
||||
Future<String> sendTokensP2pk(
|
||||
BigInt amount,
|
||||
String pubkey,
|
||||
@@ -830,36 +1014,113 @@ class WalletProvider extends ChangeNotifier {
|
||||
|
||||
/// Inicia un depósito via Lightning.
|
||||
/// Retorna Stream con estados: unpaid -> paid -> issued.
|
||||
/// Guarda metadata type=lightning cuando se completa.
|
||||
Stream<MintQuote> mintTokens(BigInt amount, String? description) {
|
||||
final wallet = activeWallet;
|
||||
if (wallet == null) {
|
||||
throw Exception('No hay wallet activo');
|
||||
}
|
||||
|
||||
String? invoiceBolt11;
|
||||
|
||||
// Wrapper del stream para capturar el invoice y guardar metadata
|
||||
return wallet.mint(
|
||||
amount: amount,
|
||||
description: description,
|
||||
);
|
||||
).map((quote) {
|
||||
// Capturar el invoice cuando está en estado unpaid
|
||||
if (quote.state == MintQuoteState.unpaid) {
|
||||
invoiceBolt11 = quote.request;
|
||||
}
|
||||
|
||||
// Cuando se completa, guardar metadata
|
||||
if (quote.state == MintQuoteState.issued && invoiceBolt11 != null) {
|
||||
_saveMintMetadata(wallet, invoiceBolt11!);
|
||||
}
|
||||
|
||||
return quote;
|
||||
});
|
||||
}
|
||||
|
||||
/// Guarda metadata para una transacción de mint (Lightning deposit).
|
||||
Future<void> _saveMintMetadata(Wallet wallet, String invoice) async {
|
||||
try {
|
||||
final txs = await wallet.listTransactions(
|
||||
direction: TransactionDirection.incoming,
|
||||
);
|
||||
|
||||
if (txs.isNotEmpty) {
|
||||
final recentTx = txs.first;
|
||||
|
||||
await _txMetaStorage.save(
|
||||
recentTx.id,
|
||||
TransactionMeta(
|
||||
type: TransactionType.lightning,
|
||||
invoice: invoice,
|
||||
),
|
||||
);
|
||||
debugPrint('Mint metadata guardada para tx ${recentTx.id}');
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Error guardando mint metadata: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// MELT (Retirar a Lightning)
|
||||
// ============================================================
|
||||
|
||||
/// Invoice temporal para guardar metadata después de melt
|
||||
String? _pendingMeltInvoice;
|
||||
|
||||
/// Obtiene quote para pagar un invoice BOLT11.
|
||||
/// Guarda el invoice temporalmente para asociarlo a la transacción después.
|
||||
Future<MeltQuote> getMeltQuote(String bolt11Invoice) async {
|
||||
_pendingMeltInvoice = bolt11Invoice;
|
||||
final wallet = await getActiveWallet();
|
||||
return await wallet.meltQuote(request: bolt11Invoice);
|
||||
}
|
||||
|
||||
/// Ejecuta el pago del invoice.
|
||||
/// Guarda metadata type=lightning para identificar en historial.
|
||||
Future<BigInt> melt(MeltQuote quote) async {
|
||||
final wallet = await getActiveWallet();
|
||||
final totalPaid = await wallet.melt(quote: quote);
|
||||
|
||||
// Guardar metadata con el invoice
|
||||
if (_pendingMeltInvoice != null) {
|
||||
await _saveMeltMetadata(wallet, _pendingMeltInvoice!);
|
||||
_pendingMeltInvoice = null;
|
||||
}
|
||||
|
||||
notifyListeners();
|
||||
return totalPaid;
|
||||
}
|
||||
|
||||
/// Guarda metadata para una transacción de melt (Lightning withdrawal).
|
||||
Future<void> _saveMeltMetadata(Wallet wallet, String invoice) async {
|
||||
try {
|
||||
final txs = await wallet.listTransactions(
|
||||
direction: TransactionDirection.outgoing,
|
||||
);
|
||||
|
||||
if (txs.isNotEmpty) {
|
||||
final recentTx = txs.first;
|
||||
|
||||
await _txMetaStorage.save(
|
||||
recentTx.id,
|
||||
TransactionMeta(
|
||||
type: TransactionType.lightning,
|
||||
invoice: invoice,
|
||||
),
|
||||
);
|
||||
debugPrint('Melt metadata guardada para tx ${recentTx.id}');
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Error guardando melt metadata: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// HISTORIAL
|
||||
// ============================================================
|
||||
@@ -894,6 +1155,22 @@ class WalletProvider extends ChangeNotifier {
|
||||
return allTransactions;
|
||||
}
|
||||
|
||||
/// Obtiene el tipo de una transacción (cashu o lightning).
|
||||
/// Busca primero en metadata del CDK, luego en storage local.
|
||||
TransactionType getTransactionType(Transaction tx) {
|
||||
return _txMetaStorage.getType(tx.id, tx.metadata);
|
||||
}
|
||||
|
||||
/// Obtiene metadata adicional de una transacción.
|
||||
TransactionMeta? getTransactionMeta(String transactionId) {
|
||||
return _txMetaStorage.get(transactionId);
|
||||
}
|
||||
|
||||
/// Verifica si una transacción tiene metadata guardada.
|
||||
bool hasTransactionMeta(String transactionId) {
|
||||
return _txMetaStorage.has(transactionId);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// VERIFICACION DE PROOFS
|
||||
// ============================================================
|
||||
@@ -1060,6 +1337,9 @@ class WalletProvider extends ChangeNotifier {
|
||||
_mnemonic = null;
|
||||
_db = null;
|
||||
|
||||
// Limpiar metadata de transacciones
|
||||
await _txMetaStorage.clear();
|
||||
|
||||
// Borrar archivo
|
||||
final dir = await getApplicationDocumentsDirectory();
|
||||
final dbPath = '${dir.path}/elcaju_wallet.sqlite';
|
||||
@@ -1086,4 +1366,159 @@ class WalletProvider extends ChangeNotifier {
|
||||
_db = null;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// PENDING TOKENS (Receive Later)
|
||||
// ============================================================
|
||||
|
||||
/// Guarda un token para reclamar después.
|
||||
/// Retorna el PendingToken creado o null si hay error/límite alcanzado.
|
||||
Future<PendingToken?> addPendingToken(String encodedToken) async {
|
||||
// Parsear token para validar y extraer info
|
||||
final tokenInfo = parseToken(encodedToken);
|
||||
if (tokenInfo == null) {
|
||||
debugPrint('Token inválido para pending');
|
||||
return null;
|
||||
}
|
||||
|
||||
// Generar ID único
|
||||
final id = _uuid.v4();
|
||||
|
||||
// Guardar en storage
|
||||
final pending = await _pendingTokenStorage.add(
|
||||
id: id,
|
||||
encoded: encodedToken,
|
||||
amount: tokenInfo.amount,
|
||||
mintUrl: tokenInfo.mintUrl,
|
||||
unit: tokenInfo.unit,
|
||||
);
|
||||
|
||||
if (pending != null) {
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
return pending;
|
||||
}
|
||||
|
||||
/// Lista todos los tokens pendientes
|
||||
List<PendingToken> listPendingTokens() {
|
||||
return _pendingTokenStorage.listAll();
|
||||
}
|
||||
|
||||
/// Lista solo tokens pendientes válidos (no expirados)
|
||||
List<PendingToken> listValidPendingTokens() {
|
||||
return _pendingTokenStorage.listValid();
|
||||
}
|
||||
|
||||
/// Elimina un token pendiente por ID
|
||||
Future<void> removePendingToken(String id) async {
|
||||
await _pendingTokenStorage.remove(id);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Intenta reclamar un token pendiente.
|
||||
/// Retorna el monto recibido si tiene éxito, o lanza excepción.
|
||||
/// Si el token está gastado o es inválido, lo elimina automáticamente.
|
||||
/// Verifica conectividad al mint antes de intentar reclamar.
|
||||
Future<BigInt> claimPendingToken(String id) async {
|
||||
final pending = _pendingTokenStorage.get(id);
|
||||
if (pending == null) {
|
||||
throw Exception('Token pendiente no encontrado');
|
||||
}
|
||||
|
||||
// Verificar conectividad al mint primero (evita esperas largas sin conexión)
|
||||
final canReach = await canReachMint(pending.mintUrl);
|
||||
if (!canReach) {
|
||||
throw Exception('No connection to mint');
|
||||
}
|
||||
|
||||
try {
|
||||
// Intentar reclamar usando el método existente
|
||||
final amount = await receiveToken(pending.encoded);
|
||||
|
||||
// Éxito: eliminar de pending
|
||||
await _pendingTokenStorage.remove(id);
|
||||
notifyListeners();
|
||||
|
||||
debugPrint('Token pendiente reclamado: $id, monto: $amount');
|
||||
return amount;
|
||||
} catch (e) {
|
||||
final errorStr = e.toString().toLowerCase();
|
||||
|
||||
// Si el token ya fue gastado o es inválido, eliminarlo
|
||||
if (errorStr.contains('already spent') ||
|
||||
errorStr.contains('token already') ||
|
||||
errorStr.contains('invalid')) {
|
||||
await _pendingTokenStorage.remove(id);
|
||||
notifyListeners();
|
||||
debugPrint('Token pendiente eliminado (gastado/inválido): $id');
|
||||
} else {
|
||||
// Otro error: registrar intento fallido
|
||||
await _pendingTokenStorage.recordFailedAttempt(id, e.toString());
|
||||
}
|
||||
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Verifica y reclama automáticamente tokens pendientes.
|
||||
/// Retorna un mapa con estadísticas: claimed, failed, removed, totalClaimed, unit.
|
||||
Future<Map<String, dynamic>> checkPendingTokens() async {
|
||||
final tokens = _pendingTokenStorage.listValid();
|
||||
if (tokens.isEmpty) {
|
||||
return {'claimed': 0, 'failed': 0, 'removed': 0, 'totalClaimed': BigInt.zero, 'unit': _activeUnit};
|
||||
}
|
||||
|
||||
int claimed = 0;
|
||||
int failed = 0;
|
||||
int removed = 0;
|
||||
BigInt totalClaimed = BigInt.zero;
|
||||
String? claimedUnit;
|
||||
|
||||
for (final token in tokens) {
|
||||
try {
|
||||
final amount = await claimPendingToken(token.id);
|
||||
claimed++;
|
||||
totalClaimed += amount;
|
||||
claimedUnit ??= token.unit; // Usar la unidad del primer token reclamado
|
||||
debugPrint('Auto-claim exitoso: ${token.id}');
|
||||
} catch (e) {
|
||||
final errorStr = e.toString().toLowerCase();
|
||||
if (errorStr.contains('already spent') ||
|
||||
errorStr.contains('token already') ||
|
||||
errorStr.contains('invalid')) {
|
||||
removed++;
|
||||
debugPrint('Auto-claim: token eliminado (gastado): ${token.id}');
|
||||
} else {
|
||||
failed++;
|
||||
debugPrint('Auto-claim fallido: ${token.id} - $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Limpiar expirados también
|
||||
final expired = await _pendingTokenStorage.cleanExpired();
|
||||
removed += expired;
|
||||
|
||||
if (claimed > 0 || removed > 0) {
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
return {
|
||||
'claimed': claimed,
|
||||
'failed': failed,
|
||||
'removed': removed,
|
||||
'totalClaimed': totalClaimed,
|
||||
'unit': claimedUnit ?? _activeUnit,
|
||||
};
|
||||
}
|
||||
|
||||
/// Limpia tokens pendientes expirados
|
||||
Future<int> cleanExpiredPendingTokens() async {
|
||||
final cleaned = await _pendingTokenStorage.cleanExpired();
|
||||
if (cleaned > 0) {
|
||||
notifyListeners();
|
||||
}
|
||||
return cleaned;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,365 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:lucide_icons/lucide_icons.dart';
|
||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
import '../../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';
|
||||
|
||||
/// 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:
|
||||
// TODO: Implementar manejo de payment requests (post-MVP)
|
||||
_showError(l10n.paymentRequestNotSupported);
|
||||
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) {
|
||||
// Retornar el invoice vía callback
|
||||
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),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
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';
|
||||
@@ -7,6 +8,7 @@ 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,5 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:lucide_icons/lucide_icons.dart';
|
||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
import '../../core/constants/colors.dart';
|
||||
import '../../core/constants/dimensions.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,5 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:lucide_icons/lucide_icons.dart';
|
||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
import '../../core/constants/colors.dart';
|
||||
import '../../core/constants/dimensions.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,5 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:lucide_icons/lucide_icons.dart';
|
||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
import '../../core/constants/colors.dart';
|
||||
import '../../core/constants/dimensions.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
|
||||
@@ -57,6 +59,7 @@ class _RestoreWalletScreenState extends State<RestoreWalletScreen> {
|
||||
final mnemonic = _seedController.text.trim().toLowerCase();
|
||||
final walletProvider = context.read<WalletProvider>();
|
||||
final settingsProvider = context.read<SettingsProvider>();
|
||||
final p2pkProvider = context.read<P2PKProvider>();
|
||||
|
||||
// Guardar mnemonic de forma segura
|
||||
await settingsProvider.saveMnemonic(mnemonic);
|
||||
@@ -65,6 +68,13 @@ class _RestoreWalletScreenState extends State<RestoreWalletScreen> {
|
||||
// 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');
|
||||
}
|
||||
|
||||
if (mounted) {
|
||||
Navigator.pushAndRemoveUntil(
|
||||
context,
|
||||
@@ -96,7 +106,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 +184,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 +230,7 @@ class _RestoreWalletScreenState extends State<RestoreWalletScreen> {
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.error_outline,
|
||||
LucideIcons.alertCircle,
|
||||
color: AppColors.error,
|
||||
size: 20,
|
||||
),
|
||||
@@ -244,7 +254,7 @@ class _RestoreWalletScreenState extends State<RestoreWalletScreen> {
|
||||
|
||||
PrimaryButton(
|
||||
text: l10n.restoreWallet,
|
||||
icon: Icons.restore,
|
||||
icon: LucideIcons.rotateCcw,
|
||||
isLoading: _isRestoring,
|
||||
onPressed: _isValidWordCount && !_isRestoring
|
||||
? _restoreWallet
|
||||
|
||||
@@ -1,21 +1,47 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:lucide_icons/lucide_icons.dart';
|
||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
import '../../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,
|
||||
|
||||
+367
-480
@@ -1,12 +1,14 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:lucide_icons/lucide_icons.dart';
|
||||
import 'package:cdk_flutter/cdk_flutter.dart' hide WalletProvider;
|
||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
import '../../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';
|
||||
@@ -16,6 +18,8 @@ import '../6_mint/mint_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
|
||||
@@ -33,12 +37,51 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
// Controller para el efecto confeti
|
||||
final CashuConfettiController _confettiController = CashuConfettiController();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// Verificar tokens pendientes al iniciar
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_checkPendingTokens();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_confettiController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// Verifica y reclama automáticamente tokens pendientes
|
||||
Future<void> _checkPendingTokens() async {
|
||||
final walletProvider = context.read<WalletProvider>();
|
||||
if (!walletProvider.hasPendingTokens) return;
|
||||
|
||||
try {
|
||||
final result = await walletProvider.checkPendingTokens();
|
||||
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) {
|
||||
// Disparar confetti
|
||||
_confettiController.fire();
|
||||
|
||||
// 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(
|
||||
@@ -135,6 +178,9 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
final walletProvider = context.watch<WalletProvider>();
|
||||
final settingsProvider = context.read<SettingsProvider>();
|
||||
final activeUnit = walletProvider.activeUnit;
|
||||
// Toggle button: "BTC" para sat (como cashu.me)
|
||||
final toggleLabel = UnitFormatter.getToggleLabel(activeUnit);
|
||||
// Balance label: "sat" minúsculas (como cashu.me)
|
||||
final unitLabel = UnitFormatter.getUnitLabel(activeUnit);
|
||||
|
||||
return Padding(
|
||||
@@ -145,85 +191,59 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
// Ojo encima del saldo (centrado)
|
||||
Center(
|
||||
child: GestureDetector(
|
||||
onTap: () {
|
||||
setState(() {
|
||||
_isBalanceVisible = !_isBalanceVisible;
|
||||
});
|
||||
},
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: Icon(
|
||||
_isBalanceVisible ? LucideIcons.eye : LucideIcons.eyeOff,
|
||||
color: AppColors.textSecondary.withValues(alpha: 0.6),
|
||||
size: 24,
|
||||
),
|
||||
),
|
||||
),
|
||||
// Botón toggle de unidad con efecto de hundirse
|
||||
_UnitToggleButton(
|
||||
label: toggleLabel,
|
||||
onTap: () async {
|
||||
await walletProvider.cycleUnit();
|
||||
await settingsProvider.setActiveUnit(walletProvider.activeUnit);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
|
||||
// Balance reactivo del mint activo
|
||||
const SizedBox(height: 32),
|
||||
|
||||
// Balance tocable para ocultar/mostrar (sin ojito)
|
||||
StreamBuilder<BigInt>(
|
||||
stream: walletProvider.streamBalance(),
|
||||
builder: (context, snapshot) {
|
||||
final balance = snapshot.data ?? BigInt.zero;
|
||||
final formattedBalance = UnitFormatter.formatBalance(balance, activeUnit);
|
||||
|
||||
return Text(
|
||||
_isBalanceVisible ? formattedBalance : '••••••',
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 48,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.white,
|
||||
return GestureDetector(
|
||||
onTap: () {
|
||||
setState(() {
|
||||
_isBalanceVisible = !_isBalanceVisible;
|
||||
});
|
||||
},
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.baseline,
|
||||
textBaseline: TextBaseline.alphabetic,
|
||||
children: [
|
||||
Text(
|
||||
_isBalanceVisible ? formattedBalance : '••••••',
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 48,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
_isBalanceVisible ? unitLabel : '',
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 32,
|
||||
fontWeight: FontWeight.w400,
|
||||
color: Colors.white.withValues(alpha: 0.7),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
|
||||
// Unidad - tap para ciclar
|
||||
GestureDetector(
|
||||
onTap: () async {
|
||||
// Ciclar unidad
|
||||
await walletProvider.cycleUnit();
|
||||
// Guardar en settings
|
||||
await settingsProvider.setActiveUnit(walletProvider.activeUnit);
|
||||
},
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.primaryAction.withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
unitLabel,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.primaryAction,
|
||||
),
|
||||
),
|
||||
// Mostrar indicador si hay más de una unidad
|
||||
if (walletProvider.activeUnits.length > 1) ...[
|
||||
const SizedBox(width: 4),
|
||||
Icon(
|
||||
LucideIcons.refreshCw,
|
||||
color: AppColors.primaryAction.withValues(alpha: 0.7),
|
||||
size: 14,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -234,9 +254,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),
|
||||
@@ -247,66 +268,115 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
MaterialPageRoute(builder: (context) => const MintsScreen()),
|
||||
);
|
||||
},
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.success.withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(
|
||||
color: AppColors.success.withValues(alpha: 0.3),
|
||||
width: 1,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Logo del mint
|
||||
if (activeMintUrl != null)
|
||||
FutureBuilder(
|
||||
future: walletProvider.fetchMintInfo(activeMintUrl),
|
||||
builder: (context, snapshot) {
|
||||
final iconUrl = snapshot.data?.iconUrl;
|
||||
return Container(
|
||||
width: 36,
|
||||
height: 36,
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.primaryAction.withValues(alpha: 0.15),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: iconUrl != null
|
||||
? ClipOval(
|
||||
child: Image.network(
|
||||
iconUrl,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (_, __, ___) => const Icon(
|
||||
LucideIcons.landmark,
|
||||
color: AppColors.primaryAction,
|
||||
size: 18,
|
||||
),
|
||||
),
|
||||
)
|
||||
: const Icon(
|
||||
LucideIcons.landmark,
|
||||
color: AppColors.primaryAction,
|
||||
size: 18,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
// Pill del mint
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.success.withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(
|
||||
color: AppColors.success.withValues(alpha: 0.3),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: 8,
|
||||
height: 8,
|
||||
decoration: BoxDecoration(
|
||||
color: walletProvider.isInitialized
|
||||
? AppColors.success
|
||||
: AppColors.warning,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Text(
|
||||
displayMint,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Icon(
|
||||
LucideIcons.chevronDown,
|
||||
color: Colors.white.withValues(alpha: 0.6),
|
||||
size: 18,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: 8,
|
||||
height: 8,
|
||||
decoration: BoxDecoration(
|
||||
color: walletProvider.isInitialized
|
||||
? AppColors.success
|
||||
: AppColors.warning,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Text(
|
||||
displayMint,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Icon(
|
||||
LucideIcons.chevronDown,
|
||||
color: Colors.white.withValues(alpha: 0.6),
|
||||
size: 18,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildActions() {
|
||||
final l10n = L10n.of(context)!;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(AppDimensions.paddingMedium),
|
||||
child: Row(
|
||||
children: [
|
||||
// Enviar (primero) - flecha diagonal arriba derecha
|
||||
// Enviar (primero) - acción crítica que mueve dinero
|
||||
Expanded(
|
||||
child: _ActionButton(label: 'Enviar ↗', onTap: _showSendOptions),
|
||||
child: AnimatedActionButton(
|
||||
label: l10n.sendAction,
|
||||
type: ButtonType.criticalAction,
|
||||
onTap: _showSendOptions,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: AppDimensions.paddingMedium),
|
||||
// Recibir (segundo) - flecha diagonal abajo derecha
|
||||
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: _ActionButton(
|
||||
label: '↘ Recibir',
|
||||
child: AnimatedActionButton(
|
||||
label: l10n.receiveAction,
|
||||
type: ButtonType.primaryAction,
|
||||
onTap: _showReceiveOptions,
|
||||
),
|
||||
),
|
||||
@@ -315,17 +385,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(
|
||||
@@ -336,8 +446,8 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
),
|
||||
_MethodOption(
|
||||
icon: LucideIcons.zap,
|
||||
label: 'Lightning',
|
||||
description: 'Generar invoice para depositar',
|
||||
label: l10n.lightning,
|
||||
description: l10n.generateInvoiceToDeposit,
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
Navigator.push(
|
||||
@@ -352,16 +462,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(
|
||||
@@ -372,8 +483,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(
|
||||
@@ -388,375 +499,62 @@ 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: SizedBox(
|
||||
width: double.infinity,
|
||||
child: GlassCard(
|
||||
onTap: _showHistoryModal,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: AppDimensions.paddingLarge,
|
||||
vertical: AppDimensions.paddingMedium + 4,
|
||||
),
|
||||
child: const Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(LucideIcons.history, color: Colors.white, size: 28),
|
||||
SizedBox(width: AppDimensions.paddingSmall),
|
||||
Text(
|
||||
'Historial',
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showHistoryModal() {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
backgroundColor: Colors.transparent,
|
||||
isScrollControlled: true,
|
||||
builder: (context) => const _HistoryModal(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Botón de acción para el home
|
||||
class _ActionButton extends StatelessWidget {
|
||||
final String label;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _ActionButton({required this.label, required this.onTap});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
vertical: AppDimensions.paddingMedium,
|
||||
horizontal: AppDimensions.paddingSmall,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
gradient: const LinearGradient(
|
||||
colors: AppColors.buttonGradient,
|
||||
begin: Alignment.centerLeft,
|
||||
end: Alignment.centerRight,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(AppDimensions.cardBorderRadius),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: AppColors.primaryAction.withValues(alpha: 0.4),
|
||||
blurRadius: 12,
|
||||
offset: const Offset(0, 6),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
label,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Modal del historial de transacciones
|
||||
class _HistoryModal extends StatelessWidget {
|
||||
const _HistoryModal();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final walletProvider = context.watch<WalletProvider>();
|
||||
|
||||
return Container(
|
||||
height: MediaQuery.of(context).size.height * 0.7,
|
||||
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(
|
||||
child: Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
// Handle
|
||||
Container(
|
||||
margin: const EdgeInsets.only(top: 12),
|
||||
width: 40,
|
||||
height: 4,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: 0.3),
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
AnimatedActionButton(
|
||||
label: l10n.history,
|
||||
type: ButtonType.navigation,
|
||||
icon: LucideIcons.history,
|
||||
showIcon: true,
|
||||
onTap: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(builder: (context) => const HistoryScreen()),
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
// Título
|
||||
const Padding(
|
||||
padding: EdgeInsets.all(AppDimensions.paddingMedium),
|
||||
child: Text(
|
||||
'Historial',
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Lista de transacciones
|
||||
Expanded(
|
||||
child: FutureBuilder<List<Transaction>>(
|
||||
future: walletProvider.getAllTransactions(),
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.connectionState == ConnectionState.waiting) {
|
||||
return const Center(
|
||||
child: CircularProgressIndicator(
|
||||
color: AppColors.primaryAction,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final transactions = snapshot.data ?? [];
|
||||
|
||||
if (transactions.isEmpty) {
|
||||
return _buildEmptyHistory();
|
||||
}
|
||||
|
||||
return ListView.builder(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: AppDimensions.paddingMedium,
|
||||
),
|
||||
itemCount: transactions.length,
|
||||
itemBuilder: (context, index) {
|
||||
final tx = transactions[index];
|
||||
return _TransactionTile(transaction: tx);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildEmptyHistory() {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
LucideIcons.history,
|
||||
size: 48,
|
||||
color: AppColors.textSecondary.withValues(alpha: 0.3),
|
||||
),
|
||||
const SizedBox(height: AppDimensions.paddingMedium),
|
||||
Text(
|
||||
'Sin transacciones aún',
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 16,
|
||||
color: AppColors.textSecondary.withValues(alpha: 0.5),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppDimensions.paddingSmall),
|
||||
Text(
|
||||
'Recibe tokens Cashu para empezar',
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 14,
|
||||
color: AppColors.textSecondary.withValues(alpha: 0.4),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Tile para mostrar una transacción
|
||||
class _TransactionTile extends StatelessWidget {
|
||||
final Transaction transaction;
|
||||
|
||||
const _TransactionTile({required this.transaction});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isIncoming = transaction.direction == TransactionDirection.incoming;
|
||||
final amount = transaction.amount.toInt();
|
||||
final fee = transaction.fee.toInt();
|
||||
|
||||
// Convertir timestamp (BigInt unix) a DateTime
|
||||
final timestamp = DateTime.fromMillisecondsSinceEpoch(
|
||||
transaction.timestamp.toInt() * 1000,
|
||||
);
|
||||
|
||||
// Formatear fecha
|
||||
final dateStr = _formatDate(timestamp);
|
||||
|
||||
// Estado (pending o settled)
|
||||
final isPending = transaction.status == TransactionStatus.pending;
|
||||
|
||||
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: Colors.white.withValues(alpha: 0.1),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
// Icono de dirección
|
||||
Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: (isIncoming ? AppColors.success : AppColors.primaryAction)
|
||||
.withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Icon(
|
||||
isIncoming ? LucideIcons.arrowDownLeft : LucideIcons.arrowUpRight,
|
||||
color: isIncoming ? AppColors.success : AppColors.primaryAction,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: AppDimensions.paddingMedium),
|
||||
|
||||
// Info de la transacción
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
isIncoming ? 'Recibido' : 'Enviado',
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
if (isPending) ...[
|
||||
const SizedBox(width: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 6,
|
||||
vertical: 2,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.warning.withValues(alpha: 0.2),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: const Text(
|
||||
'Pendiente',
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: AppColors.warning,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
dateStr,
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 12,
|
||||
color: AppColors.textSecondary.withValues(alpha: 0.6),
|
||||
// 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,
|
||||
),
|
||||
),
|
||||
if (transaction.memo != null && transaction.memo!.isNotEmpty) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
transaction.memo!,
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 12,
|
||||
fontStyle: FontStyle.italic,
|
||||
color: AppColors.textSecondary.withValues(alpha: 0.5),
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Monto
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Text(
|
||||
'${isIncoming ? '+' : '-'}$amount',
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: isIncoming ? AppColors.success : AppColors.primaryAction,
|
||||
constraints: const BoxConstraints(
|
||||
minWidth: 20,
|
||||
minHeight: 20,
|
||||
),
|
||||
),
|
||||
if (fee > 0)
|
||||
Text(
|
||||
'fee: $fee',
|
||||
style: TextStyle(
|
||||
child: Text(
|
||||
pendingCount > 9 ? '9+' : pendingCount.toString(),
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 10,
|
||||
color: AppColors.textSecondary.withValues(alpha: 0.5),
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.black,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _formatDate(DateTime date) {
|
||||
final now = DateTime.now();
|
||||
final diff = now.difference(date);
|
||||
|
||||
if (diff.inMinutes < 1) {
|
||||
return 'Ahora';
|
||||
} else if (diff.inHours < 1) {
|
||||
return 'Hace ${diff.inMinutes} min';
|
||||
} else if (diff.inDays < 1) {
|
||||
return 'Hace ${diff.inHours} h';
|
||||
} else if (diff.inDays < 7) {
|
||||
return 'Hace ${diff.inDays} días';
|
||||
} else {
|
||||
return '${date.day}/${date.month}/${date.year}';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Modelo para opciones del selector
|
||||
@@ -829,7 +627,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),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -910,3 +709,91 @@ class _MethodOptionTile extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Botón de toggle de unidad con efecto de hundirse
|
||||
class _UnitToggleButton extends StatefulWidget {
|
||||
final String label;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _UnitToggleButton({
|
||||
required this.label,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
State<_UnitToggleButton> createState() => _UnitToggleButtonState();
|
||||
}
|
||||
|
||||
class _UnitToggleButtonState extends State<_UnitToggleButton>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late AnimationController _controller;
|
||||
late Animation<double> _scaleAnimation;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 100),
|
||||
);
|
||||
_scaleAnimation = Tween<double>(
|
||||
begin: 1.0,
|
||||
end: 0.95,
|
||||
).animate(CurvedAnimation(
|
||||
parent: _controller,
|
||||
curve: Curves.easeOutCubic,
|
||||
));
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _handleTapDown(TapDownDetails details) {
|
||||
_controller.forward();
|
||||
}
|
||||
|
||||
void _handleTapUp(TapUpDetails details) {
|
||||
_controller.reverse();
|
||||
widget.onTap();
|
||||
}
|
||||
|
||||
void _handleTapCancel() {
|
||||
_controller.reverse();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTapDown: _handleTapDown,
|
||||
onTapUp: _handleTapUp,
|
||||
onTapCancel: _handleTapCancel,
|
||||
child: AnimatedBuilder(
|
||||
animation: _controller,
|
||||
builder: (context, child) {
|
||||
return Transform.scale(
|
||||
scale: _scaleAnimation.value,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
),
|
||||
child: Text(
|
||||
widget.label,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
import '../../core/constants/colors.dart';
|
||||
import '../../core/constants/dimensions.dart';
|
||||
import '../../core/utils/formatters.dart';
|
||||
import '../../core/utils/incoming_data_parser.dart' hide TokenInfo;
|
||||
import '../../core/utils/nostr_utils.dart';
|
||||
import '../../widgets/common/gradient_background.dart';
|
||||
import '../../widgets/common/glass_card.dart';
|
||||
import '../../widgets/common/primary_button.dart';
|
||||
import '../../widgets/effects/cashu_confetti.dart';
|
||||
import '../../providers/wallet_provider.dart';
|
||||
import '../../providers/p2pk_provider.dart';
|
||||
import '../10_scanner/scan_screen.dart';
|
||||
|
||||
/// Pantalla para recibir tokens Cashu
|
||||
class ReceiveScreen extends StatefulWidget {
|
||||
const ReceiveScreen({super.key});
|
||||
/// Token inicial (pre-cargado desde QR scanner o deep link)
|
||||
final String? initialToken;
|
||||
|
||||
const ReceiveScreen({super.key, this.initialToken});
|
||||
|
||||
@override
|
||||
State<ReceiveScreen> createState() => _ReceiveScreenState();
|
||||
@@ -32,9 +40,31 @@ class _ReceiveScreenState extends State<ReceiveScreen> {
|
||||
TokenInfo? _tokenInfo;
|
||||
String? _errorMessage;
|
||||
|
||||
// Estado P2PK
|
||||
bool _isP2PKLocked = false;
|
||||
bool _isLockedToUs = false;
|
||||
String? _lockedToPubkeyHex;
|
||||
String? _matchingKeyLabel; // Label de nuestra clave si coincide
|
||||
final TextEditingController _manualKeyController = TextEditingController();
|
||||
bool _showManualKey = false; // Toggle para mostrar/ocultar nsec
|
||||
String? _manualKeyError;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// Pre-cargar token inicial si existe
|
||||
if (widget.initialToken != null && widget.initialToken!.isNotEmpty) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_tokenController.text = widget.initialToken!;
|
||||
_onTokenChanged(widget.initialToken!);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_tokenController.dispose();
|
||||
_manualKeyController.dispose();
|
||||
_confettiController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
@@ -53,9 +83,9 @@ class _ReceiveScreenState extends State<ReceiveScreen> {
|
||||
icon: const Icon(LucideIcons.arrowLeft, color: Colors.white),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
title: const Text(
|
||||
'Recibir Cashu',
|
||||
style: TextStyle(
|
||||
title: Text(
|
||||
L10n.of(context)!.receiveCashu,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.white,
|
||||
@@ -63,10 +93,7 @@ class _ReceiveScreenState extends State<ReceiveScreen> {
|
||||
),
|
||||
),
|
||||
body: SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(AppDimensions.paddingMedium),
|
||||
child: _showSuccess ? _buildSuccessView() : _buildReceiveForm(),
|
||||
),
|
||||
child: _showSuccess ? _buildSuccessView() : _buildReceiveForm(),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -74,98 +101,122 @@ class _ReceiveScreenState extends State<ReceiveScreen> {
|
||||
}
|
||||
|
||||
Widget _buildReceiveForm() {
|
||||
final l10n = L10n.of(context)!;
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// Instrucciones
|
||||
Text(
|
||||
'Pega el token Cashu:',
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: AppColors.textSecondary,
|
||||
// Contenido scrolleable
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(AppDimensions.paddingMedium),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// Instrucciones
|
||||
Text(
|
||||
l10n.pasteTheCashuToken,
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppDimensions.paddingSmall),
|
||||
|
||||
// Campo de texto para el token
|
||||
_buildTokenInput(),
|
||||
|
||||
const SizedBox(height: AppDimensions.paddingMedium),
|
||||
|
||||
// Botones pegar y escanear
|
||||
_buildActionButtons(),
|
||||
|
||||
const SizedBox(height: AppDimensions.paddingLarge),
|
||||
|
||||
// Preview del token (si es válido)
|
||||
if (_isValidToken && _tokenInfo != null) _buildTokenPreview(),
|
||||
|
||||
// Indicador P2PK (si el token está bloqueado)
|
||||
if (_isValidToken && _isP2PKLocked) ...[
|
||||
const SizedBox(height: AppDimensions.paddingMedium),
|
||||
_buildP2PKIndicator(),
|
||||
],
|
||||
|
||||
// Mensaje de error (si hay)
|
||||
if (_errorMessage != null) _buildErrorMessage(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppDimensions.paddingSmall),
|
||||
|
||||
// Campo de texto para el token
|
||||
_buildTokenInput(),
|
||||
|
||||
const SizedBox(height: AppDimensions.paddingMedium),
|
||||
|
||||
// Botón pegar del portapapeles
|
||||
_buildPasteButton(),
|
||||
|
||||
const SizedBox(height: AppDimensions.paddingLarge),
|
||||
|
||||
// Preview del token (si es válido)
|
||||
if (_isValidToken && _tokenInfo != null) _buildTokenPreview(),
|
||||
|
||||
// Mensaje de error (si hay)
|
||||
if (_errorMessage != null) _buildErrorMessage(),
|
||||
|
||||
const Spacer(),
|
||||
|
||||
// Botón reclamar
|
||||
_buildClaimButton(),
|
||||
// Botón único "Recibir" (fijo abajo)
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(AppDimensions.paddingMedium),
|
||||
child: _buildReceiveButton(),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSuccessView() {
|
||||
return Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
// Icono de éxito
|
||||
Container(
|
||||
width: 100,
|
||||
height: 100,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.success.withValues(alpha: 0.2),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Icon(
|
||||
LucideIcons.checkCircle2,
|
||||
color: AppColors.success,
|
||||
size: 50,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppDimensions.paddingLarge),
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(AppDimensions.paddingMedium),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Spacer(),
|
||||
|
||||
// Monto recibido
|
||||
Builder(
|
||||
builder: (context) {
|
||||
final unit = _receivedUnit ?? context.read<WalletProvider>().activeUnit;
|
||||
return Text(
|
||||
'+${UnitFormatter.formatBalance(_receivedAmount, unit)} ${UnitFormatter.getUnitLabel(unit)}',
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 36,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.white,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: AppDimensions.paddingSmall),
|
||||
|
||||
Text(
|
||||
'Tokens recibidos',
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 18,
|
||||
color: AppColors.textSecondary,
|
||||
// Icono de éxito
|
||||
Container(
|
||||
width: 100,
|
||||
height: 100,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.success.withValues(alpha: 0.2),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Icon(
|
||||
LucideIcons.checkCircle2,
|
||||
color: AppColors.success,
|
||||
size: 50,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppDimensions.paddingLarge * 2),
|
||||
const SizedBox(height: AppDimensions.paddingLarge),
|
||||
|
||||
// Botón volver
|
||||
PrimaryButton(
|
||||
text: 'Volver al inicio',
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
],
|
||||
// Monto recibido
|
||||
Builder(
|
||||
builder: (context) {
|
||||
final unit = _receivedUnit ?? context.read<WalletProvider>().activeUnit;
|
||||
return Text(
|
||||
'+${UnitFormatter.formatBalance(_receivedAmount, unit)} ${UnitFormatter.getUnitLabel(unit)}',
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 36,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.white,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: AppDimensions.paddingSmall),
|
||||
|
||||
Text(
|
||||
L10n.of(context)!.tokensReceived,
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 18,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
|
||||
const Spacer(),
|
||||
|
||||
// Botón volver (fijo abajo)
|
||||
PrimaryButton(
|
||||
text: L10n.of(context)!.backToHome,
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -195,6 +246,18 @@ class _ReceiveScreenState extends State<ReceiveScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
@@ -221,7 +284,7 @@ class _ReceiveScreenState extends State<ReceiveScreen> {
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'Pegar del portapapeles',
|
||||
L10n.of(context)!.pasteFromClipboard,
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 16,
|
||||
@@ -235,6 +298,40 @@ class _ReceiveScreenState extends State<ReceiveScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
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.cashuOnly),
|
||||
),
|
||||
);
|
||||
|
||||
if (result != null && result.isNotEmpty && mounted) {
|
||||
_tokenController.text = result;
|
||||
_onTokenChanged(result);
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildTokenPreview() {
|
||||
final walletProvider = context.read<WalletProvider>();
|
||||
final amount = _tokenInfo!.amount;
|
||||
@@ -264,7 +361,7 @@ class _ReceiveScreenState extends State<ReceiveScreen> {
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'Token válido',
|
||||
L10n.of(context)!.validToken,
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 14,
|
||||
@@ -281,7 +378,7 @@ class _ReceiveScreenState extends State<ReceiveScreen> {
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
'Monto:',
|
||||
L10n.of(context)!.amount,
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 14,
|
||||
@@ -319,7 +416,7 @@ class _ReceiveScreenState extends State<ReceiveScreen> {
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
'Mint:',
|
||||
L10n.of(context)!.mint,
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 14,
|
||||
@@ -378,10 +475,312 @@ class _ReceiveScreenState extends State<ReceiveScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildClaimButton() {
|
||||
/// Widget que muestra el estado P2PK del token
|
||||
Widget _buildP2PKIndicator() {
|
||||
final l10n = L10n.of(context)!;
|
||||
|
||||
// Convertir pubkey hex a npub para mostrar
|
||||
String? npubDisplay;
|
||||
if (_lockedToPubkeyHex != null) {
|
||||
try {
|
||||
npubDisplay = NostrUtils.hexToNpub(_lockedToPubkeyHex!);
|
||||
} catch (_) {
|
||||
npubDisplay = _lockedToPubkeyHex; // Fallback a hex si falla conversión
|
||||
}
|
||||
}
|
||||
|
||||
// Truncar npub para display: npub1abc...xyz
|
||||
String truncatedNpub = '';
|
||||
if (npubDisplay != null && npubDisplay.length > 20) {
|
||||
truncatedNpub = '${npubDisplay.substring(0, 12)}...${npubDisplay.substring(npubDisplay.length - 6)}';
|
||||
} else {
|
||||
truncatedNpub = npubDisplay ?? '';
|
||||
}
|
||||
|
||||
return GlassCard(
|
||||
padding: const EdgeInsets.all(AppDimensions.paddingMedium),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Encabezado con icono y estado
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: _isLockedToUs
|
||||
? AppColors.success.withValues(alpha: 0.2)
|
||||
: AppColors.warning.withValues(alpha: 0.2),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(
|
||||
_isLockedToUs ? LucideIcons.unlock : LucideIcons.lock,
|
||||
color: _isLockedToUs ? AppColors.success : AppColors.warning,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
_isLockedToUs ? l10n.p2pkLockedToYou : l10n.p2pkLockedToOther,
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: _isLockedToUs ? AppColors.success : AppColors.warning,
|
||||
),
|
||||
),
|
||||
if (_isLockedToUs && _matchingKeyLabel != null)
|
||||
Text(
|
||||
_matchingKeyLabel!,
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 12,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// Badge experimental
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.warning.withValues(alpha: 0.2),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
LucideIcons.flaskConical,
|
||||
size: 12,
|
||||
color: AppColors.warning,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
l10n.p2pkExperimentalShort,
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: AppColors.warning,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// Pubkey (npub truncado) - tap para copiar
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
if (npubDisplay != null) {
|
||||
Clipboard.setData(ClipboardData(text: npubDisplay));
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(l10n.copied('npub')),
|
||||
duration: const Duration(seconds: 2),
|
||||
backgroundColor: AppColors.success,
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: 0.05),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: Colors.white.withValues(alpha: 0.1),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
truncatedNpub,
|
||||
style: TextStyle(
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 12,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
),
|
||||
Icon(
|
||||
LucideIcons.copy,
|
||||
size: 14,
|
||||
color: AppColors.textSecondary.withValues(alpha: 0.7),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Campo nsec manual (solo si NO es nuestra clave)
|
||||
if (!_isLockedToUs) ...[
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Mensaje de error si no puede desbloquear
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.error.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: AppColors.error.withValues(alpha: 0.2),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
LucideIcons.alertTriangle,
|
||||
size: 16,
|
||||
color: AppColors.error,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
l10n.p2pkCannotUnlock,
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 12,
|
||||
color: AppColors.error,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// Campo para ingresar nsec manualmente
|
||||
Text(
|
||||
l10n.p2pkEnterPrivateKey,
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: 0.05),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: _manualKeyError != null
|
||||
? AppColors.error.withValues(alpha: 0.5)
|
||||
: Colors.white.withValues(alpha: 0.1),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _manualKeyController,
|
||||
obscureText: !_showManualKey,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 14,
|
||||
color: Colors.white,
|
||||
),
|
||||
decoration: InputDecoration(
|
||||
hintText: 'nsec1... o hex',
|
||||
hintStyle: TextStyle(
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 14,
|
||||
color: Colors.white.withValues(alpha: 0.3),
|
||||
),
|
||||
border: InputBorder.none,
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 14,
|
||||
),
|
||||
),
|
||||
onChanged: (_) {
|
||||
setState(() {
|
||||
_manualKeyError = null;
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
// Botón mostrar/ocultar
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
_showManualKey ? LucideIcons.eyeOff : LucideIcons.eye,
|
||||
color: AppColors.textSecondary,
|
||||
size: 20,
|
||||
),
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_showManualKey = !_showManualKey;
|
||||
});
|
||||
},
|
||||
),
|
||||
// Botón pegar
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
LucideIcons.clipboard,
|
||||
color: AppColors.textSecondary,
|
||||
size: 20,
|
||||
),
|
||||
onPressed: () async {
|
||||
final clipboardData = await Clipboard.getData(Clipboard.kTextPlain);
|
||||
if (clipboardData?.text != null) {
|
||||
_manualKeyController.text = clipboardData!.text!.trim();
|
||||
setState(() {
|
||||
_manualKeyError = null;
|
||||
});
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Error de clave manual
|
||||
if (_manualKeyError != null) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
_manualKeyError!,
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 12,
|
||||
color: AppColors.error,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildReceiveButton() {
|
||||
final l10n = L10n.of(context)!;
|
||||
|
||||
// Determinar si el botón debe estar habilitado
|
||||
bool canReceive = _isValidToken && !_isProcessing;
|
||||
|
||||
// Si es P2PK y NO es nuestra clave, necesita nsec manual válido
|
||||
if (_isP2PKLocked && !_isLockedToUs) {
|
||||
final manualKey = _manualKeyController.text.trim();
|
||||
canReceive = canReceive && manualKey.isNotEmpty;
|
||||
}
|
||||
|
||||
return PrimaryButton(
|
||||
text: _isProcessing ? 'Reclamando...' : 'Reclamar tokens',
|
||||
onPressed: _isValidToken && !_isProcessing ? _claimToken : null,
|
||||
text: _isProcessing ? l10n.claiming : l10n.receive,
|
||||
onPressed: canReceive ? _receiveToken : null,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -395,9 +794,17 @@ class _ReceiveScreenState extends State<ReceiveScreen> {
|
||||
|
||||
void _onTokenChanged(String value) {
|
||||
final walletProvider = context.read<WalletProvider>();
|
||||
final p2pkProvider = context.read<P2PKProvider>();
|
||||
|
||||
setState(() {
|
||||
_errorMessage = null;
|
||||
_manualKeyError = null;
|
||||
|
||||
// Reset P2PK state
|
||||
_isP2PKLocked = false;
|
||||
_isLockedToUs = false;
|
||||
_lockedToPubkeyHex = null;
|
||||
_matchingKeyLabel = null;
|
||||
|
||||
if (value.isEmpty) {
|
||||
_isValidToken = false;
|
||||
@@ -411,35 +818,211 @@ class _ReceiveScreenState extends State<ReceiveScreen> {
|
||||
if (tokenInfo != null) {
|
||||
_isValidToken = true;
|
||||
_tokenInfo = tokenInfo;
|
||||
|
||||
// Detectar P2PK
|
||||
_isP2PKLocked = p2pkProvider.isTokenLocked(value.trim());
|
||||
if (_isP2PKLocked) {
|
||||
_lockedToPubkeyHex = p2pkProvider.extractLockedPubkey(value.trim());
|
||||
_isLockedToUs = p2pkProvider.isTokenLockedToUs(value.trim());
|
||||
|
||||
// Si es nuestra, buscar el label de la clave
|
||||
if (_isLockedToUs && _lockedToPubkeyHex != null) {
|
||||
_matchingKeyLabel = _findMatchingKeyLabel(p2pkProvider);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
_isValidToken = false;
|
||||
_tokenInfo = null;
|
||||
_errorMessage = 'Token inválido o malformado';
|
||||
_errorMessage = L10n.of(context)!.invalidToken;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _claimToken() async {
|
||||
/// Busca el label de la clave que coincide con el token P2PK
|
||||
String? _findMatchingKeyLabel(P2PKProvider p2pkProvider) {
|
||||
if (_lockedToPubkeyHex == null) return null;
|
||||
|
||||
// Normalizar a x-only para comparar
|
||||
final lockedNormalized = _normalizeToXOnly(_lockedToPubkeyHex!);
|
||||
|
||||
for (final key in p2pkProvider.keys) {
|
||||
final keyNormalized = _normalizeToXOnly(key.publicKey);
|
||||
if (keyNormalized == lockedNormalized) {
|
||||
return key.label;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Normaliza pubkey a x-only (64 chars) quitando prefijo SEC1 si existe
|
||||
String _normalizeToXOnly(String pubkey) {
|
||||
final lower = pubkey.toLowerCase();
|
||||
if (lower.length == 66 && (lower.startsWith('02') || lower.startsWith('03'))) {
|
||||
return lower.substring(2);
|
||||
}
|
||||
return lower;
|
||||
}
|
||||
|
||||
/// Método principal: recibe token con detección automática de conectividad.
|
||||
/// 1. Si el mint es desconocido y no hay conexión → rechazar
|
||||
/// 2. Ping al mint (3s) → si OK → claim → si falla → guardar pendiente
|
||||
Future<void> _receiveToken() async {
|
||||
if (_isProcessing) return;
|
||||
|
||||
setState(() {
|
||||
_isProcessing = true;
|
||||
_errorMessage = null;
|
||||
});
|
||||
|
||||
final l10n = L10n.of(context)!;
|
||||
final walletProvider = context.read<WalletProvider>();
|
||||
|
||||
try {
|
||||
final walletProvider = context.read<WalletProvider>();
|
||||
final mintUrl = _tokenInfo?.mintUrl;
|
||||
if (mintUrl == null) {
|
||||
throw Exception(l10n.invalidToken);
|
||||
}
|
||||
|
||||
// Guardar unidad detectada del token ANTES de reclamar
|
||||
final detectedUnit = _tokenInfo?.unit ?? walletProvider.activeUnit;
|
||||
// Verificar si el mint es conocido
|
||||
final isKnownMint = walletProvider.mintUrls.contains(mintUrl);
|
||||
|
||||
// Reclamar token (usa unidad detectada internamente)
|
||||
final amountReceived = await walletProvider.receiveToken(
|
||||
// Verificar conectividad al mint
|
||||
final canReach = await walletProvider.canReachMint(mintUrl);
|
||||
|
||||
// Si el mint es desconocido y no hay conexión → rechazar
|
||||
if (!isKnownMint && !canReach) {
|
||||
setState(() {
|
||||
_errorMessage = l10n.unknownMintOffline;
|
||||
_isProcessing = false;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Si no hay conexión → guardar como pendiente
|
||||
if (!canReach) {
|
||||
await _saveForLaterOffline();
|
||||
return;
|
||||
}
|
||||
|
||||
// Hay conexión → intentar reclamar
|
||||
await _claimToken();
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_errorMessage = l10n.claimError(e.toString());
|
||||
_isProcessing = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Guarda el token como pendiente cuando no hay conexión.
|
||||
Future<void> _saveForLaterOffline() async {
|
||||
final l10n = L10n.of(context)!;
|
||||
final walletProvider = context.read<WalletProvider>();
|
||||
|
||||
try {
|
||||
final pending = await walletProvider.addPendingToken(
|
||||
_tokenController.text.trim(),
|
||||
);
|
||||
|
||||
if (pending != null) {
|
||||
if (mounted) {
|
||||
// Mostrar mensaje de sin conexión
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(l10n.noConnectionTokenSaved),
|
||||
backgroundColor: AppColors.warning,
|
||||
duration: const Duration(seconds: 3),
|
||||
),
|
||||
);
|
||||
Navigator.pop(context);
|
||||
}
|
||||
} else {
|
||||
// Límite alcanzado
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(l10n.pendingTokenLimitReached),
|
||||
backgroundColor: AppColors.warning,
|
||||
duration: const Duration(seconds: 3),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(l10n.saveTokenError),
|
||||
backgroundColor: AppColors.error,
|
||||
),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() => _isProcessing = false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Reclama el token directamente (cuando hay conexión).
|
||||
Future<void> _claimToken() async {
|
||||
final walletProvider = context.read<WalletProvider>();
|
||||
final p2pkProvider = context.read<P2PKProvider>();
|
||||
final l10n = L10n.of(context)!;
|
||||
|
||||
// Guardar unidad detectada del token ANTES de reclamar
|
||||
final detectedUnit = _tokenInfo?.unit ?? walletProvider.activeUnit;
|
||||
final token = _tokenController.text.trim();
|
||||
|
||||
try {
|
||||
BigInt amountReceived;
|
||||
|
||||
if (_isP2PKLocked) {
|
||||
// Token P2PK - necesita clave privada para desbloquear
|
||||
String? privateKeyHex;
|
||||
|
||||
if (_isLockedToUs) {
|
||||
// Es nuestra clave - obtener automáticamente
|
||||
privateKeyHex = p2pkProvider.getPrivateKeyForToken(token);
|
||||
if (privateKeyHex == null) {
|
||||
throw Exception(l10n.p2pkCannotUnlock);
|
||||
}
|
||||
} else {
|
||||
// No es nuestra - usar clave manual
|
||||
final manualInput = _manualKeyController.text.trim();
|
||||
|
||||
// Intentar convertir nsec a hex
|
||||
if (manualInput.startsWith('nsec1')) {
|
||||
privateKeyHex = NostrUtils.nsecToHex(manualInput);
|
||||
} else if (RegExp(r'^[0-9a-fA-F]{64}$').hasMatch(manualInput)) {
|
||||
// Ya es hex de 64 caracteres
|
||||
privateKeyHex = manualInput.toLowerCase();
|
||||
}
|
||||
|
||||
if (privateKeyHex == null) {
|
||||
setState(() {
|
||||
_manualKeyError = l10n.p2pkInvalidPrivateKey;
|
||||
_isProcessing = false;
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Reclamar token P2PK
|
||||
amountReceived = await walletProvider.receiveP2pkToken(
|
||||
token,
|
||||
[privateKeyHex],
|
||||
);
|
||||
} else {
|
||||
// Token normal - flujo existente
|
||||
amountReceived = await walletProvider.receiveToken(token);
|
||||
}
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_receivedAmount = amountReceived;
|
||||
_receivedUnit = detectedUnit; // Usar unidad del token
|
||||
_receivedUnit = detectedUnit;
|
||||
_showSuccess = true;
|
||||
_isProcessing = false;
|
||||
});
|
||||
@@ -448,23 +1031,23 @@ class _ReceiveScreenState extends State<ReceiveScreen> {
|
||||
_confettiController.fire();
|
||||
}
|
||||
} catch (e) {
|
||||
setState(() {
|
||||
// Mensajes de error más amigables
|
||||
final errorStr = e.toString().toLowerCase();
|
||||
if (errorStr.contains('already spent') || errorStr.contains('token already')) {
|
||||
_errorMessage = 'Este token ya fue reclamado';
|
||||
} else if (errorStr.contains('unknown mint') || errorStr.contains('mint not found')) {
|
||||
_errorMessage = 'Token de un mint desconocido';
|
||||
} else {
|
||||
_errorMessage = 'Error al reclamar: $e';
|
||||
}
|
||||
});
|
||||
} finally {
|
||||
if (mounted && !_showSuccess) {
|
||||
setState(() {
|
||||
_isProcessing = false;
|
||||
});
|
||||
if (!mounted) return;
|
||||
// Mensajes de error más amigables
|
||||
final errorStr = e.toString().toLowerCase();
|
||||
String errorMessage;
|
||||
if (errorStr.contains('already spent') || errorStr.contains('token already')) {
|
||||
errorMessage = l10n.tokenAlreadyClaimed;
|
||||
} else if (errorStr.contains('unknown mint') || errorStr.contains('mint not found')) {
|
||||
errorMessage = l10n.unknownMint;
|
||||
} else if (errorStr.contains('cannot unlock') || errorStr.contains('p2pk')) {
|
||||
errorMessage = l10n.p2pkCannotUnlock;
|
||||
} else {
|
||||
errorMessage = l10n.claimError(e.toString());
|
||||
}
|
||||
setState(() {
|
||||
_errorMessage = errorMessage;
|
||||
_isProcessing = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:lucide_icons/lucide_icons.dart';
|
||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
import '../../core/constants/colors.dart';
|
||||
import '../../core/constants/dimensions.dart';
|
||||
import '../../core/models/proof.dart';
|
||||
@@ -64,7 +65,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 +121,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,
|
||||
@@ -201,7 +202,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 +236,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 +247,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 +267,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 +292,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,
|
||||
@@ -341,8 +343,10 @@ class _OfflineSendScreenState extends State<OfflineSendScreen> {
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
final errorMessage = L10n.of(context)!.creatingTokenError(e.toString());
|
||||
setState(() {
|
||||
_errorMessage = 'Error creando token: $e';
|
||||
_errorMessage = errorMessage;
|
||||
_isCreating = false;
|
||||
});
|
||||
}
|
||||
|
||||
+330
-144
@@ -1,14 +1,18 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:lucide_icons/lucide_icons.dart';
|
||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
import '../../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 'share_token_screen.dart';
|
||||
import 'offline_send_screen.dart';
|
||||
|
||||
@@ -21,14 +25,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 +57,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 +82,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,36 +94,49 @@ 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,
|
||||
),
|
||||
],
|
||||
),
|
||||
body: SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(AppDimensions.paddingMedium),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// Monto a enviar
|
||||
_buildAmountSection(),
|
||||
child: Column(
|
||||
children: [
|
||||
// Contenido scrolleable
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(AppDimensions.paddingMedium),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// Monto a enviar
|
||||
_buildAmountSection(),
|
||||
|
||||
const SizedBox(height: AppDimensions.paddingLarge),
|
||||
const SizedBox(height: AppDimensions.paddingLarge),
|
||||
|
||||
// Memo opcional
|
||||
_buildMemoSection(),
|
||||
// Memo opcional
|
||||
_buildMemoSection(),
|
||||
|
||||
const SizedBox(height: AppDimensions.paddingMedium),
|
||||
const SizedBox(height: AppDimensions.paddingMedium),
|
||||
|
||||
// Mensaje de error (si hay)
|
||||
if (_errorMessage != null) _buildErrorMessage(),
|
||||
// P2PK (bloquear a clave pública)
|
||||
_buildP2PKSection(),
|
||||
|
||||
const Spacer(),
|
||||
const SizedBox(height: AppDimensions.paddingMedium),
|
||||
|
||||
// Botón crear token
|
||||
_buildCreateButton(),
|
||||
],
|
||||
),
|
||||
// Mensaje de error (si hay)
|
||||
if (_errorMessage != null) _buildErrorMessage(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Botón crear token (fijo abajo)
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(AppDimensions.paddingMedium),
|
||||
child: _buildCreateButton(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -123,116 +145,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,
|
||||
@@ -254,7 +254,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,
|
||||
@@ -273,6 +273,160 @@ class _SendScreenState extends State<SendScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildP2PKSection() {
|
||||
final l10n = L10n.of(context)!;
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Toggle P2PK
|
||||
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;
|
||||
if (!value) {
|
||||
_pubkeyController.clear();
|
||||
_pubkeyError = null;
|
||||
}
|
||||
});
|
||||
},
|
||||
activeColor: AppColors.primaryAction,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Campo pubkey (visible solo si P2PK está activo)
|
||||
if (_useP2PK) ...[
|
||||
const SizedBox(height: AppDimensions.paddingSmall),
|
||||
|
||||
GlassCard(
|
||||
padding: const EdgeInsets.all(AppDimensions.paddingSmall),
|
||||
child: TextField(
|
||||
controller: _pubkeyController,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 14,
|
||||
color: Colors.white,
|
||||
),
|
||||
decoration: InputDecoration(
|
||||
hintText: l10n.p2pkReceiverPubkey,
|
||||
hintStyle: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 14,
|
||||
color: Colors.white.withValues(alpha: 0.3),
|
||||
),
|
||||
border: InputBorder.none,
|
||||
errorText: _pubkeyError,
|
||||
errorStyle: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 12,
|
||||
color: AppColors.error,
|
||||
),
|
||||
prefixIcon: Icon(
|
||||
LucideIcons.key,
|
||||
color: AppColors.textSecondary.withValues(alpha: 0.5),
|
||||
size: 18,
|
||||
),
|
||||
suffixIcon: IconButton(
|
||||
icon: Icon(
|
||||
LucideIcons.clipboard,
|
||||
color: AppColors.textSecondary.withValues(alpha: 0.5),
|
||||
size: 18,
|
||||
),
|
||||
onPressed: _pastePubkey,
|
||||
),
|
||||
),
|
||||
onChanged: _validatePubkey,
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: AppDimensions.paddingSmall),
|
||||
|
||||
// Advertencia experimental
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
LucideIcons.alertTriangle,
|
||||
size: 14,
|
||||
color: AppColors.warning.withValues(alpha: 0.8),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Expanded(
|
||||
child: Text(
|
||||
l10n.p2pkExperimental,
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 11,
|
||||
color: AppColors.warning.withValues(alpha: 0.8),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
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 (clipboardData?.text != null) {
|
||||
_pubkeyController.text = clipboardData!.text!;
|
||||
_validatePubkey(clipboardData.text!);
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildErrorMessage() {
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: AppDimensions.paddingMedium),
|
||||
@@ -309,16 +463,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;
|
||||
});
|
||||
}
|
||||
@@ -330,7 +489,7 @@ class _SendScreenState extends State<SendScreen> {
|
||||
|
||||
if (mintUrl == null) {
|
||||
setState(() {
|
||||
_errorMessage = 'No hay mint activo';
|
||||
_errorMessage = L10n.of(context)!.noActiveMint;
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -353,7 +512,7 @@ class _SendScreenState extends State<SendScreen> {
|
||||
|
||||
if (mintUrl == null) {
|
||||
setState(() {
|
||||
_errorMessage = 'No hay mint activo';
|
||||
_errorMessage = L10n.of(context)!.noActiveMint;
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -371,6 +530,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;
|
||||
@@ -417,7 +583,24 @@ 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);
|
||||
debugPrint('[SendScreen] P2PK enabled, pubkeyHex: $pubkeyHex');
|
||||
if (pubkeyHex == null) {
|
||||
throw Exception(L10n.of(context)!.p2pkInvalidPubkey);
|
||||
}
|
||||
debugPrint('[SendScreen] Calling sendTokensP2pk with amount: $amount, pubkey: ${pubkeyHex.length > 16 ? pubkeyHex.substring(0, 16) : pubkeyHex}...');
|
||||
token = await walletProvider.sendTokensP2pk(amount, pubkeyHex, memo);
|
||||
debugPrint('[SendScreen] Token P2PK creado exitosamente!');
|
||||
} else {
|
||||
debugPrint('[SendScreen] Sending normal token, amount: $amount');
|
||||
token = await walletProvider.sendTokens(amount, memo);
|
||||
}
|
||||
|
||||
if (mounted) {
|
||||
Navigator.pushReplacement(
|
||||
@@ -432,7 +615,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
|
||||
@@ -446,11 +631,12 @@ class _SendScreenState extends State<SendScreen> {
|
||||
return;
|
||||
}
|
||||
|
||||
final l10n = L10n.of(context)!;
|
||||
setState(() {
|
||||
if (errorStr.contains('insufficient') || errorStr.contains('not enough')) {
|
||||
_errorMessage = 'Balance insuficiente';
|
||||
_errorMessage = l10n.insufficientBalance;
|
||||
} else {
|
||||
_errorMessage = 'Error al crear token: $e';
|
||||
_errorMessage = l10n.tokenCreationError(e.toString());
|
||||
}
|
||||
});
|
||||
} finally {
|
||||
@@ -481,17 +667,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),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -567,9 +753,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,
|
||||
@@ -617,10 +803,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,
|
||||
@@ -634,7 +820,7 @@ class _ConfirmationModal extends StatelessWidget {
|
||||
const SizedBox(width: AppDimensions.paddingMedium),
|
||||
Expanded(
|
||||
child: PrimaryButton(
|
||||
text: 'Confirmar',
|
||||
text: L10n.of(context)!.confirm,
|
||||
onPressed: onConfirm,
|
||||
height: 52,
|
||||
),
|
||||
|
||||
@@ -5,6 +5,7 @@ import 'package:qr_flutter/qr_flutter.dart';
|
||||
import 'package:lucide_icons/lucide_icons.dart';
|
||||
import 'package:share_plus/share_plus.dart';
|
||||
import 'package:cdk_flutter/cdk_flutter.dart' as cdk;
|
||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
import '../../core/constants/colors.dart';
|
||||
import '../../core/constants/dimensions.dart';
|
||||
import '../../core/utils/formatters.dart';
|
||||
@@ -114,9 +115,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,
|
||||
@@ -124,49 +125,57 @@ class _ShareTokenScreenState extends State<ShareTokenScreen> {
|
||||
),
|
||||
),
|
||||
body: SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(AppDimensions.paddingMedium),
|
||||
child: Column(
|
||||
children: [
|
||||
const SizedBox(height: AppDimensions.paddingLarge),
|
||||
child: Column(
|
||||
children: [
|
||||
// Contenido scrolleable
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(AppDimensions.paddingMedium),
|
||||
child: Column(
|
||||
children: [
|
||||
const SizedBox(height: AppDimensions.paddingLarge),
|
||||
|
||||
// Icono de exito
|
||||
_buildSuccessIcon(),
|
||||
// Icono de exito
|
||||
_buildSuccessIcon(),
|
||||
|
||||
const SizedBox(height: AppDimensions.paddingLarge),
|
||||
const SizedBox(height: AppDimensions.paddingLarge),
|
||||
|
||||
// Monto
|
||||
_buildAmountDisplay(),
|
||||
// Monto
|
||||
_buildAmountDisplay(),
|
||||
|
||||
const SizedBox(height: AppDimensions.paddingLarge),
|
||||
const SizedBox(height: AppDimensions.paddingLarge),
|
||||
|
||||
// QR del token (dinámico si hay múltiples fragmentos)
|
||||
_buildQRDisplay(),
|
||||
// QR del token (dinámico si hay múltiples fragmentos)
|
||||
_buildQRDisplay(),
|
||||
|
||||
const SizedBox(height: AppDimensions.paddingMedium),
|
||||
const SizedBox(height: AppDimensions.paddingMedium),
|
||||
|
||||
// Token truncado
|
||||
_buildTokenTextDisplay(),
|
||||
// Token truncado
|
||||
_buildTokenTextDisplay(),
|
||||
|
||||
const SizedBox(height: AppDimensions.paddingMedium),
|
||||
const SizedBox(height: AppDimensions.paddingMedium),
|
||||
|
||||
// Botones copiar y compartir
|
||||
_buildActionButtons(context),
|
||||
// Botones copiar y compartir
|
||||
_buildActionButtons(context),
|
||||
|
||||
const SizedBox(height: AppDimensions.paddingLarge),
|
||||
const SizedBox(height: AppDimensions.paddingLarge),
|
||||
|
||||
// Advertencia
|
||||
_buildWarning(),
|
||||
// Advertencia
|
||||
_buildWarning(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const Spacer(),
|
||||
|
||||
// Boton volver al inicio
|
||||
PrimaryButton(
|
||||
text: 'Volver al inicio',
|
||||
// Botón volver al inicio (fijo abajo)
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(AppDimensions.paddingMedium),
|
||||
child: PrimaryButton(
|
||||
text: L10n.of(context)!.backToHome,
|
||||
onPressed: () => _goToHome(context),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -326,6 +335,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;
|
||||
@@ -343,8 +353,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,
|
||||
@@ -390,9 +400,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,
|
||||
@@ -422,9 +432,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,
|
||||
@@ -457,7 +467,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,
|
||||
@@ -474,7 +484,7 @@ 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)),
|
||||
|
||||
@@ -5,6 +5,7 @@ import 'package:provider/provider.dart';
|
||||
import 'package:qr_flutter/qr_flutter.dart';
|
||||
import 'package:lucide_icons/lucide_icons.dart';
|
||||
import 'package:cdk_flutter/cdk_flutter.dart' hide WalletProvider;
|
||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
import '../../core/constants/colors.dart';
|
||||
import '../../core/constants/dimensions.dart';
|
||||
import '../../core/utils/formatters.dart';
|
||||
@@ -85,7 +86,7 @@ class _InvoiceScreenState extends State<InvoiceScreen> {
|
||||
default:
|
||||
// Cualquier otro estado se trata como error
|
||||
_status = MintStatus.error;
|
||||
_errorMessage = 'Estado desconocido';
|
||||
_errorMessage = L10n.of(context)!.unknownState;
|
||||
}
|
||||
});
|
||||
},
|
||||
@@ -112,12 +113,15 @@ class _InvoiceScreenState extends State<InvoiceScreen> {
|
||||
// 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),
|
||||
@@ -148,9 +152,9 @@ class _InvoiceScreenState extends State<InvoiceScreen> {
|
||||
onPressed: () => Navigator.pop(context),
|
||||
)
|
||||
: null,
|
||||
title: const Text(
|
||||
'Pagar invoice',
|
||||
style: TextStyle(
|
||||
title: Text(
|
||||
L10n.of(context)!.payInvoiceTitle,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.white,
|
||||
@@ -160,9 +164,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,
|
||||
@@ -197,17 +201,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,
|
||||
@@ -220,36 +224,48 @@ class _InvoiceScreenState extends State<InvoiceScreen> {
|
||||
|
||||
Widget _buildInvoiceView() {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// Título con monto
|
||||
_buildHeader(),
|
||||
// Contenido scrolleable
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// Título con monto
|
||||
_buildHeader(),
|
||||
|
||||
const SizedBox(height: AppDimensions.paddingLarge),
|
||||
const SizedBox(height: AppDimensions.paddingLarge),
|
||||
|
||||
// QR del invoice
|
||||
if (_invoice != null) _buildQRSection(),
|
||||
// QR del invoice
|
||||
if (_invoice != null) _buildQRSection(),
|
||||
|
||||
const SizedBox(height: AppDimensions.paddingLarge),
|
||||
const SizedBox(height: AppDimensions.paddingLarge),
|
||||
|
||||
// Invoice truncado
|
||||
if (_invoice != null) _buildInvoiceText(),
|
||||
// Invoice truncado
|
||||
if (_invoice != null) _buildInvoiceText(),
|
||||
|
||||
const SizedBox(height: AppDimensions.paddingMedium),
|
||||
const SizedBox(height: AppDimensions.paddingMedium),
|
||||
|
||||
// Botón copiar
|
||||
if (_invoice != null && _status == MintStatus.unpaid) _buildCopyButton(),
|
||||
// Botón copiar
|
||||
if (_invoice != null && _status == MintStatus.unpaid) _buildCopyButton(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const Spacer(),
|
||||
// Estado y descripción (fijos abajo)
|
||||
Column(
|
||||
children: [
|
||||
// Estado del invoice
|
||||
_buildStatus(),
|
||||
|
||||
// Estado del invoice
|
||||
_buildStatus(),
|
||||
const SizedBox(height: AppDimensions.paddingMedium),
|
||||
|
||||
const SizedBox(height: AppDimensions.paddingMedium),
|
||||
|
||||
// Descripción (si existe)
|
||||
if (widget.description != null && widget.description!.isNotEmpty)
|
||||
_buildDescription(),
|
||||
// Descripción (si existe)
|
||||
if (widget.description != null && widget.description!.isNotEmpty)
|
||||
_buildDescription(),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -273,9 +289,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,
|
||||
@@ -286,7 +302,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,
|
||||
@@ -304,9 +320,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,
|
||||
@@ -321,13 +337,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,
|
||||
@@ -392,7 +409,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,
|
||||
@@ -407,6 +424,7 @@ class _InvoiceScreenState extends State<InvoiceScreen> {
|
||||
}
|
||||
|
||||
Widget _buildStatus() {
|
||||
final l10n = L10n.of(context)!;
|
||||
IconData icon;
|
||||
String text;
|
||||
Color color;
|
||||
@@ -414,27 +432,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;
|
||||
}
|
||||
@@ -477,7 +495,7 @@ class _InvoiceScreenState extends State<InvoiceScreen> {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Descripcion:',
|
||||
L10n.of(context)!.description,
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 12,
|
||||
@@ -503,10 +521,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:flutter_gen/gen_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,
|
||||
@@ -70,30 +71,38 @@ class _MintScreenState extends State<MintScreen> {
|
||||
),
|
||||
),
|
||||
body: SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(AppDimensions.paddingMedium),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// Monto a depositar
|
||||
_buildAmountSection(),
|
||||
child: Column(
|
||||
children: [
|
||||
// Contenido scrolleable
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(AppDimensions.paddingMedium),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// Monto a depositar
|
||||
_buildAmountSection(),
|
||||
|
||||
const SizedBox(height: AppDimensions.paddingLarge),
|
||||
const SizedBox(height: AppDimensions.paddingLarge),
|
||||
|
||||
// Descripción opcional
|
||||
_buildDescriptionSection(),
|
||||
// Descripción opcional
|
||||
_buildDescriptionSection(),
|
||||
|
||||
const SizedBox(height: AppDimensions.paddingMedium),
|
||||
const SizedBox(height: AppDimensions.paddingMedium),
|
||||
|
||||
// Mensaje de error (si hay)
|
||||
if (_errorMessage != null) _buildErrorMessage(),
|
||||
// Mensaje de error (si hay)
|
||||
if (_errorMessage != null) _buildErrorMessage(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const Spacer(),
|
||||
|
||||
// Botón generar invoice
|
||||
_buildGenerateButton(),
|
||||
],
|
||||
),
|
||||
// Botón generar invoice (fijo abajo)
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(AppDimensions.paddingMedium),
|
||||
child: _buildGenerateButton(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -102,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,
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -165,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,
|
||||
@@ -191,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,
|
||||
@@ -242,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,
|
||||
);
|
||||
}
|
||||
@@ -343,9 +340,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,
|
||||
@@ -393,10 +390,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,
|
||||
@@ -410,7 +407,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:flutter_gen/gen_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;
|
||||
}
|
||||
}
|
||||
@@ -3,17 +3,25 @@ import 'package:flutter/services.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:lucide_icons/lucide_icons.dart';
|
||||
import 'package:cdk_flutter/cdk_flutter.dart' hide WalletProvider;
|
||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
import '../../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,
|
||||
@@ -82,54 +101,70 @@ class _MeltScreenState extends State<MeltScreen> {
|
||||
),
|
||||
),
|
||||
body: SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(AppDimensions.paddingMedium),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// Instrucciones
|
||||
Text(
|
||||
'Pega el invoice Lightning:',
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: AppColors.textSecondary,
|
||||
child: Column(
|
||||
children: [
|
||||
// Contenido scrolleable
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(AppDimensions.paddingMedium),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// Instrucciones
|
||||
Text(
|
||||
L10n.of(context)!.pasteLightningInvoice,
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppDimensions.paddingSmall),
|
||||
|
||||
// Campo de texto para el invoice
|
||||
_buildInvoiceInput(),
|
||||
|
||||
const SizedBox(height: AppDimensions.paddingMedium),
|
||||
|
||||
// Botones pegar y escanear
|
||||
_buildActionButtons(),
|
||||
|
||||
const SizedBox(height: AppDimensions.paddingLarge),
|
||||
|
||||
// Loading LNURL resolve
|
||||
if (_isResolvingLnurl) _buildResolvingLnurl(),
|
||||
|
||||
// Loading quote
|
||||
if (_isLoadingQuote) _buildLoadingQuote(),
|
||||
|
||||
// Preview del invoice (si es válido)
|
||||
if (_isValidInvoice && _quote != null && !_isLoadingQuote)
|
||||
_buildInvoicePreview(),
|
||||
|
||||
// Mensaje de error (si hay)
|
||||
if (_errorMessage != null) _buildErrorMessage(),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppDimensions.paddingSmall),
|
||||
),
|
||||
|
||||
// Campo de texto para el invoice
|
||||
_buildInvoiceInput(),
|
||||
// Balance y botón (fijos abajo)
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(AppDimensions.paddingMedium),
|
||||
child: Column(
|
||||
children: [
|
||||
// Balance disponible
|
||||
_buildBalanceInfo(),
|
||||
|
||||
const SizedBox(height: AppDimensions.paddingMedium),
|
||||
const SizedBox(height: AppDimensions.paddingMedium),
|
||||
|
||||
// Botón pegar del portapapeles
|
||||
_buildPasteButton(),
|
||||
|
||||
const SizedBox(height: AppDimensions.paddingLarge),
|
||||
|
||||
// Loading quote
|
||||
if (_isLoadingQuote) _buildLoadingQuote(),
|
||||
|
||||
// Preview del invoice (si es válido)
|
||||
if (_isValidInvoice && _quote != null && !_isLoadingQuote)
|
||||
_buildInvoicePreview(),
|
||||
|
||||
// Mensaje de error (si hay)
|
||||
if (_errorMessage != null) _buildErrorMessage(),
|
||||
|
||||
const Spacer(),
|
||||
|
||||
// Balance disponible
|
||||
_buildBalanceInfo(),
|
||||
|
||||
const SizedBox(height: AppDimensions.paddingMedium),
|
||||
|
||||
// Botón pagar invoice
|
||||
_buildPayButton(),
|
||||
],
|
||||
),
|
||||
// Botón pagar invoice
|
||||
_buildPayButton(),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -148,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,
|
||||
@@ -162,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,
|
||||
@@ -188,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,
|
||||
@@ -202,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),
|
||||
@@ -218,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,
|
||||
@@ -254,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,
|
||||
@@ -271,7 +384,7 @@ class _MeltScreenState extends State<MeltScreen> {
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
'Monto:',
|
||||
L10n.of(context)!.amount,
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 14,
|
||||
@@ -296,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,
|
||||
@@ -323,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,
|
||||
@@ -349,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,
|
||||
@@ -397,7 +510,7 @@ class _MeltScreenState extends State<MeltScreen> {
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
'Disponible: ',
|
||||
'${L10n.of(context)!.available} ',
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 14,
|
||||
@@ -418,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) {
|
||||
@@ -445,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;
|
||||
@@ -452,19 +594,126 @@ 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;
|
||||
}
|
||||
// Detectar tipo de input (sin mostrar error, solo detectar)
|
||||
final inputType = LnurlService.detectType(value);
|
||||
setState(() => _inputType = inputType);
|
||||
|
||||
// Obtener quote real
|
||||
_getQuote(value.trim());
|
||||
// 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(value);
|
||||
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 {
|
||||
@@ -489,15 +738,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());
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -537,10 +787,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),
|
||||
),
|
||||
@@ -548,17 +799,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) {
|
||||
@@ -631,9 +886,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,
|
||||
@@ -685,10 +940,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,
|
||||
@@ -702,7 +957,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:flutter_gen/gen_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,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,563 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:lucide_icons/lucide_icons.dart';
|
||||
import 'package:cdk_flutter/cdk_flutter.dart' show MintInfo, ContactInfo;
|
||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
import '../../core/constants/colors.dart';
|
||||
import '../../core/utils/formatters.dart';
|
||||
import '../../widgets/common/gradient_background.dart';
|
||||
|
||||
/// Pantalla de detalles de un mint (estilo cashu.me)
|
||||
class MintDetailScreen extends StatefulWidget {
|
||||
final String mintUrl;
|
||||
final MintInfo? mintInfo;
|
||||
final bool isActive;
|
||||
final Map<String, BigInt> balances;
|
||||
final VoidCallback? onSetActive;
|
||||
final VoidCallback? onDelete;
|
||||
|
||||
const MintDetailScreen({
|
||||
super.key,
|
||||
required this.mintUrl,
|
||||
this.mintInfo,
|
||||
this.isActive = false,
|
||||
this.balances = const {},
|
||||
this.onSetActive,
|
||||
this.onDelete,
|
||||
});
|
||||
|
||||
@override
|
||||
State<MintDetailScreen> createState() => _MintDetailScreenState();
|
||||
}
|
||||
|
||||
class _MintDetailScreenState extends State<MintDetailScreen> {
|
||||
bool _motdDismissed = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final info = widget.mintInfo;
|
||||
final name = info?.name ?? UnitFormatter.getMintDisplayName(widget.mintUrl);
|
||||
final motd = info?.motd;
|
||||
final hasMotd = motd != null && motd.isNotEmpty && !_motdDismissed;
|
||||
|
||||
return GradientBackground(
|
||||
child: Scaffold(
|
||||
backgroundColor: Colors.transparent,
|
||||
appBar: AppBar(
|
||||
backgroundColor: Colors.transparent,
|
||||
elevation: 0,
|
||||
leading: IconButton(
|
||||
icon: const Icon(LucideIcons.arrowLeft, color: Colors.white),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
),
|
||||
body: SafeArea(
|
||||
child: SingleChildScrollView(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
child: Column(
|
||||
children: [
|
||||
// Header: Logo + Nombre
|
||||
_buildHeader(name, info?.iconUrl),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// MOTD Banner (si existe)
|
||||
if (hasMotd) _buildMotdBanner(motd),
|
||||
|
||||
// Descripción (si existe)
|
||||
if (info?.description != null && info!.description!.isNotEmpty) ...[
|
||||
const SizedBox(height: 16),
|
||||
_buildDescription(info.description!),
|
||||
],
|
||||
|
||||
// Sección CONTACT
|
||||
if (info?.contact != null && info!.contact!.isNotEmpty) ...[
|
||||
const SizedBox(height: 24),
|
||||
_buildSectionDivider('CONTACT'),
|
||||
const SizedBox(height: 16),
|
||||
_buildContactSection(info.contact!),
|
||||
],
|
||||
|
||||
// Sección MINT DETAILS
|
||||
const SizedBox(height: 24),
|
||||
_buildSectionDivider('MINT DETAILS'),
|
||||
const SizedBox(height: 16),
|
||||
_buildMintDetails(info),
|
||||
|
||||
// Sección ACTIONS
|
||||
const SizedBox(height: 24),
|
||||
_buildSectionDivider('ACTIONS'),
|
||||
const SizedBox(height: 16),
|
||||
_buildActions(),
|
||||
|
||||
const SizedBox(height: 40),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHeader(String name, String? iconUrl) {
|
||||
return Column(
|
||||
children: [
|
||||
// Logo
|
||||
Container(
|
||||
width: 72,
|
||||
height: 72,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.primaryAction.withValues(alpha: 0.15),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: iconUrl != null
|
||||
? ClipOval(
|
||||
child: Image.network(
|
||||
iconUrl,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (_, __, ___) => const Icon(
|
||||
LucideIcons.landmark,
|
||||
color: AppColors.primaryAction,
|
||||
size: 36,
|
||||
),
|
||||
),
|
||||
)
|
||||
: const Icon(
|
||||
LucideIcons.landmark,
|
||||
color: AppColors.primaryAction,
|
||||
size: 36,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
// Nombre
|
||||
Text(
|
||||
name,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.white,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
// Badge activo
|
||||
if (widget.isActive) ...[
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.success.withValues(alpha: 0.2),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Text(
|
||||
L10n.of(context)!.activeMint,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.success,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMotdBanner(String motd) {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFF18408).withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: const Color(0xFFF18408).withValues(alpha: 0.5),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Icon(
|
||||
LucideIcons.info,
|
||||
color: Color(0xFFF18408),
|
||||
size: 20,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
L10n.of(context)!.mintMessage,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Color(0xFFF18408),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
motd,
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 13,
|
||||
color: Colors.white.withValues(alpha: 0.8),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
GestureDetector(
|
||||
onTap: () => setState(() => _motdDismissed = true),
|
||||
child: Icon(
|
||||
LucideIcons.x,
|
||||
color: Colors.white.withValues(alpha: 0.5),
|
||||
size: 18,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDescription(String description) {
|
||||
return Text(
|
||||
description,
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 14,
|
||||
color: Colors.white.withValues(alpha: 0.7),
|
||||
height: 1.5,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSectionDivider(String title) {
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Container(
|
||||
height: 1,
|
||||
color: Colors.white.withValues(alpha: 0.1),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Text(
|
||||
title,
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: 1,
|
||||
color: Colors.white.withValues(alpha: 0.5),
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Container(
|
||||
height: 1,
|
||||
color: Colors.white.withValues(alpha: 0.1),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildContactSection(List<ContactInfo> contacts) {
|
||||
return Column(
|
||||
children: contacts.map((contact) {
|
||||
final icon = _getContactIcon(contact.method);
|
||||
final value = contact.info;
|
||||
|
||||
return _buildCopyableRow(
|
||||
icon: icon,
|
||||
label: value,
|
||||
onCopy: () => _copyToClipboard(value, contact.method),
|
||||
);
|
||||
}).toList(),
|
||||
);
|
||||
}
|
||||
|
||||
IconData _getContactIcon(String method) {
|
||||
switch (method.toLowerCase()) {
|
||||
case 'email':
|
||||
return LucideIcons.mail;
|
||||
case 'twitter':
|
||||
case 'x':
|
||||
return LucideIcons.atSign;
|
||||
case 'nostr':
|
||||
return LucideIcons.key;
|
||||
default:
|
||||
return LucideIcons.link;
|
||||
}
|
||||
}
|
||||
|
||||
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';
|
||||
|
||||
// Versión del mint
|
||||
final version = info?.version != null
|
||||
? '${info!.version!.name}/${info.version!.version}'
|
||||
: l10n.unknown;
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
_buildDetailRow(
|
||||
icon: LucideIcons.link,
|
||||
label: l10n.url,
|
||||
value: widget.mintUrl,
|
||||
canCopy: true,
|
||||
),
|
||||
_buildDetailRow(
|
||||
icon: LucideIcons.coins,
|
||||
label: l10n.currency,
|
||||
value: currencyDisplay,
|
||||
),
|
||||
_buildDetailRow(
|
||||
icon: LucideIcons.box,
|
||||
label: l10n.version,
|
||||
value: version,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDetailRow({
|
||||
required IconData icon,
|
||||
required String label,
|
||||
required String value,
|
||||
bool canCopy = false,
|
||||
}) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 16),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
icon,
|
||||
color: Colors.white.withValues(alpha: 0.5),
|
||||
size: 20,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 14,
|
||||
color: Colors.white.withValues(alpha: 0.6),
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
Flexible(
|
||||
child: Text(
|
||||
value,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Colors.white,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
textAlign: TextAlign.right,
|
||||
),
|
||||
),
|
||||
if (canCopy) ...[
|
||||
const SizedBox(width: 8),
|
||||
GestureDetector(
|
||||
onTap: () => _copyToClipboard(value, label),
|
||||
child: Icon(
|
||||
LucideIcons.copy,
|
||||
color: Colors.white.withValues(alpha: 0.5),
|
||||
size: 16,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCopyableRow({
|
||||
required IconData icon,
|
||||
required String label,
|
||||
required VoidCallback onCopy,
|
||||
}) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
icon,
|
||||
color: Colors.white.withValues(alpha: 0.5),
|
||||
size: 20,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
label,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 14,
|
||||
color: Colors.white,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
GestureDetector(
|
||||
onTap: onCopy,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: Icon(
|
||||
LucideIcons.copy,
|
||||
color: Colors.white.withValues(alpha: 0.5),
|
||||
size: 18,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
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: l10n.useThisMint,
|
||||
onTap: () {
|
||||
widget.onSetActive!();
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
|
||||
// Copiar URL
|
||||
_buildActionButton(
|
||||
icon: LucideIcons.copy,
|
||||
label: l10n.copyMintUrl,
|
||||
onTap: () => _copyToClipboard(widget.mintUrl, l10n.url),
|
||||
),
|
||||
|
||||
// Eliminar mint
|
||||
if (widget.onDelete != null)
|
||||
_buildActionButton(
|
||||
icon: LucideIcons.trash2,
|
||||
label: l10n.deleteMint,
|
||||
isDestructive: true,
|
||||
onTap: () => _showDeleteConfirmation(),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildActionButton({
|
||||
required IconData icon,
|
||||
required String label,
|
||||
required VoidCallback onTap,
|
||||
bool isDestructive = false,
|
||||
}) {
|
||||
final color = isDestructive ? AppColors.error : Colors.white;
|
||||
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 16),
|
||||
decoration: BoxDecoration(
|
||||
color: isDestructive
|
||||
? AppColors.error.withValues(alpha: 0.1)
|
||||
: Colors.white.withValues(alpha: 0.05),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(icon, color: color, size: 20),
|
||||
const SizedBox(width: 12),
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: color,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _copyToClipboard(String text, String label) {
|
||||
Clipboard.setData(ClipboardData(text: text));
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(L10n.of(context)!.copied(label)),
|
||||
backgroundColor: AppColors.success,
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showDeleteConfirmation() {
|
||||
final l10n = L10n.of(context)!;
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
backgroundColor: AppColors.deepVoidPurple,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
title: Text(
|
||||
l10n.deleteMintConfirmTitle,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
content: Text(
|
||||
l10n.deleteMintConfirmMessage,
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
color: Colors.white.withValues(alpha: 0.8),
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: Text(
|
||||
l10n.cancel,
|
||||
style: const TextStyle(color: AppColors.textSecondary),
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.pop(context); // Cerrar diálogo
|
||||
Navigator.pop(context); // Volver a lista
|
||||
widget.onDelete!();
|
||||
},
|
||||
child: Text(
|
||||
l10n.delete,
|
||||
style: const TextStyle(color: AppColors.error),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +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 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
import '../../core/constants/colors.dart';
|
||||
import '../../core/constants/dimensions.dart';
|
||||
import '../../core/utils/formatters.dart';
|
||||
@@ -8,6 +10,7 @@ import '../../providers/wallet_provider.dart';
|
||||
import '../../widgets/common/gradient_background.dart';
|
||||
import '../../widgets/common/glass_card.dart';
|
||||
import '../../widgets/common/primary_button.dart';
|
||||
import 'mint_detail_screen.dart';
|
||||
|
||||
/// Pantalla para gestionar mints conectados
|
||||
class MintsScreen extends StatefulWidget {
|
||||
@@ -30,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,
|
||||
@@ -98,6 +101,7 @@ class _MintsScreenState extends State<MintsScreen> {
|
||||
}
|
||||
|
||||
Widget _buildEmptyState() {
|
||||
final l10n = L10n.of(context)!;
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
@@ -109,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,
|
||||
@@ -119,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,
|
||||
@@ -141,267 +145,262 @@ class _MintsScreenState extends State<MintsScreen> {
|
||||
builder: (context, balanceSnapshot) {
|
||||
final balances = balanceSnapshot.data ?? {};
|
||||
|
||||
return GlassCard(
|
||||
padding: const EdgeInsets.all(AppDimensions.paddingMedium),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Header: URL + estado + refresh
|
||||
Row(
|
||||
children: [
|
||||
// Icono estado conexión (verde si es el activo)
|
||||
Container(
|
||||
width: 8,
|
||||
height: 8,
|
||||
decoration: BoxDecoration(
|
||||
color: isActive ? AppColors.success : AppColors.textSecondary,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
return FutureBuilder<MintInfo?>(
|
||||
future: walletProvider.fetchMintInfo(mintUrl),
|
||||
builder: (context, infoSnapshot) {
|
||||
final mintInfo = infoSnapshot.data;
|
||||
final mintName = mintInfo?.name ?? UnitFormatter.getMintDisplayName(mintUrl);
|
||||
final iconUrl = mintInfo?.iconUrl;
|
||||
|
||||
// URL del mint (display name)
|
||||
Expanded(
|
||||
child: Text(
|
||||
UnitFormatter.getMintDisplayName(mintUrl),
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.white,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
|
||||
// Botón refresh
|
||||
GestureDetector(
|
||||
onTap: () => _refreshMint(mintUrl, walletProvider),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(4),
|
||||
child: Icon(
|
||||
LucideIcons.refreshCw,
|
||||
color: AppColors.textSecondary,
|
||||
size: 18,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(width: 8),
|
||||
|
||||
// Badge activo
|
||||
if (isActive)
|
||||
return GestureDetector(
|
||||
onTap: () => _openMintDetails(
|
||||
mintUrl,
|
||||
walletProvider,
|
||||
mintInfo,
|
||||
balances,
|
||||
isActive,
|
||||
isCubaBitcoin,
|
||||
),
|
||||
child: GlassCard(
|
||||
padding: const EdgeInsets.all(AppDimensions.paddingMedium),
|
||||
child: Row(
|
||||
children: [
|
||||
// Logo del mint
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 4,
|
||||
),
|
||||
width: 48,
|
||||
height: 48,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.primaryAction.withValues(alpha: 0.2),
|
||||
color: AppColors.primaryAction.withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Text(
|
||||
'Activo',
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.primaryAction,
|
||||
),
|
||||
child: iconUrl != null
|
||||
? ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Image.network(
|
||||
iconUrl,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (_, __, ___) => const Icon(
|
||||
LucideIcons.landmark,
|
||||
color: AppColors.primaryAction,
|
||||
size: 24,
|
||||
),
|
||||
),
|
||||
)
|
||||
: const Icon(
|
||||
LucideIcons.landmark,
|
||||
color: AppColors.primaryAction,
|
||||
size: 24,
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(width: 12),
|
||||
|
||||
// Info del mint
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Nombre + badge activo
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
mintName,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.white,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
if (isActive)
|
||||
Container(
|
||||
margin: const EdgeInsets.only(left: 8),
|
||||
width: 8,
|
||||
height: 8,
|
||||
decoration: const BoxDecoration(
|
||||
color: AppColors.success,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 2),
|
||||
|
||||
// URL
|
||||
Text(
|
||||
mintUrl,
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 12,
|
||||
color: AppColors.textSecondary.withValues(alpha: 0.6),
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
|
||||
const SizedBox(height: 6),
|
||||
|
||||
// Balance badge
|
||||
if (balanceSnapshot.connectionState == ConnectionState.waiting)
|
||||
SizedBox(
|
||||
width: 12,
|
||||
height: 12,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
)
|
||||
else
|
||||
_buildBalanceBadges(units, balances),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 4),
|
||||
|
||||
// URL completa (más pequeña)
|
||||
Text(
|
||||
mintUrl,
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 12,
|
||||
color: AppColors.textSecondary.withValues(alpha: 0.6),
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
|
||||
const SizedBox(height: AppDimensions.paddingSmall),
|
||||
|
||||
// Balances por unidad
|
||||
if (balanceSnapshot.connectionState == ConnectionState.waiting)
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
'Balance:',
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 14,
|
||||
color: AppColors.textSecondary.withValues(alpha: 0.7),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
SizedBox(
|
||||
width: 14,
|
||||
height: 14,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
// Chevron
|
||||
Icon(
|
||||
LucideIcons.chevronRight,
|
||||
color: Colors.white.withValues(alpha: 0.3),
|
||||
size: 20,
|
||||
),
|
||||
],
|
||||
)
|
||||
else
|
||||
_buildBalancesList(units, balances),
|
||||
|
||||
const SizedBox(height: AppDimensions.paddingMedium),
|
||||
|
||||
// Acciones
|
||||
Row(
|
||||
children: [
|
||||
// Hacer activo (si no lo es)
|
||||
if (!isActive)
|
||||
Expanded(
|
||||
child: GestureDetector(
|
||||
onTap: () => _setActiveMint(mintUrl, walletProvider),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
LucideIcons.star,
|
||||
color: AppColors.textSecondary,
|
||||
size: 16,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
'Usar este mint',
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
if (!isActive && !isCubaBitcoin) const SizedBox(width: 8),
|
||||
|
||||
// Eliminar (no disponible para Cuba Bitcoin)
|
||||
if (!isCubaBitcoin)
|
||||
Expanded(
|
||||
child: GestureDetector(
|
||||
onTap: () => _showDeleteDialog(mintUrl, balances, walletProvider),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.error.withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
LucideIcons.trash2,
|
||||
color: AppColors.error,
|
||||
size: 16,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
'Eliminar',
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: AppColors.error,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Construye la lista de balances por unidad.
|
||||
Widget _buildBalancesList(List<String> units, Map<String, BigInt> balances) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Balances:',
|
||||
/// Badges de balance compactos (estilo cashu.me)
|
||||
Widget _buildBalanceBadges(List<String> units, Map<String, BigInt> balances) {
|
||||
final nonZeroBalances = balances.entries
|
||||
.where((e) => e.value > BigInt.zero)
|
||||
.toList();
|
||||
|
||||
if (nonZeroBalances.isEmpty) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
'0 ${units.isNotEmpty ? units.first : "sat"}',
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 14,
|
||||
color: AppColors.textSecondary.withValues(alpha: 0.7),
|
||||
fontSize: 12,
|
||||
color: Colors.white.withValues(alpha: 0.6),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
...units.map((unit) {
|
||||
final balance = balances[unit] ?? BigInt.zero;
|
||||
final formattedBalance = UnitFormatter.formatBalance(balance, unit);
|
||||
final unitLabel = UnitFormatter.getUnitLabel(unit);
|
||||
);
|
||||
}
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(left: 8, top: 2),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 6,
|
||||
height: 6,
|
||||
decoration: BoxDecoration(
|
||||
color: balance > BigInt.zero
|
||||
? AppColors.success
|
||||
: AppColors.textSecondary.withValues(alpha: 0.3),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'$formattedBalance $unitLabel',
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 14,
|
||||
fontWeight: balance > BigInt.zero ? FontWeight.w600 : FontWeight.normal,
|
||||
color: balance > BigInt.zero
|
||||
? Colors.white
|
||||
: AppColors.textSecondary.withValues(alpha: 0.7),
|
||||
),
|
||||
),
|
||||
],
|
||||
return Wrap(
|
||||
spacing: 6,
|
||||
runSpacing: 4,
|
||||
children: nonZeroBalances.map((entry) {
|
||||
final unit = entry.key;
|
||||
final balance = entry.value;
|
||||
final formatted = UnitFormatter.formatBalance(balance, unit);
|
||||
final label = UnitFormatter.getUnitLabel(unit);
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.primaryAction.withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
'$formatted $label',
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: AppColors.primaryAction,
|
||||
),
|
||||
);
|
||||
}),
|
||||
],
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
);
|
||||
}
|
||||
|
||||
/// Refresca la información del mint (detecta nuevas unidades).
|
||||
Future<void> _refreshMint(String mintUrl, WalletProvider walletProvider) async {
|
||||
/// Abre la pantalla de detalles del mint
|
||||
void _openMintDetails(
|
||||
String mintUrl,
|
||||
WalletProvider walletProvider,
|
||||
MintInfo? mintInfo,
|
||||
Map<String, BigInt> balances,
|
||||
bool isActive,
|
||||
bool isCubaBitcoin,
|
||||
) {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => MintDetailScreen(
|
||||
mintUrl: mintUrl,
|
||||
mintInfo: mintInfo,
|
||||
isActive: isActive,
|
||||
balances: balances,
|
||||
onSetActive: isActive
|
||||
? null
|
||||
: () => _setActiveMint(mintUrl, walletProvider),
|
||||
onDelete: isCubaBitcoin
|
||||
? null
|
||||
: () => _deleteMint(mintUrl, walletProvider),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Elimina un mint (llamado desde pantalla de detalles)
|
||||
Future<void> _deleteMint(String mintUrl, WalletProvider walletProvider) async {
|
||||
final l10n = L10n.of(context)!;
|
||||
try {
|
||||
final units = await walletProvider.refreshMint(mintUrl);
|
||||
await walletProvider.removeMint(mintUrl);
|
||||
if (mounted) {
|
||||
setState(() {});
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(l10n.mintDeleted),
|
||||
backgroundColor: AppColors.success,
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('${l10n.error}: $e'),
|
||||
backgroundColor: AppColors.error,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildAddMintButton(WalletProvider walletProvider) {
|
||||
final l10n = L10n.of(context)!;
|
||||
return PrimaryButton(
|
||||
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(
|
||||
SnackBar(
|
||||
content: Text('Unidades detectadas: ${units.join(", ")}'),
|
||||
content: Text(l10n.activeMintUpdated),
|
||||
backgroundColor: AppColors.success,
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
@@ -411,95 +410,7 @@ class _MintsScreenState extends State<MintsScreen> {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Error: $e'),
|
||||
backgroundColor: AppColors.error,
|
||||
duration: const Duration(seconds: 3),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildAddMintButton(WalletProvider walletProvider) {
|
||||
return PrimaryButton(
|
||||
text: 'Agregar mint',
|
||||
icon: LucideIcons.plus,
|
||||
onPressed: () => _showAddMintDialog(walletProvider),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _setActiveMint(String mintUrl, WalletProvider walletProvider) async {
|
||||
try {
|
||||
await walletProvider.setActiveMint(mintUrl);
|
||||
|
||||
if (mounted) {
|
||||
setState(() {}); // Rebuild para actualizar UI
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Mint activo actualizado'),
|
||||
backgroundColor: AppColors.success,
|
||||
duration: Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Error: $e'),
|
||||
backgroundColor: AppColors.error,
|
||||
duration: const Duration(seconds: 3),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _showDeleteDialog(String mintUrl, Map<String, BigInt> balances, WalletProvider walletProvider) {
|
||||
// Verificar si hay balance en alguna unidad
|
||||
final hasBalance = balances.values.any((b) => b > BigInt.zero);
|
||||
|
||||
// Calcular balance total para mostrar (simplificado)
|
||||
final balanceStrings = balances.entries
|
||||
.where((e) => e.value > BigInt.zero)
|
||||
.map((e) => '${UnitFormatter.formatBalance(e.value, e.key)} ${UnitFormatter.getUnitLabel(e.key)}')
|
||||
.toList();
|
||||
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
backgroundColor: Colors.transparent,
|
||||
builder: (context) => _DeleteMintModal(
|
||||
mintUrl: mintUrl,
|
||||
hasBalance: hasBalance,
|
||||
balanceDescription: balanceStrings.isEmpty ? '' : balanceStrings.join(', '),
|
||||
onConfirm: () async {
|
||||
Navigator.pop(context);
|
||||
await _deleteMint(mintUrl, walletProvider);
|
||||
},
|
||||
onCancel: () => Navigator.pop(context),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _deleteMint(String mintUrl, WalletProvider walletProvider) async {
|
||||
try {
|
||||
await walletProvider.removeMint(mintUrl);
|
||||
|
||||
if (mounted) {
|
||||
setState(() {}); // Rebuild para actualizar UI
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Mint eliminado'),
|
||||
backgroundColor: AppColors.success,
|
||||
duration: Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Error: $e'),
|
||||
content: Text('${l10n.error}: $e'),
|
||||
backgroundColor: AppColors.error,
|
||||
duration: const Duration(seconds: 3),
|
||||
),
|
||||
@@ -522,191 +433,6 @@ class _MintsScreenState extends State<MintsScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Modal para confirmar eliminación de mint
|
||||
class _DeleteMintModal extends StatelessWidget {
|
||||
final String mintUrl;
|
||||
final bool hasBalance;
|
||||
final String balanceDescription;
|
||||
final VoidCallback onConfirm;
|
||||
final VoidCallback onCancel;
|
||||
|
||||
const _DeleteMintModal({
|
||||
required this.mintUrl,
|
||||
required this.hasBalance,
|
||||
required this.balanceDescription,
|
||||
required this.onConfirm,
|
||||
required this.onCancel,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(AppDimensions.paddingMedium),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.deepVoidPurple,
|
||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(24)),
|
||||
border: Border.all(
|
||||
color: Colors.white.withValues(alpha: 0.1),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
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 advertencia
|
||||
Container(
|
||||
width: 64,
|
||||
height: 64,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.error.withValues(alpha: 0.2),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(
|
||||
LucideIcons.alertTriangle,
|
||||
color: AppColors.error,
|
||||
size: 32,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppDimensions.paddingMedium),
|
||||
|
||||
// Título
|
||||
const Text(
|
||||
'¿Eliminar mint?',
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppDimensions.paddingSmall),
|
||||
|
||||
// URL del mint
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 32),
|
||||
child: Text(
|
||||
mintUrl,
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 14,
|
||||
color: AppColors.textSecondary.withValues(alpha: 0.7),
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: AppDimensions.paddingMedium),
|
||||
|
||||
// Advertencia según balance
|
||||
Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 32),
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: hasBalance
|
||||
? AppColors.secondaryAction.withValues(alpha: 0.2)
|
||||
: Colors.white.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(
|
||||
hasBalance ? LucideIcons.alertCircle : LucideIcons.info,
|
||||
color: hasBalance ? AppColors.secondaryAction : AppColors.textSecondary,
|
||||
size: 16,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
hasBalance
|
||||
? 'Este mint tiene $balanceDescription. Puedes recuperarlos después agregando el mint de nuevo.'
|
||||
: 'Perderás acceso a este mint',
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 12,
|
||||
color: hasBalance
|
||||
? AppColors.secondaryAction
|
||||
: AppColors.textSecondary.withValues(alpha: 0.7),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: AppDimensions.paddingLarge),
|
||||
|
||||
// Botones
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: GestureDetector(
|
||||
onTap: onCancel,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: const Center(
|
||||
child: Text(
|
||||
'Cancelar',
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: AppDimensions.paddingMedium),
|
||||
Expanded(
|
||||
child: GestureDetector(
|
||||
onTap: onConfirm,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.error,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: const Center(
|
||||
child: Text(
|
||||
'Eliminar',
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: AppDimensions.paddingSmall),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Diálogo para agregar nuevo mint
|
||||
class _AddMintDialog extends StatefulWidget {
|
||||
final WalletProvider walletProvider;
|
||||
@@ -735,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,
|
||||
@@ -751,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,
|
||||
@@ -770,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,
|
||||
@@ -806,7 +533,7 @@ class _AddMintDialogState extends State<_AddMintDialog> {
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Text(
|
||||
'Conectando al mint...',
|
||||
l10n.connectingToMint,
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 14,
|
||||
@@ -822,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,
|
||||
@@ -842,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,
|
||||
@@ -856,6 +583,7 @@ class _AddMintDialogState extends State<_AddMintDialog> {
|
||||
}
|
||||
|
||||
void _validateUrl(String value) {
|
||||
final l10n = L10n.of(context)!;
|
||||
setState(() {
|
||||
_errorMessage = null;
|
||||
});
|
||||
@@ -874,7 +602,7 @@ class _AddMintDialogState extends State<_AddMintDialog> {
|
||||
if (!isValidBasic) {
|
||||
setState(() {
|
||||
_isValid = false;
|
||||
_errorMessage = 'La URL debe comenzar con https://';
|
||||
_errorMessage = l10n.urlMustStartWithHttps;
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -885,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;
|
||||
|
||||
@@ -902,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),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -913,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:flutter_gen/gen_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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:lucide_icons/lucide_icons.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
import '../../core/constants/colors.dart';
|
||||
import '../../core/constants/dimensions.dart';
|
||||
import '../../core/utils/formatters.dart';
|
||||
@@ -11,6 +12,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 {
|
||||
@@ -23,6 +26,7 @@ class SettingsScreen extends StatefulWidget {
|
||||
class _SettingsScreenState extends State<SettingsScreen> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = L10n.of(context)!;
|
||||
return GradientBackground(
|
||||
child: Scaffold(
|
||||
backgroundColor: Colors.transparent,
|
||||
@@ -33,9 +37,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 +55,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 +78,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 +91,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',
|
||||
title: l10n.version,
|
||||
subtitle: '0.0.1',
|
||||
),
|
||||
_buildSettingTile(
|
||||
icon: LucideIcons.info,
|
||||
title: 'Acerca de',
|
||||
title: l10n.about,
|
||||
onTap: () => _showAboutDialog(context),
|
||||
),
|
||||
_buildSettingTile(
|
||||
@@ -215,6 +227,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 +373,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 +391,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 +411,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 +454,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 +487,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 +501,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 +512,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
}
|
||||
|
||||
Future<String?> _showCreatePinDialog(BuildContext context) async {
|
||||
final l10n = L10n.of(context)!;
|
||||
String? firstPin;
|
||||
|
||||
// Primer ingreso
|
||||
@@ -393,8 +520,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 +532,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 +542,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 +556,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 +573,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 +583,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,
|
||||
@@ -625,7 +629,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,
|
||||
@@ -635,7 +639,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Cashu wallet con identidad cubana, hermana de La Chispa.',
|
||||
L10n.of(context)!.aboutDescription,
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 14,
|
||||
@@ -676,9 +680,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
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,7 +693,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
/// 5. Abrir GitHub
|
||||
/// 4. Abrir GitHub
|
||||
Future<void> _openGitHub() async {
|
||||
final url = Uri.parse('https://github.com/Forte11Cuba/elcaju');
|
||||
try {
|
||||
@@ -697,8 +701,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
} catch (e) {
|
||||
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 +710,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
/// 6. Recuperar tokens
|
||||
/// 5. Recuperar tokens
|
||||
void _showRecoverTokensDialog(
|
||||
BuildContext context, SettingsProvider settingsProvider) {
|
||||
showModalBottomSheet(
|
||||
@@ -719,7 +723,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
/// 7. Borrar wallet
|
||||
/// 6. Borrar wallet
|
||||
void _showDeleteWalletDialog(
|
||||
BuildContext context, SettingsProvider settingsProvider) {
|
||||
showModalBottomSheet(
|
||||
@@ -757,7 +761,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 +986,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 +1033,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 +1062,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 +1077,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 +1097,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 +1113,7 @@ class _DeleteWalletModalState extends State<_DeleteWalletModal> {
|
||||
color: Colors.white,
|
||||
),
|
||||
decoration: InputDecoration(
|
||||
hintText: 'BORRAR',
|
||||
hintText: confirmWord,
|
||||
hintStyle: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 16,
|
||||
@@ -1122,7 +1128,7 @@ class _DeleteWalletModalState extends State<_DeleteWalletModal> {
|
||||
),
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_canDelete = value == 'BORRAR';
|
||||
_canDelete = value == confirmWord;
|
||||
});
|
||||
},
|
||||
),
|
||||
@@ -1144,10 +1150,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 +1178,7 @@ class _DeleteWalletModalState extends State<_DeleteWalletModal> {
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
'Borrar wallet',
|
||||
l10n.deleteWallet,
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 16,
|
||||
@@ -1293,9 +1299,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 +1312,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 +1351,7 @@ class _RecoverTokensModalState extends State<_RecoverTokensModal> {
|
||||
}
|
||||
|
||||
Widget _buildMnemonicOptions() {
|
||||
final l10n = L10n.of(context)!;
|
||||
return Column(
|
||||
children: [
|
||||
// Opción: Usar mnemonic actual
|
||||
@@ -1379,9 +1386,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 +1396,7 @@ class _RecoverTokensModalState extends State<_RecoverTokensModal> {
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'Escanear mints con las 12 palabras guardadas',
|
||||
l10n.scanWithSavedWords,
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 12,
|
||||
@@ -1438,9 +1445,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 +1455,7 @@ class _RecoverTokensModalState extends State<_RecoverTokensModal> {
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'Recuperar tokens de otras 12 palabras',
|
||||
l10n.recoverFromOtherWords,
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 12,
|
||||
@@ -1467,6 +1474,7 @@ class _RecoverTokensModalState extends State<_RecoverTokensModal> {
|
||||
}
|
||||
|
||||
Widget _buildMintSelector() {
|
||||
final l10n = L10n.of(context)!;
|
||||
if (_isLoadingMints) {
|
||||
return const Padding(
|
||||
padding: EdgeInsets.all(16),
|
||||
@@ -1487,7 +1495,7 @@ class _RecoverTokensModalState extends State<_RecoverTokensModal> {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Mints a escanear:',
|
||||
l10n.mintsToScan,
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 14,
|
||||
@@ -1524,7 +1532,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 +1573,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 +1647,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 +1729,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 +1746,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 +1767,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 +1796,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 +1810,7 @@ class _RecoverTokensModalState extends State<_RecoverTokensModal> {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_isSuccess = false;
|
||||
_result = 'Selecciona un mint para escanear';
|
||||
_result = l10n.selectMintToScan;
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -1825,9 +1833,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 +1848,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 +1860,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 +1878,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 +1888,7 @@ class _RecoverTokensModalState extends State<_RecoverTokensModal> {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_isSuccess = false;
|
||||
_result = 'Error: $e';
|
||||
_result = '${l10n.error}: $e';
|
||||
});
|
||||
} finally {
|
||||
if (mounted) {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,328 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import '../../core/constants/colors.dart';
|
||||
import '../../core/constants/dimensions.dart';
|
||||
|
||||
/// Tipos de botón según la importancia de la acción
|
||||
/// La intensidad del efecto = importancia de la acción
|
||||
enum ButtonType {
|
||||
/// Acciones críticas que mueven dinero (Enviar)
|
||||
/// Efecto más fuerte: heavyImpact, scale 0.95, delay 100ms
|
||||
criticalAction,
|
||||
|
||||
/// Acciones importantes pero seguras (Recibir)
|
||||
/// Efecto medio: mediumImpact, scale 0.97, sin delay
|
||||
primaryAction,
|
||||
|
||||
/// Acciones de navegación (Historial)
|
||||
/// Efecto sutil: lightImpact, scale 0.98, sin delay
|
||||
navigation,
|
||||
}
|
||||
|
||||
/// Botón animado con feedback táctil y visual premium
|
||||
///
|
||||
/// Diferencia la intensidad del efecto según el tipo de acción:
|
||||
/// - [ButtonType.criticalAction]: Para acciones que mueven dinero
|
||||
/// - [ButtonType.primaryAction]: Para acciones importantes pero seguras
|
||||
/// - [ButtonType.navigation]: Para navegación simple
|
||||
///
|
||||
/// Ejemplo de uso:
|
||||
/// ```dart
|
||||
/// AnimatedActionButton(
|
||||
/// label: 'Enviar',
|
||||
/// type: ButtonType.criticalAction,
|
||||
/// onTap: () => _handleSend(),
|
||||
/// )
|
||||
/// ```
|
||||
class AnimatedActionButton extends StatefulWidget {
|
||||
/// Texto del botón
|
||||
final String label;
|
||||
|
||||
/// Callback al tocar (se ejecuta después del micro-delay si aplica)
|
||||
final VoidCallback onTap;
|
||||
|
||||
/// Tipo de botón que determina la intensidad del efecto
|
||||
final ButtonType type;
|
||||
|
||||
/// Gradiente opcional (para botones primarios)
|
||||
/// Si es null y backgroundColor también, usa el gradiente por defecto
|
||||
final Gradient? gradient;
|
||||
|
||||
/// Color de fondo opcional (para botones de navegación)
|
||||
/// Se ignora si gradient está definido
|
||||
final Color? backgroundColor;
|
||||
|
||||
/// Icono opcional antes del texto
|
||||
final IconData? icon;
|
||||
|
||||
/// Si mostrar el icono (default: false)
|
||||
final bool showIcon;
|
||||
|
||||
/// Ancho del botón (default: expandir al padre)
|
||||
final double? width;
|
||||
|
||||
/// Alto del botón (default: según tipo)
|
||||
final double? height;
|
||||
|
||||
const AnimatedActionButton({
|
||||
super.key,
|
||||
required this.label,
|
||||
required this.onTap,
|
||||
required this.type,
|
||||
this.gradient,
|
||||
this.backgroundColor,
|
||||
this.icon,
|
||||
this.showIcon = false,
|
||||
this.width,
|
||||
this.height,
|
||||
});
|
||||
|
||||
@override
|
||||
State<AnimatedActionButton> createState() => _AnimatedActionButtonState();
|
||||
}
|
||||
|
||||
class _AnimatedActionButtonState extends State<AnimatedActionButton>
|
||||
with SingleTickerProviderStateMixin {
|
||||
/// Controller para animaciones coordinadas
|
||||
late AnimationController _controller;
|
||||
|
||||
/// Animación de escala
|
||||
late Animation<double> _scaleAnimation;
|
||||
|
||||
/// Animación de offset de sombra
|
||||
late Animation<double> _shadowOffsetAnimation;
|
||||
|
||||
/// Animación de blur de sombra
|
||||
late Animation<double> _shadowBlurAnimation;
|
||||
|
||||
/// Animación de opacidad de sombra
|
||||
late Animation<double> _shadowOpacityAnimation;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
// Duración de la animación: 150ms para sentirse responsivo
|
||||
_controller = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 150),
|
||||
);
|
||||
|
||||
// Configurar animaciones según el tipo de botón
|
||||
_setupAnimations();
|
||||
}
|
||||
|
||||
void _setupAnimations() {
|
||||
// Curve suave para salida (easeOutCubic da sensación premium)
|
||||
const curve = Curves.easeOutCubic;
|
||||
|
||||
// === ESCALA ===
|
||||
// Intensidad según tipo: crítico se hunde más, navegación casi imperceptible
|
||||
final double targetScale = switch (widget.type) {
|
||||
ButtonType.criticalAction => 0.95, // Se hunde más - acción crítica
|
||||
ButtonType.primaryAction => 0.97, // Se hunde menos
|
||||
ButtonType.navigation => 0.98, // Muy sutil - solo navegación
|
||||
};
|
||||
|
||||
_scaleAnimation = Tween<double>(
|
||||
begin: 1.0,
|
||||
end: targetScale,
|
||||
).animate(CurvedAnimation(parent: _controller, curve: curve));
|
||||
|
||||
// === SOMBRA ===
|
||||
// Los valores de sombra también varían según tipo
|
||||
final (normalOffset, pressedOffset) = switch (widget.type) {
|
||||
ButtonType.criticalAction => (6.0, 2.0),
|
||||
ButtonType.primaryAction => (6.0, 2.0),
|
||||
ButtonType.navigation => (4.0, 1.0),
|
||||
};
|
||||
|
||||
final (normalBlur, pressedBlur) = switch (widget.type) {
|
||||
ButtonType.criticalAction => (12.0, 4.0),
|
||||
ButtonType.primaryAction => (12.0, 4.0),
|
||||
ButtonType.navigation => (8.0, 3.0),
|
||||
};
|
||||
|
||||
final (normalOpacity, pressedOpacity) = switch (widget.type) {
|
||||
ButtonType.criticalAction => (0.4, 0.15),
|
||||
ButtonType.primaryAction => (0.3, 0.1),
|
||||
ButtonType.navigation => (0.2, 0.1),
|
||||
};
|
||||
|
||||
_shadowOffsetAnimation = Tween<double>(
|
||||
begin: normalOffset,
|
||||
end: pressedOffset,
|
||||
).animate(CurvedAnimation(parent: _controller, curve: curve));
|
||||
|
||||
_shadowBlurAnimation = Tween<double>(
|
||||
begin: normalBlur,
|
||||
end: pressedBlur,
|
||||
).animate(CurvedAnimation(parent: _controller, curve: curve));
|
||||
|
||||
_shadowOpacityAnimation = Tween<double>(
|
||||
begin: normalOpacity,
|
||||
end: pressedOpacity,
|
||||
).animate(CurvedAnimation(parent: _controller, curve: curve));
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(AnimatedActionButton oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
// Reconfigurar si cambia el tipo
|
||||
if (oldWidget.type != widget.type) {
|
||||
_setupAnimations();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// Trigger haptic feedback según tipo de acción
|
||||
void _triggerHaptic() {
|
||||
switch (widget.type) {
|
||||
case ButtonType.criticalAction:
|
||||
// Acción crítica - mueve dinero - feedback fuerte
|
||||
HapticFeedback.heavyImpact();
|
||||
break;
|
||||
case ButtonType.primaryAction:
|
||||
// Acción importante pero segura - feedback medio
|
||||
HapticFeedback.mediumImpact();
|
||||
break;
|
||||
case ButtonType.navigation:
|
||||
// Solo navegación - feedback sutil
|
||||
HapticFeedback.lightImpact();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/// Delay después del tap según tipo
|
||||
/// Solo acciones críticas tienen micro-delay (sensación de "peso")
|
||||
Duration get _callbackDelay {
|
||||
return widget.type == ButtonType.criticalAction
|
||||
? const Duration(milliseconds: 100)
|
||||
: Duration.zero;
|
||||
}
|
||||
|
||||
void _handleTapDown(TapDownDetails details) {
|
||||
_controller.forward();
|
||||
// Haptic inmediato al tocar
|
||||
_triggerHaptic();
|
||||
}
|
||||
|
||||
void _handleTapUp(TapUpDetails details) {
|
||||
_controller.reverse();
|
||||
|
||||
// Ejecutar callback después del delay según tipo
|
||||
Future.delayed(_callbackDelay, () {
|
||||
widget.onTap();
|
||||
});
|
||||
}
|
||||
|
||||
void _handleTapCancel() {
|
||||
// Revertir animación sin ejecutar callback
|
||||
_controller.reverse();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Determinar si es botón con gradiente o con color sólido
|
||||
final bool isGradientButton = widget.type != ButtonType.navigation;
|
||||
|
||||
// Gradiente por defecto para botones primarios
|
||||
final effectiveGradient = widget.gradient ??
|
||||
(isGradientButton
|
||||
? const LinearGradient(
|
||||
begin: Alignment.centerLeft,
|
||||
end: Alignment.centerRight,
|
||||
colors: AppColors.buttonGradient,
|
||||
)
|
||||
: null);
|
||||
|
||||
// Color de fondo para botón de navegación (glass effect)
|
||||
final effectiveBackgroundColor = widget.backgroundColor ??
|
||||
(!isGradientButton
|
||||
? AppColors.glassBase.withValues(alpha: AppColors.glassOpacity)
|
||||
: null);
|
||||
|
||||
// Color de sombra según tipo
|
||||
final shadowColor = isGradientButton
|
||||
? AppColors.primaryAction
|
||||
: Colors.black;
|
||||
|
||||
// Altura según tipo
|
||||
final effectiveHeight = widget.height ??
|
||||
(widget.type == ButtonType.navigation
|
||||
? AppDimensions.buttonHeight + 4
|
||||
: AppDimensions.buttonHeight);
|
||||
|
||||
return GestureDetector(
|
||||
onTapDown: _handleTapDown,
|
||||
onTapUp: _handleTapUp,
|
||||
onTapCancel: _handleTapCancel,
|
||||
child: AnimatedBuilder(
|
||||
animation: _controller,
|
||||
builder: (context, child) {
|
||||
return Transform.scale(
|
||||
scale: _scaleAnimation.value,
|
||||
child: Container(
|
||||
width: widget.width ?? double.infinity,
|
||||
height: effectiveHeight,
|
||||
decoration: BoxDecoration(
|
||||
// Gradiente o color sólido
|
||||
gradient: effectiveGradient,
|
||||
color: effectiveGradient == null ? effectiveBackgroundColor : null,
|
||||
borderRadius: BorderRadius.circular(AppDimensions.buttonBorderRadius),
|
||||
// Borde para botones de navegación (glass effect)
|
||||
border: !isGradientButton
|
||||
? Border.all(
|
||||
color: Colors.white.withValues(alpha: AppColors.glassBorderOpacity),
|
||||
width: 1,
|
||||
)
|
||||
: null,
|
||||
// Sombra animada
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: shadowColor.withValues(alpha: _shadowOpacityAnimation.value),
|
||||
blurRadius: _shadowBlurAnimation.value,
|
||||
offset: Offset(0, _shadowOffsetAnimation.value),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
},
|
||||
child: Center(
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
// Icono opcional
|
||||
if (widget.showIcon && widget.icon != null) ...[
|
||||
Icon(
|
||||
widget.icon,
|
||||
color: Colors.white,
|
||||
size: widget.type == ButtonType.navigation ? 28 : 20,
|
||||
),
|
||||
const SizedBox(width: AppDimensions.paddingSmall),
|
||||
],
|
||||
// Texto
|
||||
Text(
|
||||
widget.label,
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: widget.type == ButtonType.navigation ? 20 : 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
import 'package:cdk_flutter/cdk_flutter.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) usando TokenDecoder de cdk-flutter
|
||||
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;
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import FlutterMacOS
|
||||
import Foundation
|
||||
|
||||
import flutter_secure_storage_macos
|
||||
import mobile_scanner
|
||||
import path_provider_foundation
|
||||
import share_plus
|
||||
import shared_preferences_foundation
|
||||
@@ -14,6 +15,7 @@ import url_launcher_macos
|
||||
|
||||
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||
FlutterSecureStoragePlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStoragePlugin"))
|
||||
MobileScannerPlugin.register(with: registry.registrar(forPlugin: "MobileScannerPlugin"))
|
||||
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
|
||||
SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin"))
|
||||
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
||||
|
||||
+41
-1
@@ -33,6 +33,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 +57,14 @@ 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_cli_annotations:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -438,6 +462,14 @@ 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"
|
||||
nested:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -526,6 +558,14 @@ 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"
|
||||
posix:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -828,7 +868,7 @@ packages:
|
||||
source: hosted
|
||||
version: "3.1.4"
|
||||
uuid:
|
||||
dependency: transitive
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: uuid
|
||||
sha256: a11b666489b1954e01d992f3d601b1804a33937b5a8fe677bd26b8a9f96f96e8
|
||||
|
||||
@@ -39,6 +39,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,6 +50,15 @@ 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
|
||||
|
||||
# BIP32 for NIP-06 key derivation (P2PK)
|
||||
bip32: ^2.0.0
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
|
||||
Reference in New Issue
Block a user