Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
0e6f06bf7f | ||
|
|
5b70ca3773 | ||
|
|
f0236df966 | ||
|
|
6b1cdabf14 | ||
|
|
48ab3d8c4d | ||
|
|
ac6af4b883 | ||
|
|
0a935193c2 | ||
|
|
a0d55df497 | ||
|
|
8ce9ed37bd | ||
|
|
862d9e36ac | ||
|
|
a3f17210bc | ||
|
|
6a1d956bef | ||
|
|
8896bc83f7 | ||
|
|
e607148cca |
@@ -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,347 @@
|
||||
# elcaju
|
||||
# ElCaju 🥜
|
||||
|
||||
A new Flutter project.
|
||||
<p align="center">
|
||||
<img src="assets/img/elcajucubano.png" alt="ElCaju Logo" width="200"/>
|
||||
</p>
|
||||
|
||||
## Getting Started
|
||||
<p align="center">
|
||||
<strong>Your private ecash wallet on Bitcoin</strong>
|
||||
</p>
|
||||
|
||||
This project is a starting point for a Flutter application.
|
||||
<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>
|
||||
|
||||
A few resources to get you started if this is your first Flutter project:
|
||||
<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>
|
||||
|
||||
- [Lab: Write your first Flutter app](https://docs.flutter.dev/get-started/codelab)
|
||||
- [Cookbook: Useful Flutter samples](https://docs.flutter.dev/cookbook)
|
||||
---
|
||||
|
||||
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 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.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 = "org.cubabitcoin.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 = "org.cubabitcoin.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,6 +1,10 @@
|
||||
<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"/>
|
||||
|
||||
<application
|
||||
android:label="elcaju"
|
||||
android:label="ElCaju"
|
||||
android:name="${applicationName}"
|
||||
android:icon="@mipmap/ic_launcher">
|
||||
<activity
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package com.example.elcaju
|
||||
package me.elcaju
|
||||
|
||||
import io.flutter.embedding.android.FlutterActivity
|
||||
|
||||
|
After Width: | Height: | Size: 188 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 188 KiB |
|
After Width: | Height: | Size: 97 KiB |
|
After Width: | Height: | Size: 8.4 KiB |
|
After Width: | Height: | Size: 97 KiB |
|
After Width: | Height: | Size: 188 KiB |
|
After Width: | Height: | Size: 97 KiB |
|
After Width: | Height: | Size: 300 KiB |
|
After Width: | Height: | Size: 536 KiB |
|
After Width: | Height: | Size: 786 KiB |
|
After Width: | Height: | Size: 69 B |
@@ -1,12 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Modify this file to customize your launch splash screen -->
|
||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:drawable="?android:colorBackground" />
|
||||
|
||||
<!-- You can insert your own image assets here -->
|
||||
<!-- <item>
|
||||
<bitmap
|
||||
android:gravity="center"
|
||||
android:src="@mipmap/launch_image" />
|
||||
</item> -->
|
||||
<item>
|
||||
<bitmap android:gravity="fill" android:src="@drawable/background"/>
|
||||
</item>
|
||||
<item>
|
||||
<bitmap android:gravity="center" android:src="@drawable/splash"/>
|
||||
</item>
|
||||
</layer-list>
|
||||
|
||||
|
After Width: | Height: | Size: 300 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 300 KiB |
|
After Width: | Height: | Size: 536 KiB |
|
After Width: | Height: | Size: 47 KiB |
|
After Width: | Height: | Size: 536 KiB |
|
After Width: | Height: | Size: 786 KiB |
|
After Width: | Height: | Size: 76 KiB |
|
After Width: | Height: | Size: 786 KiB |
|
After Width: | Height: | Size: 69 B |
@@ -1,12 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Modify this file to customize your launch splash screen -->
|
||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:drawable="@android:color/white" />
|
||||
|
||||
<!-- You can insert your own image assets here -->
|
||||
<!-- <item>
|
||||
<bitmap
|
||||
android:gravity="center"
|
||||
android:src="@mipmap/launch_image" />
|
||||
</item> -->
|
||||
<item>
|
||||
<bitmap android:gravity="fill" android:src="@drawable/background"/>
|
||||
</item>
|
||||
<item>
|
||||
<bitmap android:gravity="center" android:src="@drawable/splash"/>
|
||||
</item>
|
||||
</layer-list>
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_background"/>
|
||||
<foreground>
|
||||
<inset
|
||||
android:drawable="@drawable/ic_launcher_foreground"
|
||||
android:inset="16%" />
|
||||
</foreground>
|
||||
</adaptive-icon>
|
||||
|
Before Width: | Height: | Size: 544 B After Width: | Height: | Size: 4.7 KiB |
|
Before Width: | Height: | Size: 442 B After Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 721 B After Width: | Height: | Size: 7.1 KiB |
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 20 KiB |
@@ -0,0 +1,21 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
|
||||
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
||||
<item name="android:forceDarkAllowed">false</item>
|
||||
<item name="android:windowFullscreen">false</item>
|
||||
<item name="android:windowDrawsSystemBarBackgrounds">false</item>
|
||||
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
|
||||
<item name="android:windowSplashScreenBackground">#1A0B2E</item>
|
||||
<item name="android:windowSplashScreenAnimatedIcon">@drawable/android12splash</item>
|
||||
</style>
|
||||
<!-- Theme applied to the Android Window as soon as the process has started.
|
||||
This theme determines the color of the Android Window while your
|
||||
Flutter UI initializes, as well as behind your Flutter UI while its
|
||||
running.
|
||||
|
||||
This Theme is only used starting with V2 of Flutter's Android embedding. -->
|
||||
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
||||
<item name="android:windowBackground">?android:colorBackground</item>
|
||||
</style>
|
||||
</resources>
|
||||
@@ -5,6 +5,10 @@
|
||||
<!-- Show a splash screen on the activity. Automatically removed when
|
||||
the Flutter engine draws its first frame -->
|
||||
<item name="android:windowBackground">@drawable/launch_background</item>
|
||||
<item name="android:forceDarkAllowed">false</item>
|
||||
<item name="android:windowFullscreen">false</item>
|
||||
<item name="android:windowDrawsSystemBarBackgrounds">false</item>
|
||||
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
|
||||
</style>
|
||||
<!-- Theme applied to the Android Window as soon as the process has started.
|
||||
This theme determines the color of the Android Window while your
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
|
||||
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
|
||||
<item name="android:forceDarkAllowed">false</item>
|
||||
<item name="android:windowFullscreen">false</item>
|
||||
<item name="android:windowDrawsSystemBarBackgrounds">false</item>
|
||||
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
|
||||
<item name="android:windowSplashScreenBackground">#1A0B2E</item>
|
||||
<item name="android:windowSplashScreenAnimatedIcon">@drawable/android12splash</item>
|
||||
</style>
|
||||
<!-- Theme applied to the Android Window as soon as the process has started.
|
||||
This theme determines the color of the Android Window while your
|
||||
Flutter UI initializes, as well as behind your Flutter UI while its
|
||||
running.
|
||||
|
||||
This Theme is only used starting with V2 of Flutter's Android embedding. -->
|
||||
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
|
||||
<item name="android:windowBackground">?android:colorBackground</item>
|
||||
</style>
|
||||
</resources>
|
||||
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="ic_launcher_background">#1A0B2E</color>
|
||||
</resources>
|
||||
@@ -5,6 +5,10 @@
|
||||
<!-- Show a splash screen on the activity. Automatically removed when
|
||||
the Flutter engine draws its first frame -->
|
||||
<item name="android:windowBackground">@drawable/launch_background</item>
|
||||
<item name="android:forceDarkAllowed">false</item>
|
||||
<item name="android:windowFullscreen">false</item>
|
||||
<item name="android:windowDrawsSystemBarBackgrounds">false</item>
|
||||
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
|
||||
</style>
|
||||
<!-- Theme applied to the Android Window as soon as the process has started.
|
||||
This theme determines the color of the Android Window while your
|
||||
|
||||
@@ -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")
|
||||
|
After Width: | Height: | Size: 197 KiB |
|
After Width: | Height: | Size: 441 KiB |
|
After Width: | Height: | Size: 208 KiB |
|
After Width: | Height: | Size: 257 KiB |
|
After Width: | Height: | Size: 283 KiB |
@@ -0,0 +1,148 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
/// Modelo de un proof Cashu local.
|
||||
/// Representa una "nota" de ecash con una denominación específica.
|
||||
class LocalProof {
|
||||
/// Primary key (Y point)
|
||||
final Uint8List y;
|
||||
|
||||
/// URL del mint que emitió este proof
|
||||
final String mintUrl;
|
||||
|
||||
/// Estado del proof
|
||||
final ProofState state;
|
||||
|
||||
/// Unidad (sat, usd, eur)
|
||||
final String unit;
|
||||
|
||||
/// Denominación (1, 2, 4, 8, 16, 32, 64, 128, 256, 512...)
|
||||
final BigInt amount;
|
||||
|
||||
/// ID del keyset
|
||||
final String keysetId;
|
||||
|
||||
/// Secret del proof
|
||||
final String secret;
|
||||
|
||||
/// Signature (C point)
|
||||
final Uint8List c;
|
||||
|
||||
/// Witness (opcional, para P2PK)
|
||||
final String? witness;
|
||||
|
||||
/// DLEQ proof components (opcional)
|
||||
final Uint8List? dleqE;
|
||||
final Uint8List? dleqS;
|
||||
final Uint8List? dleqR;
|
||||
|
||||
LocalProof({
|
||||
required this.y,
|
||||
required this.mintUrl,
|
||||
required this.state,
|
||||
required this.unit,
|
||||
required this.amount,
|
||||
required this.keysetId,
|
||||
required this.secret,
|
||||
required this.c,
|
||||
this.witness,
|
||||
this.dleqE,
|
||||
this.dleqS,
|
||||
this.dleqR,
|
||||
});
|
||||
|
||||
/// Crea un LocalProof desde un row de SQLite.
|
||||
factory LocalProof.fromSqlite(Map<String, dynamic> row) {
|
||||
return LocalProof(
|
||||
y: row['y'] as Uint8List,
|
||||
mintUrl: row['mint_url'] as String,
|
||||
state: ProofState.fromString(row['state'] as String),
|
||||
unit: row['unit'] as String,
|
||||
amount: BigInt.from(row['amount'] as int),
|
||||
keysetId: row['keyset_id'] as String,
|
||||
secret: row['secret'] as String,
|
||||
c: row['c'] as Uint8List,
|
||||
witness: row['witness'] as String?,
|
||||
dleqE: row['dleq_e'] as Uint8List?,
|
||||
dleqS: row['dleq_s'] as Uint8List?,
|
||||
dleqR: row['dleq_r'] as Uint8List?,
|
||||
);
|
||||
}
|
||||
|
||||
/// Convierte a formato JSON para token V3.
|
||||
Map<String, dynamic> toTokenProof() {
|
||||
final proof = <String, dynamic>{
|
||||
'id': keysetId,
|
||||
'amount': amount.toInt(),
|
||||
'secret': secret,
|
||||
'C': _bytesToHex(c),
|
||||
};
|
||||
|
||||
// Agregar DLEQ si existe
|
||||
if (dleqE != null && dleqS != null) {
|
||||
proof['dleq'] = {
|
||||
'e': _bytesToHex(dleqE!),
|
||||
's': _bytesToHex(dleqS!),
|
||||
if (dleqR != null) 'r': _bytesToHex(dleqR!),
|
||||
};
|
||||
}
|
||||
|
||||
// Agregar witness si existe
|
||||
if (witness != null && witness!.isNotEmpty) {
|
||||
proof['witness'] = witness;
|
||||
}
|
||||
|
||||
return proof;
|
||||
}
|
||||
|
||||
/// Convierte bytes a hex string.
|
||||
static String _bytesToHex(Uint8List bytes) {
|
||||
return bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join();
|
||||
}
|
||||
|
||||
/// Convierte Y a hex para identificación.
|
||||
String get yHex => _bytesToHex(y);
|
||||
|
||||
@override
|
||||
String toString() => 'LocalProof(amount: $amount, unit: $unit, state: $state)';
|
||||
}
|
||||
|
||||
/// Estados posibles de un proof.
|
||||
enum ProofState {
|
||||
unspent,
|
||||
spent,
|
||||
pending,
|
||||
reserved,
|
||||
pendingSpent;
|
||||
|
||||
static ProofState fromString(String s) {
|
||||
switch (s.toUpperCase()) {
|
||||
case 'UNSPENT':
|
||||
return ProofState.unspent;
|
||||
case 'SPENT':
|
||||
return ProofState.spent;
|
||||
case 'PENDING':
|
||||
return ProofState.pending;
|
||||
case 'RESERVED':
|
||||
return ProofState.reserved;
|
||||
case 'PENDING_SPENT':
|
||||
return ProofState.pendingSpent;
|
||||
default:
|
||||
return ProofState.unspent;
|
||||
}
|
||||
}
|
||||
|
||||
String toSqlite() {
|
||||
switch (this) {
|
||||
case ProofState.unspent:
|
||||
return 'UNSPENT';
|
||||
case ProofState.spent:
|
||||
return 'SPENT';
|
||||
case ProofState.pending:
|
||||
return 'PENDING';
|
||||
case ProofState.reserved:
|
||||
return 'RESERVED';
|
||||
case ProofState.pendingSpent:
|
||||
return 'PENDING_SPENT';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
|
||||
import '../models/proof.dart';
|
||||
|
||||
/// Servicio para acceder a los proofs locales en SQLite.
|
||||
/// Permite leer proofs, crear tokens offline y actualizar estados.
|
||||
class ProofService {
|
||||
Database? _db;
|
||||
static bool _ffiInitialized = false;
|
||||
|
||||
/// Inicializa sqflite FFI para plataformas desktop (Linux, Windows, macOS).
|
||||
static void _initFfiIfNeeded() {
|
||||
if (_ffiInitialized) return;
|
||||
|
||||
if (Platform.isLinux || Platform.isWindows || Platform.isMacOS) {
|
||||
sqfliteFfiInit();
|
||||
databaseFactory = databaseFactoryFfi;
|
||||
debugPrint('sqflite FFI initialized for desktop');
|
||||
}
|
||||
_ffiInitialized = true;
|
||||
}
|
||||
|
||||
/// Obtiene la instancia de la base de datos.
|
||||
Future<Database> get database async {
|
||||
if (_db != null) return _db!;
|
||||
|
||||
// Inicializar FFI para desktop
|
||||
_initFfiIfNeeded();
|
||||
|
||||
final dir = await getApplicationDocumentsDirectory();
|
||||
final dbPath = '${dir.path}/elcaju_wallet.sqlite';
|
||||
|
||||
_db = await openDatabase(dbPath);
|
||||
return _db!;
|
||||
}
|
||||
|
||||
/// Obtiene todos los proofs UNSPENT de un mint y unidad específicos.
|
||||
/// Ordenados de mayor a menor denominación.
|
||||
Future<List<LocalProof>> getUnspentProofs({
|
||||
required String mintUrl,
|
||||
required String unit,
|
||||
}) async {
|
||||
final db = await database;
|
||||
|
||||
final rows = await db.query(
|
||||
'proof',
|
||||
where: 'mint_url = ? AND unit = ? AND state = ?',
|
||||
whereArgs: [mintUrl, unit, 'UNSPENT'],
|
||||
orderBy: 'amount DESC',
|
||||
);
|
||||
|
||||
return rows.map((row) => LocalProof.fromSqlite(row)).toList();
|
||||
}
|
||||
|
||||
/// Agrupa proofs por denominación.
|
||||
/// Retorna Map ordenado de mayor a menor.
|
||||
Map<BigInt, List<LocalProof>> groupByDenomination(List<LocalProof> proofs) {
|
||||
final grouped = <BigInt, List<LocalProof>>{};
|
||||
|
||||
for (final proof in proofs) {
|
||||
grouped.putIfAbsent(proof.amount, () => []).add(proof);
|
||||
}
|
||||
|
||||
// Ordenar por denominación descendente
|
||||
final sorted = Map.fromEntries(
|
||||
grouped.entries.toList()..sort((a, b) => b.key.compareTo(a.key)),
|
||||
);
|
||||
|
||||
return sorted;
|
||||
}
|
||||
|
||||
/// Calcula el total de proofs seleccionados.
|
||||
BigInt calculateTotal(List<LocalProof> proofs) {
|
||||
return proofs.fold(BigInt.zero, (sum, p) => sum + p.amount);
|
||||
}
|
||||
|
||||
/// Selecciona proofs que sumen exactamente el monto.
|
||||
/// Retorna null si no hay combinación exacta.
|
||||
List<LocalProof>? selectExactProofs(
|
||||
List<LocalProof> available,
|
||||
BigInt targetAmount,
|
||||
) {
|
||||
final selected = <LocalProof>[];
|
||||
var remaining = targetAmount;
|
||||
|
||||
// Ordenar de mayor a menor
|
||||
final sorted = List<LocalProof>.from(available)
|
||||
..sort((a, b) => b.amount.compareTo(a.amount));
|
||||
|
||||
// Greedy selection
|
||||
for (final proof in sorted) {
|
||||
if (remaining >= proof.amount) {
|
||||
selected.add(proof);
|
||||
remaining -= proof.amount;
|
||||
}
|
||||
if (remaining == BigInt.zero) break;
|
||||
}
|
||||
|
||||
return remaining == BigInt.zero ? selected : null;
|
||||
}
|
||||
|
||||
/// Marca proofs como PENDING_SPENT en la base de datos.
|
||||
Future<void> markProofsPendingSpent(List<LocalProof> proofs) async {
|
||||
final db = await database;
|
||||
|
||||
await db.transaction((txn) async {
|
||||
for (final proof in proofs) {
|
||||
await txn.update(
|
||||
'proof',
|
||||
{'state': 'PENDING_SPENT'},
|
||||
where: 'y = ?',
|
||||
whereArgs: [proof.y],
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
debugPrint('Marked ${proofs.length} proofs as PENDING_SPENT');
|
||||
}
|
||||
|
||||
/// Marca proofs como UNSPENT (para revertir si falla el envío).
|
||||
Future<void> markProofsUnspent(List<LocalProof> proofs) async {
|
||||
final db = await database;
|
||||
|
||||
await db.transaction((txn) async {
|
||||
for (final proof in proofs) {
|
||||
await txn.update(
|
||||
'proof',
|
||||
{'state': 'UNSPENT'},
|
||||
where: 'y = ?',
|
||||
whereArgs: [proof.y],
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
debugPrint('Reverted ${proofs.length} proofs to UNSPENT');
|
||||
}
|
||||
|
||||
/// Crea un token Cashu V3 desde proofs seleccionados.
|
||||
/// Formato: cashuA + base64(JSON)
|
||||
String createTokenV3({
|
||||
required String mintUrl,
|
||||
required List<LocalProof> proofs,
|
||||
String? memo,
|
||||
String? unit,
|
||||
}) {
|
||||
// Estructura del token V3
|
||||
final tokenData = <String, dynamic>{
|
||||
'token': [
|
||||
{
|
||||
'mint': mintUrl,
|
||||
'proofs': proofs.map((p) => p.toTokenProof()).toList(),
|
||||
}
|
||||
],
|
||||
};
|
||||
|
||||
// Agregar memo si existe
|
||||
if (memo != null && memo.isNotEmpty) {
|
||||
tokenData['memo'] = memo;
|
||||
}
|
||||
|
||||
// Agregar unit si existe
|
||||
if (unit != null && unit.isNotEmpty) {
|
||||
tokenData['unit'] = unit;
|
||||
}
|
||||
|
||||
// Codificar a JSON y luego base64
|
||||
final jsonStr = jsonEncode(tokenData);
|
||||
final base64Str = base64.encode(utf8.encode(jsonStr));
|
||||
|
||||
return 'cashuA$base64Str';
|
||||
}
|
||||
|
||||
/// Cierra la conexión a la base de datos.
|
||||
Future<void> close() async {
|
||||
await _db?.close();
|
||||
_db = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
/// Utilidades para formatear montos y unidades en El Caju.
|
||||
///
|
||||
/// USD y EUR usan centavos (multiplicador 100).
|
||||
/// SAT y otras unidades usan valores enteros.
|
||||
|
||||
class UnitFormatter {
|
||||
/// Multiplicador para convertir a unidad base.
|
||||
/// USD/EUR almacenan centavos, display en unidades.
|
||||
static int getMultiplier(String unit) {
|
||||
switch (unit.toLowerCase()) {
|
||||
case 'usd':
|
||||
case 'eur':
|
||||
return 100; // centavos
|
||||
default:
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
/// Formatea un balance para display.
|
||||
/// Ejemplo: 1000 sat → "1,000", 500 usd → "5.00"
|
||||
static String formatBalance(BigInt amount, String unit) {
|
||||
final multiplier = getMultiplier(unit);
|
||||
|
||||
if (multiplier == 100) {
|
||||
// USD/EUR: mostrar con 2 decimales
|
||||
final value = amount.toDouble() / 100;
|
||||
return value.toStringAsFixed(2);
|
||||
} else {
|
||||
// SAT y otros: entero con separador de miles
|
||||
return NumberFormat('#,###').format(amount.toInt());
|
||||
}
|
||||
}
|
||||
|
||||
/// Formatea un balance con su unidad.
|
||||
/// 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 (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';
|
||||
case 'usd':
|
||||
return 'USD';
|
||||
case 'eur':
|
||||
return 'EUR';
|
||||
case '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();
|
||||
}
|
||||
}
|
||||
|
||||
/// Convierte input del usuario a BigInt para la unidad.
|
||||
/// Ejemplo: "5.00" USD → BigInt(500)
|
||||
static BigInt parseUserInput(String input, String unit) {
|
||||
final multiplier = getMultiplier(unit);
|
||||
|
||||
// Limpiar input
|
||||
final cleaned = input.replaceAll(',', '').replaceAll(' ', '');
|
||||
|
||||
if (multiplier == 100) {
|
||||
// USD/EUR: parsear como decimal
|
||||
final value = double.tryParse(cleaned) ?? 0.0;
|
||||
return BigInt.from((value * 100).round());
|
||||
} else {
|
||||
// SAT: parsear como entero
|
||||
return BigInt.from(int.tryParse(cleaned) ?? 0);
|
||||
}
|
||||
}
|
||||
|
||||
/// Obtiene el nombre del host de un mint URL.
|
||||
/// Ejemplo: 'https://mint.cubabitcoin.org' → 'cubabitcoin.org'
|
||||
static String getMintDisplayName(String mintUrl) {
|
||||
try {
|
||||
final uri = Uri.parse(mintUrl);
|
||||
var host = uri.host;
|
||||
|
||||
// Limpiar prefijos comunes
|
||||
host = host.replaceFirst('mint.', '');
|
||||
host = host.replaceFirst('www.', '');
|
||||
|
||||
return host;
|
||||
} catch (e) {
|
||||
return mintUrl;
|
||||
}
|
||||
}
|
||||
|
||||
/// Formatea una fecha relativa.
|
||||
/// Ejemplo: hace 5 min, hace 2 h, ayer, 15 ene
|
||||
static String formatRelativeDate(DateTime date) {
|
||||
final now = DateTime.now();
|
||||
final difference = now.difference(date);
|
||||
|
||||
if (difference.inSeconds < 60) {
|
||||
return 'Ahora';
|
||||
} else if (difference.inMinutes < 60) {
|
||||
return 'Hace ${difference.inMinutes} min';
|
||||
} else if (difference.inHours < 24) {
|
||||
return 'Hace ${difference.inHours} h';
|
||||
} else if (difference.inDays == 1) {
|
||||
return 'Ayer';
|
||||
} else if (difference.inDays < 7) {
|
||||
return 'Hace ${difference.inDays} días';
|
||||
} else {
|
||||
return DateFormat('d MMM').format(date);
|
||||
}
|
||||
}
|
||||
|
||||
/// Convierte timestamp BigInt (unix seconds) a DateTime.
|
||||
static DateTime timestampToDateTime(BigInt timestamp) {
|
||||
return DateTime.fromMillisecondsSinceEpoch(
|
||||
timestamp.toInt() * 1000,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -44,31 +44,287 @@
|
||||
"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": "E.g.: For coffee",
|
||||
"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": "E.g.: El Caju deposit",
|
||||
"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",
|
||||
"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",
|
||||
|
||||
"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": "Cashu wallet with Cuban identity, sister 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" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,49 +40,411 @@
|
||||
"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": "Ej: Para el café",
|
||||
"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": "Ej: Deposito El Caju",
|
||||
"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",
|
||||
"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",
|
||||
|
||||
"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": "Cashu wallet con identidad cubana, hermana 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" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,6 +46,8 @@ class ElCajuApp extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final settingsProvider = context.watch<SettingsProvider>();
|
||||
|
||||
return MaterialApp(
|
||||
title: 'ElCaju',
|
||||
debugShowCheckedModeBanner: false,
|
||||
@@ -62,7 +64,7 @@ class ElCajuApp extends StatelessWidget {
|
||||
Locale('es'), // Español (por defecto)
|
||||
Locale('en'), // English
|
||||
],
|
||||
locale: null, // null = detectar del sistema
|
||||
locale: Locale(settingsProvider.locale),
|
||||
|
||||
home: const SplashScreen(),
|
||||
);
|
||||
|
||||
@@ -16,6 +16,8 @@ class SettingsProvider extends ChangeNotifier {
|
||||
static const _defaultMintKey = 'default_mint';
|
||||
static const _localeKey = 'locale';
|
||||
static const _pinEnabledKey = 'pin_enabled';
|
||||
static const _activeUnitKey = 'active_unit';
|
||||
static const _activeMintUrlKey = 'active_mint_url';
|
||||
|
||||
// Estado interno
|
||||
bool _isInitialized = false;
|
||||
@@ -24,6 +26,8 @@ class SettingsProvider extends ChangeNotifier {
|
||||
String? _pin;
|
||||
String _locale = 'es';
|
||||
String _defaultMint = 'https://mint.cubabitcoin.org';
|
||||
String _activeUnit = 'sat';
|
||||
String? _activeMintUrl;
|
||||
|
||||
// Getters
|
||||
bool get isInitialized => _isInitialized;
|
||||
@@ -32,6 +36,8 @@ class SettingsProvider extends ChangeNotifier {
|
||||
bool get hasPin => _pin != null && _pin!.isNotEmpty;
|
||||
String get locale => _locale;
|
||||
String get defaultMint => _defaultMint;
|
||||
String get activeUnit => _activeUnit;
|
||||
String? get activeMintUrl => _activeMintUrl;
|
||||
|
||||
// ============================================================
|
||||
// INICIALIZACION
|
||||
@@ -49,6 +55,8 @@ class SettingsProvider extends ChangeNotifier {
|
||||
_locale = _prefs?.getString(_localeKey) ?? 'es';
|
||||
_defaultMint = _prefs?.getString(_defaultMintKey) ?? 'https://mint.cubabitcoin.org';
|
||||
_pinEnabled = _prefs?.getBool(_pinEnabledKey) ?? false;
|
||||
_activeUnit = _prefs?.getString(_activeUnitKey) ?? 'sat';
|
||||
_activeMintUrl = _prefs?.getString(_activeMintUrlKey);
|
||||
|
||||
// Cargar PIN si existe
|
||||
if (_pinEnabled) {
|
||||
@@ -142,6 +150,28 @@ class SettingsProvider extends ChangeNotifier {
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// UNIDAD Y MINT ACTIVO
|
||||
// ============================================================
|
||||
|
||||
/// Guarda la unidad activa (sat, usd, eur, etc.).
|
||||
Future<void> setActiveUnit(String unit) async {
|
||||
await _prefs?.setString(_activeUnitKey, unit);
|
||||
_activeUnit = unit;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Guarda el mint activo.
|
||||
Future<void> setActiveMintUrl(String? mintUrl) async {
|
||||
if (mintUrl != null) {
|
||||
await _prefs?.setString(_activeMintUrlKey, mintUrl);
|
||||
} else {
|
||||
await _prefs?.remove(_activeMintUrlKey);
|
||||
}
|
||||
_activeMintUrl = mintUrl;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// BORRAR WALLET
|
||||
// ============================================================
|
||||
@@ -156,12 +186,16 @@ class SettingsProvider extends ChangeNotifier {
|
||||
// Resetear preferencias
|
||||
await _prefs?.setBool(_hasWalletKey, false);
|
||||
await _prefs?.setBool(_pinEnabledKey, false);
|
||||
await _prefs?.remove(_activeUnitKey);
|
||||
await _prefs?.remove(_activeMintUrlKey);
|
||||
// Mantener locale y defaultMint (preferencias de app, no de wallet)
|
||||
|
||||
// Resetear estado
|
||||
_hasWallet = false;
|
||||
_pinEnabled = false;
|
||||
_pin = null;
|
||||
_activeUnit = 'sat';
|
||||
_activeMintUrl = null;
|
||||
|
||||
notifyListeners();
|
||||
}
|
||||
@@ -180,6 +214,8 @@ class SettingsProvider extends ChangeNotifier {
|
||||
_pin = null;
|
||||
_locale = 'es';
|
||||
_defaultMint = 'https://mint.cubabitcoin.org';
|
||||
_activeUnit = 'sat';
|
||||
_activeMintUrl = null;
|
||||
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
@@ -65,6 +66,12 @@ class _SplashScreenState extends State<SplashScreen>
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
// Restaurar mint y unidad activa desde settings
|
||||
await _restoreActiveState(settingsProvider, walletProvider);
|
||||
|
||||
// Verificar proofs en background (no bloquea navegación)
|
||||
unawaited(_checkPendingTransactions(walletProvider));
|
||||
|
||||
// Ir al Home
|
||||
_navigateTo(const HomeScreen());
|
||||
} else {
|
||||
@@ -91,6 +98,41 @@ class _SplashScreenState extends State<SplashScreen>
|
||||
}
|
||||
}
|
||||
|
||||
/// Restaura el mint y unidad activa desde SettingsProvider.
|
||||
Future<void> _restoreActiveState(
|
||||
SettingsProvider settings,
|
||||
WalletProvider wallet,
|
||||
) async {
|
||||
try {
|
||||
// Restaurar mint activo si está guardado
|
||||
final savedMintUrl = settings.activeMintUrl;
|
||||
if (savedMintUrl != null && wallet.mintUrls.contains(savedMintUrl)) {
|
||||
await wallet.setActiveMint(savedMintUrl);
|
||||
}
|
||||
|
||||
// Restaurar unidad activa si está guardada y es soportada
|
||||
final savedUnit = settings.activeUnit;
|
||||
if (wallet.activeUnits.contains(savedUnit)) {
|
||||
await wallet.setActiveUnit(savedUnit);
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Error restaurando estado activo: $e');
|
||||
// No bloquear si falla, usar defaults
|
||||
}
|
||||
}
|
||||
|
||||
/// Verifica proofs pendientes en background.
|
||||
/// Se ejecuta sin bloquear la navegación.
|
||||
Future<void> _checkPendingTransactions(WalletProvider wallet) async {
|
||||
try {
|
||||
await wallet.checkPendingTransactions();
|
||||
debugPrint('Verificación de proofs completada');
|
||||
} catch (e) {
|
||||
debugPrint('Error verificando proofs: $e');
|
||||
// Silencioso - no mostrar error al usuario
|
||||
}
|
||||
}
|
||||
|
||||
void _navigateTo(Widget screen) {
|
||||
Navigator.pushReplacement(
|
||||
context,
|
||||
|
||||
@@ -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';
|
||||
@@ -87,7 +88,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 +115,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 +237,7 @@ class _CreateWalletScreenState extends State<CreateWalletScreen> {
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.check_circle,
|
||||
LucideIcons.checkCircle,
|
||||
size: 60,
|
||||
color: AppColors.success,
|
||||
),
|
||||
@@ -261,7 +272,7 @@ class _CreateWalletScreenState extends State<CreateWalletScreen> {
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.warning_amber_rounded,
|
||||
LucideIcons.alertTriangle,
|
||||
color: AppColors.warning,
|
||||
size: 32,
|
||||
),
|
||||
@@ -285,7 +296,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';
|
||||
@@ -96,7 +97,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 +175,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 +221,7 @@ class _RestoreWalletScreenState extends State<RestoreWalletScreen> {
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.error_outline,
|
||||
LucideIcons.alertCircle,
|
||||
color: AppColors.error,
|
||||
size: 20,
|
||||
),
|
||||
@@ -244,7 +245,7 @@ class _RestoreWalletScreenState extends State<RestoreWalletScreen> {
|
||||
|
||||
PrimaryButton(
|
||||
text: l10n.restoreWallet,
|
||||
icon: Icons.restore,
|
||||
icon: LucideIcons.rotateCcw,
|
||||
isLoading: _isRestoring,
|
||||
onPressed: _isValidWordCount && !_isRestoring
|
||||
? _restoreWallet
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
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';
|
||||
@@ -12,10 +15,104 @@ import 'restore_wallet_screen.dart';
|
||||
class WelcomeScreen extends StatelessWidget {
|
||||
const WelcomeScreen({super.key});
|
||||
|
||||
static const _languages = [
|
||||
{'code': 'es', 'flag': '🇪🇸', 'name': 'Español'},
|
||||
{'code': 'en', 'flag': '🇬🇧', 'name': 'English'},
|
||||
];
|
||||
|
||||
void _showLanguageSelector(BuildContext context) {
|
||||
final settingsProvider = context.read<SettingsProvider>();
|
||||
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
backgroundColor: Colors.transparent,
|
||||
builder: (context) => Container(
|
||||
padding: const EdgeInsets.all(AppDimensions.paddingMedium),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.deepVoidPurple,
|
||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(24)),
|
||||
border: Border.all(
|
||||
color: Colors.white.withValues(alpha: 0.1),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Handle
|
||||
Container(
|
||||
margin: const EdgeInsets.only(bottom: 16),
|
||||
width: 40,
|
||||
height: 4,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: 0.3),
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
// Opciones de idioma
|
||||
..._languages.map((lang) {
|
||||
final isSelected = settingsProvider.locale == lang['code'];
|
||||
return GestureDetector(
|
||||
onTap: () {
|
||||
settingsProvider.setLocale(lang['code']!);
|
||||
Navigator.pop(context);
|
||||
},
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected
|
||||
? AppColors.primaryAction.withValues(alpha: 0.2)
|
||||
: Colors.white.withValues(alpha: 0.05),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: isSelected
|
||||
? AppColors.primaryAction.withValues(alpha: 0.5)
|
||||
: Colors.white.withValues(alpha: 0.1),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
lang['flag']!,
|
||||
style: const TextStyle(fontSize: 24),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Text(
|
||||
lang['name']!,
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 16,
|
||||
fontWeight: isSelected ? FontWeight.w600 : FontWeight.w500,
|
||||
color: isSelected ? AppColors.primaryAction : Colors.white,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
if (isSelected)
|
||||
const Icon(
|
||||
LucideIcons.checkCircle,
|
||||
color: AppColors.primaryAction,
|
||||
size: 22,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = L10n.of(context)!;
|
||||
|
||||
final settingsProvider = context.watch<SettingsProvider>();
|
||||
final isSpanish = settingsProvider.locale == 'es';
|
||||
|
||||
return GradientBackground(
|
||||
child: Scaffold(
|
||||
backgroundColor: Colors.transparent,
|
||||
@@ -24,6 +121,52 @@ 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: () => _showLanguageSelector(context),
|
||||
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(
|
||||
isSpanish ? '🇪🇸' : '🇬🇧',
|
||||
style: const TextStyle(fontSize: 18),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
isSpanish ? 'ES' : 'EN',
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Icon(
|
||||
LucideIcons.chevronDown,
|
||||
color: Colors.white.withValues(alpha: 0.7),
|
||||
size: 18,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const Spacer(flex: 1),
|
||||
|
||||
// Logo / Mascota
|
||||
@@ -66,7 +209,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 +224,7 @@ class WelcomeScreen extends StatelessWidget {
|
||||
// Botón restaurar wallet
|
||||
SecondaryButton(
|
||||
text: l10n.restoreWallet,
|
||||
icon: Icons.restore,
|
||||
icon: LucideIcons.rotateCcw,
|
||||
onPressed: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
|
||||
@@ -1,19 +1,23 @@
|
||||
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 '../../widgets/common/gradient_background.dart';
|
||||
import '../../widgets/common/glass_card.dart';
|
||||
import '../../widgets/common/animated_action_button.dart';
|
||||
import '../../widgets/effects/cashu_confetti.dart';
|
||||
import '../../providers/wallet_provider.dart';
|
||||
import '../../providers/settings_provider.dart';
|
||||
import '../4_receive/receive_screen.dart';
|
||||
import '../5_send/send_screen.dart';
|
||||
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';
|
||||
|
||||
/// Pantalla principal - Home
|
||||
/// Muestra balance, acciones principales e historial
|
||||
@@ -31,12 +35,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(
|
||||
@@ -131,6 +174,12 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
|
||||
Widget _buildBalanceSection() {
|
||||
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(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
@@ -140,63 +189,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 con StreamBuilder
|
||||
const SizedBox(height: 32),
|
||||
|
||||
// Balance tocable para ocultar/mostrar (sin ojito)
|
||||
StreamBuilder<BigInt>(
|
||||
stream: walletProvider.streamTotalBalance(),
|
||||
stream: walletProvider.streamBalance(),
|
||||
builder: (context, snapshot) {
|
||||
final balance = snapshot.data ?? BigInt.zero;
|
||||
final balanceInt = balance.toInt();
|
||||
final formattedBalance = UnitFormatter.formatBalance(balance, activeUnit);
|
||||
|
||||
return Text(
|
||||
_isBalanceVisible ? _formatBalance(balanceInt) : '••••••',
|
||||
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 (siempre sats por ahora)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.primaryAction.withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: const Text(
|
||||
'sats',
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.primaryAction,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -206,16 +251,11 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
final walletProvider = context.watch<WalletProvider>();
|
||||
final activeMintUrl = walletProvider.activeMintUrl;
|
||||
|
||||
// Extraer solo el host del URL para mostrar
|
||||
String displayMint = 'Sin mint';
|
||||
if (activeMintUrl != null) {
|
||||
try {
|
||||
final uri = Uri.parse(activeMintUrl);
|
||||
displayMint = uri.host;
|
||||
} catch (_) {
|
||||
displayMint = activeMintUrl;
|
||||
}
|
||||
}
|
||||
// Extraer nombre del mint para mostrar
|
||||
final l10n = L10n.of(context)!;
|
||||
final displayMint = activeMintUrl != null
|
||||
? UnitFormatter.getMintDisplayName(activeMintUrl)
|
||||
: l10n.noMint;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: AppDimensions.paddingSmall),
|
||||
@@ -226,66 +266,112 @@ 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
|
||||
// Recibir (segundo) - acción importante pero segura
|
||||
Expanded(
|
||||
child: _ActionButton(
|
||||
label: '↘ Recibir',
|
||||
child: AnimatedActionButton(
|
||||
label: l10n.receiveAction,
|
||||
type: ButtonType.primaryAction,
|
||||
onTap: _showReceiveOptions,
|
||||
),
|
||||
),
|
||||
@@ -295,16 +381,17 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
}
|
||||
|
||||
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(
|
||||
@@ -315,8 +402,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(
|
||||
@@ -331,16 +418,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(
|
||||
@@ -351,8 +439,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(
|
||||
@@ -367,386 +455,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(),
|
||||
);
|
||||
}
|
||||
|
||||
String _formatBalance(int amount) {
|
||||
if (amount >= 1000) {
|
||||
final formatted = amount.toString().replaceAllMapped(
|
||||
RegExp(r'(\d{1,3})(?=(\d{3})+(?!\d))'),
|
||||
(Match m) => '${m[1]},',
|
||||
);
|
||||
return formatted;
|
||||
}
|
||||
return amount.toString();
|
||||
}
|
||||
}
|
||||
|
||||
/// 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
|
||||
@@ -900,3 +664,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,8 +2,10 @@ 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 '../../widgets/common/gradient_background.dart';
|
||||
import '../../widgets/common/glass_card.dart';
|
||||
import '../../widgets/common/primary_button.dart';
|
||||
@@ -26,7 +28,8 @@ class _ReceiveScreenState extends State<ReceiveScreen> {
|
||||
bool _isValidToken = false;
|
||||
bool _isProcessing = false;
|
||||
bool _showSuccess = false;
|
||||
int _receivedAmount = 0;
|
||||
BigInt _receivedAmount = BigInt.zero;
|
||||
String? _receivedUnit; // Se asigna al reclamar desde walletProvider.activeUnit
|
||||
TokenInfo? _tokenInfo;
|
||||
String? _errorMessage;
|
||||
|
||||
@@ -51,9 +54,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,
|
||||
@@ -61,10 +64,7 @@ class _ReceiveScreenState extends State<ReceiveScreen> {
|
||||
),
|
||||
),
|
||||
body: SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(AppDimensions.paddingMedium),
|
||||
child: _showSuccess ? _buildSuccessView() : _buildReceiveForm(),
|
||||
),
|
||||
child: _showSuccess ? _buildSuccessView() : _buildReceiveForm(),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -72,93 +72,116 @@ 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),
|
||||
|
||||
// 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 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
|
||||
Text(
|
||||
'+$_receivedAmount sats',
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 36,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.white,
|
||||
// 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.paddingSmall),
|
||||
const SizedBox(height: AppDimensions.paddingLarge),
|
||||
|
||||
Text(
|
||||
'Tokens recibidos',
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 18,
|
||||
color: AppColors.textSecondary,
|
||||
// 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.paddingLarge * 2),
|
||||
const SizedBox(height: AppDimensions.paddingSmall),
|
||||
|
||||
// Botón volver
|
||||
PrimaryButton(
|
||||
text: 'Volver al inicio',
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
],
|
||||
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),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -214,7 +237,7 @@ class _ReceiveScreenState extends State<ReceiveScreen> {
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'Pegar del portapapeles',
|
||||
L10n.of(context)!.pasteFromClipboard,
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 16,
|
||||
@@ -229,14 +252,12 @@ class _ReceiveScreenState extends State<ReceiveScreen> {
|
||||
}
|
||||
|
||||
Widget _buildTokenPreview() {
|
||||
final amount = _tokenInfo!.amount.toInt();
|
||||
|
||||
// Extraer solo el host del mint URL
|
||||
String mintDisplay = _tokenInfo!.mintUrl;
|
||||
try {
|
||||
final uri = Uri.parse(_tokenInfo!.mintUrl);
|
||||
mintDisplay = uri.host;
|
||||
} catch (_) {}
|
||||
final walletProvider = context.read<WalletProvider>();
|
||||
final amount = _tokenInfo!.amount;
|
||||
// Usar unidad detectada del token, o fallback a unidad activa
|
||||
final tokenUnit = _tokenInfo!.unit ?? walletProvider.activeUnit;
|
||||
final mintDisplay = UnitFormatter.getMintDisplayName(_tokenInfo!.mintUrl);
|
||||
final unitDetected = _tokenInfo!.unit != null;
|
||||
|
||||
return GlassCard(
|
||||
padding: const EdgeInsets.all(AppDimensions.paddingMedium),
|
||||
@@ -259,7 +280,7 @@ class _ReceiveScreenState extends State<ReceiveScreen> {
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'Token válido',
|
||||
L10n.of(context)!.validToken,
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 14,
|
||||
@@ -271,26 +292,39 @@ class _ReceiveScreenState extends State<ReceiveScreen> {
|
||||
),
|
||||
const SizedBox(height: AppDimensions.paddingMedium),
|
||||
|
||||
// Monto
|
||||
// Monto con unidad detectada
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
'Monto:',
|
||||
L10n.of(context)!.amount,
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 14,
|
||||
color: AppColors.textSecondary.withValues(alpha: 0.7),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'$amount sats',
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.white,
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
'${UnitFormatter.formatBalance(amount, tokenUnit)} ${UnitFormatter.getUnitLabel(tokenUnit)}',
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
// Indicador si la unidad fue detectada automáticamente
|
||||
if (!unitDetected) ...[
|
||||
const SizedBox(width: 4),
|
||||
Icon(
|
||||
LucideIcons.helpCircle,
|
||||
color: AppColors.textSecondary.withValues(alpha: 0.5),
|
||||
size: 14,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -301,7 +335,7 @@ class _ReceiveScreenState extends State<ReceiveScreen> {
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
'Mint:',
|
||||
L10n.of(context)!.mint,
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 14,
|
||||
@@ -360,10 +394,12 @@ class _ReceiveScreenState extends State<ReceiveScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildClaimButton() {
|
||||
Widget _buildReceiveButton() {
|
||||
final l10n = L10n.of(context)!;
|
||||
// Botón único "Recibir" - auto-detecta conectividad
|
||||
return PrimaryButton(
|
||||
text: _isProcessing ? 'Reclamando...' : 'Reclamar tokens',
|
||||
onPressed: _isValidToken && !_isProcessing ? _claimToken : null,
|
||||
text: _isProcessing ? l10n.claiming : l10n.receive,
|
||||
onPressed: _isValidToken && !_isProcessing ? _receiveToken : null,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -396,28 +432,130 @@ class _ReceiveScreenState extends State<ReceiveScreen> {
|
||||
} else {
|
||||
_isValidToken = false;
|
||||
_tokenInfo = null;
|
||||
_errorMessage = 'Token inválido o malformado';
|
||||
_errorMessage = L10n.of(context)!.invalidToken;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _claimToken() async {
|
||||
/// 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;
|
||||
});
|
||||
|
||||
try {
|
||||
final walletProvider = context.read<WalletProvider>();
|
||||
final l10n = L10n.of(context)!;
|
||||
final walletProvider = context.read<WalletProvider>();
|
||||
|
||||
// Reclamar token real
|
||||
try {
|
||||
final mintUrl = _tokenInfo?.mintUrl;
|
||||
if (mintUrl == null) {
|
||||
throw Exception(l10n.invalidToken);
|
||||
}
|
||||
|
||||
// Verificar si el mint es conocido
|
||||
final isKnownMint = walletProvider.mintUrls.contains(mintUrl);
|
||||
|
||||
// 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>();
|
||||
|
||||
// Guardar unidad detectada del token ANTES de reclamar
|
||||
final detectedUnit = _tokenInfo?.unit ?? walletProvider.activeUnit;
|
||||
|
||||
try {
|
||||
// Reclamar token (usa unidad detectada internamente)
|
||||
final amountReceived = await walletProvider.receiveToken(
|
||||
_tokenController.text.trim(),
|
||||
);
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_receivedAmount = amountReceived.toInt();
|
||||
_receivedAmount = amountReceived;
|
||||
_receivedUnit = detectedUnit;
|
||||
_showSuccess = true;
|
||||
_isProcessing = false;
|
||||
});
|
||||
@@ -426,23 +564,22 @@ 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;
|
||||
final l10n = L10n.of(context)!;
|
||||
// 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 {
|
||||
errorMessage = l10n.claimError(e.toString());
|
||||
}
|
||||
setState(() {
|
||||
_errorMessage = errorMessage;
|
||||
_isProcessing = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,354 @@
|
||||
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';
|
||||
import '../../core/services/proof_service.dart';
|
||||
import '../../widgets/common/gradient_background.dart';
|
||||
import '../../widgets/common/glass_card.dart';
|
||||
import '../../widgets/common/primary_button.dart';
|
||||
import '../../widgets/proof/proof_selector.dart';
|
||||
import 'share_token_screen.dart';
|
||||
|
||||
/// Pantalla para enviar tokens de forma offline seleccionando proofs manualmente.
|
||||
class OfflineSendScreen extends StatefulWidget {
|
||||
final String mintUrl;
|
||||
final String unit;
|
||||
|
||||
const OfflineSendScreen({
|
||||
super.key,
|
||||
required this.mintUrl,
|
||||
required this.unit,
|
||||
});
|
||||
|
||||
@override
|
||||
State<OfflineSendScreen> createState() => _OfflineSendScreenState();
|
||||
}
|
||||
|
||||
class _OfflineSendScreenState extends State<OfflineSendScreen> {
|
||||
final TextEditingController _memoController = TextEditingController();
|
||||
final ProofService _proofService = ProofService();
|
||||
|
||||
List<LocalProof> _availableProofs = [];
|
||||
Set<String> _selectedIds = {};
|
||||
bool _isLoading = true;
|
||||
bool _isCreating = false;
|
||||
String? _errorMessage;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadProofs();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_memoController.dispose();
|
||||
_proofService.close();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _loadProofs() async {
|
||||
try {
|
||||
final proofs = await _proofService.getUnspentProofs(
|
||||
mintUrl: widget.mintUrl,
|
||||
unit: widget.unit,
|
||||
);
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_availableProofs = proofs;
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_errorMessage = L10n.of(context)!.loadingProofsError(e.toString());
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Obtiene los proofs seleccionados.
|
||||
List<LocalProof> get _selectedProofs {
|
||||
return _availableProofs
|
||||
.where((p) => _selectedIds.contains(p.yHex))
|
||||
.toList();
|
||||
}
|
||||
|
||||
/// Calcula el total seleccionado.
|
||||
BigInt get _selectedTotal {
|
||||
return _proofService.calculateTotal(_selectedProofs);
|
||||
}
|
||||
|
||||
/// Toggle selección de un proof.
|
||||
void _toggleProof(LocalProof proof) {
|
||||
setState(() {
|
||||
if (_selectedIds.contains(proof.yHex)) {
|
||||
_selectedIds.remove(proof.yHex);
|
||||
} else {
|
||||
_selectedIds.add(proof.yHex);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Seleccionar todos.
|
||||
void _selectAll() {
|
||||
setState(() {
|
||||
_selectedIds = _availableProofs.map((p) => p.yHex).toSet();
|
||||
});
|
||||
}
|
||||
|
||||
/// Deseleccionar todos.
|
||||
void _clearSelection() {
|
||||
setState(() {
|
||||
_selectedIds.clear();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GradientBackground(
|
||||
child: Scaffold(
|
||||
backgroundColor: Colors.transparent,
|
||||
appBar: AppBar(
|
||||
backgroundColor: Colors.transparent,
|
||||
elevation: 0,
|
||||
leading: IconButton(
|
||||
icon: const Icon(LucideIcons.arrowLeft, color: Colors.white),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
title: Text(
|
||||
L10n.of(context)!.offlineSend,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
// Botón seleccionar/deseleccionar todos
|
||||
if (_availableProofs.isNotEmpty)
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
_selectedIds.length == _availableProofs.length
|
||||
? LucideIcons.checkSquare
|
||||
: LucideIcons.square,
|
||||
color: AppColors.primaryAction,
|
||||
),
|
||||
onPressed: _selectedIds.length == _availableProofs.length
|
||||
? _clearSelection
|
||||
: _selectAll,
|
||||
),
|
||||
],
|
||||
),
|
||||
body: SafeArea(
|
||||
child: _isLoading
|
||||
? _buildLoading()
|
||||
: _errorMessage != null
|
||||
? _buildError()
|
||||
: _buildContent(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildLoading() {
|
||||
return const Center(
|
||||
child: CircularProgressIndicator(
|
||||
color: AppColors.primaryAction,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildError() {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
LucideIcons.alertCircle,
|
||||
size: 48,
|
||||
color: AppColors.error,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
_errorMessage!,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 16,
|
||||
color: Colors.white,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildContent() {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(AppDimensions.paddingMedium),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// Total seleccionado
|
||||
_buildTotalDisplay(),
|
||||
|
||||
const SizedBox(height: AppDimensions.paddingMedium),
|
||||
|
||||
// Instrucciones
|
||||
Text(
|
||||
L10n.of(context)!.selectNotesToSend,
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 14,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: AppDimensions.paddingMedium),
|
||||
|
||||
// Selector de proofs
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
child: GlassCard(
|
||||
padding: const EdgeInsets.all(AppDimensions.paddingMedium),
|
||||
child: ProofSelector(
|
||||
proofs: _availableProofs,
|
||||
selectedIds: _selectedIds,
|
||||
unit: widget.unit,
|
||||
onToggle: _toggleProof,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: AppDimensions.paddingMedium),
|
||||
|
||||
// Memo opcional
|
||||
_buildMemoSection(),
|
||||
|
||||
const SizedBox(height: AppDimensions.paddingMedium),
|
||||
|
||||
// Botón crear token
|
||||
PrimaryButton(
|
||||
text: _isCreating ? L10n.of(context)!.creatingToken : L10n.of(context)!.createToken,
|
||||
onPressed: _selectedIds.isNotEmpty && !_isCreating
|
||||
? _createOfflineToken
|
||||
: null,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTotalDisplay() {
|
||||
final l10n = L10n.of(context)!;
|
||||
return GlassCard(
|
||||
padding: const EdgeInsets.all(AppDimensions.paddingMedium),
|
||||
child: Column(
|
||||
children: [
|
||||
Text(
|
||||
l10n.totalToSend,
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 14,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
ProofTotalDisplay(
|
||||
total: _selectedTotal,
|
||||
unit: widget.unit,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
l10n.notesSelected(_selectedIds.length),
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 12,
|
||||
color: AppColors.textSecondary.withValues(alpha: 0.7),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMemoSection() {
|
||||
return GlassCard(
|
||||
padding: const EdgeInsets.all(AppDimensions.paddingSmall),
|
||||
child: TextField(
|
||||
controller: _memoController,
|
||||
maxLines: 1,
|
||||
maxLength: 100,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 16,
|
||||
color: Colors.white,
|
||||
),
|
||||
decoration: InputDecoration(
|
||||
hintText: L10n.of(context)!.memoOptional,
|
||||
hintStyle: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 16,
|
||||
color: Colors.white.withValues(alpha: 0.3),
|
||||
),
|
||||
border: InputBorder.none,
|
||||
counterStyle: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 12,
|
||||
color: AppColors.textSecondary.withValues(alpha: 0.5),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _createOfflineToken() async {
|
||||
setState(() {
|
||||
_isCreating = true;
|
||||
});
|
||||
|
||||
try {
|
||||
final selectedProofs = _selectedProofs;
|
||||
|
||||
// Marcar proofs como PENDING_SPENT
|
||||
await _proofService.markProofsPendingSpent(selectedProofs);
|
||||
|
||||
// Crear token V3
|
||||
final memo = _memoController.text.isNotEmpty ? _memoController.text : null;
|
||||
final token = _proofService.createTokenV3(
|
||||
mintUrl: widget.mintUrl,
|
||||
proofs: selectedProofs,
|
||||
memo: memo,
|
||||
unit: widget.unit,
|
||||
);
|
||||
|
||||
if (mounted) {
|
||||
Navigator.pushReplacement(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => ShareTokenScreen(
|
||||
token: token,
|
||||
amount: _selectedTotal,
|
||||
unit: widget.unit,
|
||||
memo: memo,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
final errorMessage = L10n.of(context)!.creatingTokenError(e.toString());
|
||||
setState(() {
|
||||
_errorMessage = errorMessage;
|
||||
_isCreating = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,17 @@
|
||||
import 'package:flutter/material.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 '../../providers/wallet_provider.dart';
|
||||
import 'share_token_screen.dart';
|
||||
import 'offline_send_screen.dart';
|
||||
|
||||
/// Pantalla para enviar tokens Cashu
|
||||
class SendScreen extends StatefulWidget {
|
||||
@@ -21,24 +25,24 @@ class _SendScreenState extends State<SendScreen> {
|
||||
final TextEditingController _amountController = TextEditingController();
|
||||
final TextEditingController _memoController = TextEditingController();
|
||||
|
||||
final String _unit = 'sats';
|
||||
|
||||
bool _isProcessing = false;
|
||||
String? _errorMessage;
|
||||
int _availableBalance = 0;
|
||||
BigInt _availableBalance = BigInt.zero;
|
||||
late String _activeUnit;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_activeUnit = context.read<WalletProvider>().activeUnit;
|
||||
_loadBalance();
|
||||
}
|
||||
|
||||
Future<void> _loadBalance() async {
|
||||
final walletProvider = context.read<WalletProvider>();
|
||||
final balance = await walletProvider.getTotalBalance();
|
||||
final balance = await walletProvider.getBalance();
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_availableBalance = balance.toInt();
|
||||
_availableBalance = balance;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -50,8 +54,13 @@ class _SendScreenState extends State<SendScreen> {
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
int get _amount => int.tryParse(_amountController.text) ?? 0;
|
||||
bool get _isValidAmount => _amount > 0 && _amount <= _availableBalance;
|
||||
/// 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);
|
||||
|
||||
bool get _isValidAmount => _amount > BigInt.zero && _amount <= _availableBalance;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -65,40 +74,56 @@ 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,
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
// Botón para modo offline (selección manual de proofs)
|
||||
IconButton(
|
||||
icon: const Icon(LucideIcons.coins, color: AppColors.primaryAction),
|
||||
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(),
|
||||
// Mensaje de error (si hay)
|
||||
if (_errorMessage != null) _buildErrorMessage(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const Spacer(),
|
||||
|
||||
// Botón crear token
|
||||
_buildCreateButton(),
|
||||
],
|
||||
),
|
||||
// Botón crear token (fijo abajo)
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(AppDimensions.paddingMedium),
|
||||
child: _buildCreateButton(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -106,11 +131,12 @@ class _SendScreenState extends State<SendScreen> {
|
||||
}
|
||||
|
||||
Widget _buildAmountSection() {
|
||||
final l10n = L10n.of(context)!;
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Monto a enviar:',
|
||||
l10n.amountToSend,
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 16,
|
||||
@@ -154,7 +180,7 @@ class _SendScreenState extends State<SendScreen> {
|
||||
),
|
||||
),
|
||||
Text(
|
||||
_unit,
|
||||
_unitLabel,
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 18,
|
||||
@@ -173,7 +199,7 @@ class _SendScreenState extends State<SendScreen> {
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
'Disponible:',
|
||||
l10n.available,
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 14,
|
||||
@@ -185,7 +211,7 @@ class _SendScreenState extends State<SendScreen> {
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
'$_availableBalance $_unit',
|
||||
'${UnitFormatter.formatBalance(_availableBalance, _activeUnit)} $_unitLabel',
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 14,
|
||||
@@ -195,7 +221,7 @@ class _SendScreenState extends State<SendScreen> {
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'(Max)',
|
||||
l10n.max,
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 12,
|
||||
@@ -212,11 +238,12 @@ class _SendScreenState extends State<SendScreen> {
|
||||
}
|
||||
|
||||
Widget _buildMemoSection() {
|
||||
final l10n = L10n.of(context)!;
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Memo (opcional):',
|
||||
l10n.memoOptional,
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 16,
|
||||
@@ -238,7 +265,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,
|
||||
@@ -293,26 +320,81 @@ class _SendScreenState extends State<SendScreen> {
|
||||
}
|
||||
|
||||
Widget _buildCreateButton() {
|
||||
final l10n = L10n.of(context)!;
|
||||
return PrimaryButton(
|
||||
text: _isProcessing ? 'Creando token...' : 'Crear token',
|
||||
text: _isProcessing ? l10n.creatingToken : l10n.createToken,
|
||||
onPressed: _isValidAmount && !_isProcessing ? _showConfirmation : null,
|
||||
);
|
||||
}
|
||||
|
||||
void _setMaxAmount() {
|
||||
_amountController.text = _availableBalance.toString();
|
||||
// Formatear el balance para el input (sin separadores de miles)
|
||||
_amountController.text = UnitFormatter.formatBalance(_availableBalance, _activeUnit).replaceAll(',', '');
|
||||
setState(() {
|
||||
_errorMessage = null;
|
||||
});
|
||||
}
|
||||
|
||||
void _showConfirmation() {
|
||||
/// Navegar al modo offline para seleccionar proofs manualmente.
|
||||
void _goToOfflineMode() {
|
||||
final walletProvider = context.read<WalletProvider>();
|
||||
final mintUrl = walletProvider.activeMintUrl;
|
||||
|
||||
if (mintUrl == null) {
|
||||
setState(() {
|
||||
_errorMessage = L10n.of(context)!.noActiveMint;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => OfflineSendScreen(
|
||||
mintUrl: mintUrl,
|
||||
unit: _activeUnit,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showConfirmation() async {
|
||||
// Verificar conectividad antes de mostrar confirmación
|
||||
final walletProvider = context.read<WalletProvider>();
|
||||
final mintUrl = walletProvider.activeMintUrl;
|
||||
|
||||
if (mintUrl == null) {
|
||||
setState(() {
|
||||
_errorMessage = L10n.of(context)!.noActiveMint;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_isProcessing = true;
|
||||
});
|
||||
|
||||
final isOnline = await _checkConnectivity(mintUrl);
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
setState(() {
|
||||
_isProcessing = false;
|
||||
});
|
||||
|
||||
if (!isOnline) {
|
||||
// Offline: ir directo a selección de monedas
|
||||
_goToOfflineModeWithMessage();
|
||||
return;
|
||||
}
|
||||
|
||||
// Online: mostrar modal de confirmación normal
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
backgroundColor: Colors.transparent,
|
||||
builder: (context) => _ConfirmationModal(
|
||||
amount: _amount,
|
||||
unit: _unit,
|
||||
unit: _activeUnit,
|
||||
memo: _memoController.text.isNotEmpty ? _memoController.text : null,
|
||||
onConfirm: () {
|
||||
Navigator.pop(context);
|
||||
@@ -323,6 +405,18 @@ class _SendScreenState extends State<SendScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
/// Verifica conectividad haciendo petición HTTP real al mint.
|
||||
Future<bool> _checkConnectivity(String mintUrl) async {
|
||||
try {
|
||||
final response = await http.get(
|
||||
Uri.parse('$mintUrl/v1/info'),
|
||||
).timeout(const Duration(seconds: 3));
|
||||
return response.statusCode == 200;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _createToken() async {
|
||||
setState(() {
|
||||
_isProcessing = true;
|
||||
@@ -334,10 +428,8 @@ class _SendScreenState extends State<SendScreen> {
|
||||
|
||||
// Crear token real con cdk-flutter
|
||||
final memo = _memoController.text.isNotEmpty ? _memoController.text : null;
|
||||
final token = await walletProvider.sendTokens(
|
||||
BigInt.from(_amount),
|
||||
memo,
|
||||
);
|
||||
final amount = _amount;
|
||||
final token = await walletProvider.sendTokens(amount, memo);
|
||||
|
||||
if (mounted) {
|
||||
Navigator.pushReplacement(
|
||||
@@ -345,20 +437,33 @@ class _SendScreenState extends State<SendScreen> {
|
||||
MaterialPageRoute(
|
||||
builder: (context) => ShareTokenScreen(
|
||||
token: token,
|
||||
amount: _amount,
|
||||
unit: _unit,
|
||||
amount: amount,
|
||||
unit: _activeUnit,
|
||||
memo: memo,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
final errorStr = e.toString().toLowerCase();
|
||||
|
||||
// Detectar errores de red y redirigir a modo offline
|
||||
if (_isNetworkError(errorStr)) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isProcessing = false;
|
||||
});
|
||||
_goToOfflineModeWithMessage();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
final l10n = L10n.of(context)!;
|
||||
setState(() {
|
||||
final errorStr = e.toString().toLowerCase();
|
||||
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 {
|
||||
@@ -369,11 +474,56 @@ class _SendScreenState extends State<SendScreen> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Detecta si el error es de conexión/red.
|
||||
bool _isNetworkError(String errorStr) {
|
||||
return errorStr.contains('transport error') ||
|
||||
errorStr.contains('network') ||
|
||||
errorStr.contains('connection') ||
|
||||
errorStr.contains('socket') ||
|
||||
errorStr.contains('timeout') ||
|
||||
errorStr.contains('unreachable') ||
|
||||
errorStr.contains('no route') ||
|
||||
errorStr.contains('error sending request');
|
||||
}
|
||||
|
||||
/// Navegar al modo offline mostrando mensaje informativo.
|
||||
void _goToOfflineModeWithMessage() {
|
||||
final walletProvider = context.read<WalletProvider>();
|
||||
final mintUrl = walletProvider.activeMintUrl;
|
||||
|
||||
if (mintUrl == null) {
|
||||
setState(() {
|
||||
_errorMessage = L10n.of(context)!.noActiveMint;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Mostrar snackbar informativo
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(L10n.of(context)!.offlineModeMessage),
|
||||
backgroundColor: AppColors.primaryAction,
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
|
||||
// Navegar a modo offline
|
||||
Navigator.pushReplacement(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => OfflineSendScreen(
|
||||
mintUrl: mintUrl,
|
||||
unit: _activeUnit,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Modal de confirmacion
|
||||
class _ConfirmationModal extends StatelessWidget {
|
||||
final int amount;
|
||||
final BigInt amount;
|
||||
final String unit;
|
||||
final String? memo;
|
||||
final VoidCallback onConfirm;
|
||||
@@ -430,9 +580,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,
|
||||
@@ -443,7 +593,7 @@ class _ConfirmationModal extends StatelessWidget {
|
||||
|
||||
// Monto
|
||||
Text(
|
||||
'$amount $unit',
|
||||
'${UnitFormatter.formatBalance(amount, unit)} ${UnitFormatter.getUnitLabel(unit)}',
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 32,
|
||||
@@ -480,10 +630,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,
|
||||
@@ -497,7 +647,7 @@ class _ConfirmationModal extends StatelessWidget {
|
||||
const SizedBox(width: AppDimensions.paddingMedium),
|
||||
Expanded(
|
||||
child: PrimaryButton(
|
||||
text: 'Confirmar',
|
||||
text: L10n.of(context)!.confirm,
|
||||
onPressed: onConfirm,
|
||||
height: 52,
|
||||
),
|
||||
|
||||
@@ -4,8 +4,11 @@ import 'package:flutter/services.dart';
|
||||
import 'package:qr_flutter/qr_flutter.dart';
|
||||
import 'package:lucide_icons/lucide_icons.dart';
|
||||
import 'package:share_plus/share_plus.dart';
|
||||
import 'package:cdk_flutter/cdk_flutter.dart' as cdk;
|
||||
import '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';
|
||||
@@ -13,7 +16,7 @@ import '../../widgets/common/primary_button.dart';
|
||||
/// Pantalla para compartir el token Cashu generado
|
||||
class ShareTokenScreen extends StatefulWidget {
|
||||
final String token;
|
||||
final int amount;
|
||||
final BigInt amount;
|
||||
final String unit;
|
||||
final String? memo;
|
||||
|
||||
@@ -30,61 +33,74 @@ class ShareTokenScreen extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _ShareTokenScreenState extends State<ShareTokenScreen> {
|
||||
late PageController _pageController;
|
||||
late List<String> _qrParts;
|
||||
int _currentPage = 0;
|
||||
Timer? _timer;
|
||||
List<String> _urFragments = [];
|
||||
int _currentFragment = 0;
|
||||
Timer? _animationTimer;
|
||||
|
||||
// Configuración de velocidad (como cashu.me)
|
||||
static const int _intervalFast = 100; // ms
|
||||
static const int _intervalMedium = 200; // ms
|
||||
static const int _intervalSlow = 400; // ms
|
||||
int _currentInterval = _intervalMedium;
|
||||
String _speedLabel = 'M';
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_pageController = PageController();
|
||||
_qrParts = _encodeTokenToQR();
|
||||
_startQRAnimation();
|
||||
_encodeTokenToUR();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_timer?.cancel();
|
||||
_pageController.dispose();
|
||||
_animationTimer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
List<String> _encodeTokenToQR() {
|
||||
// TODO: Implementar con cdk-flutter cuando esté disponible
|
||||
// final qrParts = encodeQrToken(token: widget.token);
|
||||
void _encodeTokenToUR() {
|
||||
// Parsear el token string a objeto Token de cdk-flutter
|
||||
final token = cdk.Token.parse(encoded: widget.token);
|
||||
|
||||
// Por ahora, simulamos fragmentos UR para el token
|
||||
if (widget.token.length <= 200) {
|
||||
return [widget.token];
|
||||
}
|
||||
// Codificar a fragmentos UR usando cdk-flutter
|
||||
// maxFragmentLength por defecto es 150 bytes
|
||||
_urFragments = cdk.encodeQrToken(token: token);
|
||||
|
||||
// Simular fragmentación para tokens largos
|
||||
final parts = <String>[];
|
||||
const chunkSize = 200;
|
||||
for (int i = 0; i < widget.token.length; i += chunkSize) {
|
||||
final end = (i + chunkSize < widget.token.length)
|
||||
? i + chunkSize
|
||||
: widget.token.length;
|
||||
parts.add(widget.token.substring(i, end));
|
||||
// Si hay múltiples fragmentos, iniciar animación
|
||||
if (_urFragments.length > 1) {
|
||||
_startAnimation();
|
||||
}
|
||||
return parts;
|
||||
}
|
||||
|
||||
void _startQRAnimation() {
|
||||
// Solo animar si hay múltiples fragmentos
|
||||
if (_qrParts.length <= 1) return;
|
||||
void _startAnimation() {
|
||||
_animationTimer?.cancel();
|
||||
_animationTimer = Timer.periodic(
|
||||
Duration(milliseconds: _currentInterval),
|
||||
(timer) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_currentFragment = (_currentFragment + 1) % _urFragments.length;
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
_timer = Timer.periodic(const Duration(seconds: 2), (timer) {
|
||||
if (mounted) {
|
||||
final nextPage = (_currentPage + 1) % _qrParts.length;
|
||||
_pageController.animateToPage(
|
||||
nextPage,
|
||||
duration: const Duration(milliseconds: 300),
|
||||
curve: Curves.easeInOut,
|
||||
);
|
||||
void _cycleSpeed() {
|
||||
setState(() {
|
||||
if (_currentInterval == _intervalMedium) {
|
||||
_currentInterval = _intervalSlow;
|
||||
_speedLabel = 'S';
|
||||
} else if (_currentInterval == _intervalSlow) {
|
||||
_currentInterval = _intervalFast;
|
||||
_speedLabel = 'F';
|
||||
} else {
|
||||
_currentInterval = _intervalMedium;
|
||||
_speedLabel = 'M';
|
||||
}
|
||||
});
|
||||
// Reiniciar timer con nueva velocidad
|
||||
if (_urFragments.length > 1) {
|
||||
_startAnimation();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -99,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,
|
||||
@@ -109,54 +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.paddingSmall),
|
||||
const SizedBox(height: AppDimensions.paddingMedium),
|
||||
|
||||
// Indicador de página (si hay múltiples fragmentos)
|
||||
if (_qrParts.length > 1) _buildPageIndicator(),
|
||||
// Botones copiar y compartir
|
||||
_buildActionButtons(context),
|
||||
|
||||
const SizedBox(height: AppDimensions.paddingMedium),
|
||||
const SizedBox(height: AppDimensions.paddingLarge),
|
||||
|
||||
// Botones copiar y compartir
|
||||
_buildActionButtons(context),
|
||||
// Advertencia
|
||||
_buildWarning(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: AppDimensions.paddingLarge),
|
||||
|
||||
// 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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -176,10 +195,13 @@ class _ShareTokenScreenState extends State<ShareTokenScreen> {
|
||||
}
|
||||
|
||||
Widget _buildAmountDisplay() {
|
||||
final formattedAmount = UnitFormatter.formatBalance(widget.amount, widget.unit);
|
||||
final unitLabel = UnitFormatter.getUnitLabel(widget.unit);
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
Text(
|
||||
'${widget.amount} ${widget.unit}',
|
||||
'$formattedAmount $unitLabel',
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 36,
|
||||
@@ -204,66 +226,122 @@ class _ShareTokenScreenState extends State<ShareTokenScreen> {
|
||||
}
|
||||
|
||||
Widget _buildQRDisplay() {
|
||||
if (_urFragments.isEmpty) {
|
||||
return const SizedBox(
|
||||
width: 220,
|
||||
height: 220,
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
);
|
||||
}
|
||||
|
||||
final size = 220.0;
|
||||
return Container(
|
||||
width: size,
|
||||
height: size,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.2),
|
||||
blurRadius: 20,
|
||||
offset: const Offset(0, 8),
|
||||
final isAnimated = _urFragments.length > 1;
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
// QR Container
|
||||
Container(
|
||||
width: size,
|
||||
height: size,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.2),
|
||||
blurRadius: 20,
|
||||
offset: const Offset(0, 8),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
child: _qrParts.length > 1
|
||||
? PageView.builder(
|
||||
controller: _pageController,
|
||||
onPageChanged: (index) {
|
||||
setState(() {
|
||||
_currentPage = index;
|
||||
});
|
||||
},
|
||||
itemCount: _qrParts.length,
|
||||
itemBuilder: (context, index) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: QrImageView(
|
||||
data: _qrParts[index],
|
||||
version: QrVersions.auto,
|
||||
size: size - 40,
|
||||
backgroundColor: Colors.white,
|
||||
errorCorrectionLevel: QrErrorCorrectLevel.M,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
)
|
||||
: Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: QrImageView(
|
||||
data: _qrParts[0],
|
||||
version: QrVersions.auto,
|
||||
size: size - 40,
|
||||
backgroundColor: Colors.white,
|
||||
errorCorrectionLevel: QrErrorCorrectLevel.M,
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: QrImageView(
|
||||
data: _urFragments[_currentFragment],
|
||||
version: QrVersions.auto,
|
||||
size: size - 32,
|
||||
backgroundColor: Colors.white,
|
||||
errorCorrectionLevel: QrErrorCorrectLevel.L,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Controles de animación (solo si hay múltiples fragmentos)
|
||||
if (isAnimated) ...[
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
// Indicador de fragmento actual
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Text(
|
||||
'${_currentFragment + 1} / ${_urFragments.length}',
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
// Botón de velocidad
|
||||
GestureDetector(
|
||||
onTap: _cycleSpeed,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.primaryAction.withValues(alpha: 0.2),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(
|
||||
color: AppColors.primaryAction.withValues(alpha: 0.5),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
LucideIcons.gauge,
|
||||
color: AppColors.primaryAction,
|
||||
size: 16,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
_speedLabel,
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.primaryAction,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
final isAnimated = _urFragments.length > 1;
|
||||
|
||||
return GlassCard(
|
||||
padding: const EdgeInsets.all(AppDimensions.paddingSmall),
|
||||
child: Column(
|
||||
@@ -274,7 +352,9 @@ class _ShareTokenScreenState extends State<ShareTokenScreen> {
|
||||
Icon(LucideIcons.bean, color: AppColors.primaryAction, size: 16),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
'Token Cashu (${_qrParts.length} ${_qrParts.length == 1 ? "parte" : "partes"})',
|
||||
isAnimated
|
||||
? l10n.tokenCashuAnimatedQr(_urFragments.length)
|
||||
: l10n.tokenCashu,
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 12,
|
||||
@@ -298,27 +378,6 @@ class _ShareTokenScreenState extends State<ShareTokenScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPageIndicator() {
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: List.generate(
|
||||
_qrParts.length,
|
||||
(index) => AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
margin: const EdgeInsets.symmetric(horizontal: 4),
|
||||
width: index == _currentPage ? 24 : 8,
|
||||
height: 8,
|
||||
decoration: BoxDecoration(
|
||||
color: index == _currentPage
|
||||
? AppColors.primaryAction
|
||||
: AppColors.textSecondary.withValues(alpha: 0.3),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildActionButtons(BuildContext context) {
|
||||
return Row(
|
||||
children: [
|
||||
@@ -341,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,
|
||||
@@ -373,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,
|
||||
@@ -408,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,
|
||||
@@ -425,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)),
|
||||
@@ -438,10 +497,13 @@ class _ShareTokenScreenState extends State<ShareTokenScreen> {
|
||||
? '\n"${widget.memo}"'
|
||||
: '';
|
||||
|
||||
final formattedAmount = UnitFormatter.formatBalance(widget.amount, widget.unit);
|
||||
final unitLabel = UnitFormatter.getUnitLabel(widget.unit);
|
||||
|
||||
await SharePlus.instance.share(
|
||||
ShareParams(
|
||||
text: '${widget.amount} ${widget.unit}$memo\n\n${widget.token}',
|
||||
subject: 'Token Cashu - ${widget.amount} ${widget.unit}',
|
||||
text: '$formattedAmount $unitLabel$memo\n\n${widget.token}',
|
||||
subject: 'Token Cashu - $formattedAmount $unitLabel',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,8 +5,10 @@ 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';
|
||||
import '../../widgets/common/gradient_background.dart';
|
||||
import '../../widgets/common/glass_card.dart';
|
||||
import '../../widgets/effects/cashu_confetti.dart';
|
||||
@@ -17,7 +19,7 @@ enum MintStatus { loading, unpaid, paid, issued, error }
|
||||
|
||||
/// Pantalla para mostrar el invoice generado y esperar el pago
|
||||
class InvoiceScreen extends StatefulWidget {
|
||||
final int amount;
|
||||
final BigInt amount;
|
||||
final String unit;
|
||||
final String? description;
|
||||
|
||||
@@ -57,7 +59,7 @@ class _InvoiceScreenState extends State<InvoiceScreen> {
|
||||
|
||||
try {
|
||||
final mintStream = walletProvider.mintTokens(
|
||||
BigInt.from(widget.amount),
|
||||
widget.amount,
|
||||
widget.description,
|
||||
);
|
||||
|
||||
@@ -84,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;
|
||||
}
|
||||
});
|
||||
},
|
||||
@@ -111,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(
|
||||
'+${widget.amount} ${widget.unit} depositados',
|
||||
l10n.deposited(formattedAmount, unitLabel),
|
||||
),
|
||||
backgroundColor: AppColors.success,
|
||||
duration: const Duration(seconds: 3),
|
||||
@@ -147,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,
|
||||
@@ -159,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,
|
||||
@@ -196,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,
|
||||
@@ -219,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(),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -272,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,
|
||||
@@ -285,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,
|
||||
@@ -303,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,
|
||||
@@ -320,10 +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 ${widget.amount} ${widget.unit}',
|
||||
l10n.depositAmountTitle(formattedAmount, unitLabel),
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 24,
|
||||
@@ -388,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,
|
||||
@@ -403,6 +424,7 @@ class _InvoiceScreenState extends State<InvoiceScreen> {
|
||||
}
|
||||
|
||||
Widget _buildStatus() {
|
||||
final l10n = L10n.of(context)!;
|
||||
IconData icon;
|
||||
String text;
|
||||
Color color;
|
||||
@@ -410,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;
|
||||
}
|
||||
@@ -473,7 +495,7 @@ class _InvoiceScreenState extends State<InvoiceScreen> {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Descripcion:',
|
||||
L10n.of(context)!.description,
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 12,
|
||||
@@ -499,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,8 +1,10 @@
|
||||
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';
|
||||
@@ -21,11 +23,17 @@ class _MintScreenState extends State<MintScreen> {
|
||||
final TextEditingController _amountController = TextEditingController();
|
||||
final TextEditingController _descriptionController = TextEditingController();
|
||||
|
||||
final String _unit = 'sats';
|
||||
late String _activeUnit;
|
||||
|
||||
bool _isProcessing = false;
|
||||
String? _errorMessage;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_activeUnit = context.read<WalletProvider>().activeUnit;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_amountController.dispose();
|
||||
@@ -33,8 +41,13 @@ class _MintScreenState extends State<MintScreen> {
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
int get _amount => int.tryParse(_amountController.text) ?? 0;
|
||||
bool get _isValidAmount => _amount > 0;
|
||||
/// 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);
|
||||
|
||||
bool get _isValidAmount => _amount > BigInt.zero;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -48,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,
|
||||
@@ -58,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(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -89,11 +110,12 @@ class _MintScreenState extends State<MintScreen> {
|
||||
}
|
||||
|
||||
Widget _buildAmountSection() {
|
||||
final l10n = L10n.of(context)!;
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Monto a depositar:',
|
||||
l10n.amountToDeposit,
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 16,
|
||||
@@ -137,7 +159,7 @@ class _MintScreenState extends State<MintScreen> {
|
||||
),
|
||||
),
|
||||
Text(
|
||||
_unit,
|
||||
_unitLabel,
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 18,
|
||||
@@ -153,11 +175,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,
|
||||
@@ -179,7 +202,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,
|
||||
@@ -230,8 +253,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,
|
||||
);
|
||||
}
|
||||
@@ -242,7 +266,7 @@ class _MintScreenState extends State<MintScreen> {
|
||||
backgroundColor: Colors.transparent,
|
||||
builder: (context) => _ConfirmationModal(
|
||||
amount: _amount,
|
||||
unit: _unit,
|
||||
unit: _activeUnit,
|
||||
description: _descriptionController.text.isNotEmpty
|
||||
? _descriptionController.text
|
||||
: null,
|
||||
@@ -262,7 +286,7 @@ class _MintScreenState extends State<MintScreen> {
|
||||
MaterialPageRoute(
|
||||
builder: (context) => InvoiceScreen(
|
||||
amount: _amount,
|
||||
unit: _unit,
|
||||
unit: _activeUnit,
|
||||
description: _descriptionController.text.isNotEmpty
|
||||
? _descriptionController.text
|
||||
: null,
|
||||
@@ -274,7 +298,7 @@ class _MintScreenState extends State<MintScreen> {
|
||||
|
||||
/// Modal de confirmación
|
||||
class _ConfirmationModal extends StatelessWidget {
|
||||
final int amount;
|
||||
final BigInt amount;
|
||||
final String unit;
|
||||
final String? description;
|
||||
final VoidCallback onConfirm;
|
||||
@@ -331,9 +355,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,
|
||||
@@ -344,7 +368,7 @@ class _ConfirmationModal extends StatelessWidget {
|
||||
|
||||
// Monto
|
||||
Text(
|
||||
'$amount $unit',
|
||||
'${UnitFormatter.formatBalance(amount, unit)} ${UnitFormatter.getUnitLabel(unit)}',
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 32,
|
||||
@@ -381,10 +405,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,
|
||||
@@ -398,7 +422,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,
|
||||
),
|
||||
|
||||
@@ -3,8 +3,10 @@ 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 '../../widgets/common/gradient_background.dart';
|
||||
import '../../widgets/common/glass_card.dart';
|
||||
import '../../widgets/common/primary_button.dart';
|
||||
@@ -21,30 +23,34 @@ class MeltScreen extends StatefulWidget {
|
||||
class _MeltScreenState extends State<MeltScreen> {
|
||||
final TextEditingController _invoiceController = TextEditingController();
|
||||
|
||||
final String _unit = 'sats';
|
||||
late String _activeUnit;
|
||||
|
||||
bool _isValidInvoice = false;
|
||||
bool _isLoadingQuote = false;
|
||||
bool _isProcessing = false;
|
||||
MeltQuote? _quote;
|
||||
int _invoiceAmount = 0;
|
||||
int _feeReserve = 0;
|
||||
int _total = 0;
|
||||
int _availableBalance = 0;
|
||||
BigInt _invoiceAmount = BigInt.zero;
|
||||
BigInt _feeReserve = BigInt.zero;
|
||||
BigInt _total = BigInt.zero;
|
||||
BigInt _availableBalance = BigInt.zero;
|
||||
String? _errorMessage;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_activeUnit = context.read<WalletProvider>().activeUnit;
|
||||
_loadBalance();
|
||||
}
|
||||
|
||||
/// Obtiene la etiqueta de la unidad para display
|
||||
String get _unitLabel => UnitFormatter.getUnitLabel(_activeUnit);
|
||||
|
||||
Future<void> _loadBalance() async {
|
||||
final walletProvider = context.read<WalletProvider>();
|
||||
final balance = await walletProvider.getTotalBalance();
|
||||
final balance = await walletProvider.getBalance();
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_availableBalance = balance.toInt();
|
||||
_availableBalance = balance;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -67,9 +73,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,
|
||||
@@ -77,54 +83,67 @@ 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),
|
||||
|
||||
// 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 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(),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -183,7 +202,7 @@ class _MeltScreenState extends State<MeltScreen> {
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'Pegar del portapapeles',
|
||||
L10n.of(context)!.pasteFromClipboard,
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 16,
|
||||
@@ -213,7 +232,7 @@ class _MeltScreenState extends State<MeltScreen> {
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Text(
|
||||
'Obteniendo quote...',
|
||||
L10n.of(context)!.gettingQuote,
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 14,
|
||||
@@ -249,7 +268,7 @@ class _MeltScreenState extends State<MeltScreen> {
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'Invoice válido',
|
||||
L10n.of(context)!.validInvoice,
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 14,
|
||||
@@ -266,7 +285,7 @@ class _MeltScreenState extends State<MeltScreen> {
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
'Monto:',
|
||||
L10n.of(context)!.amount,
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 14,
|
||||
@@ -274,7 +293,7 @@ class _MeltScreenState extends State<MeltScreen> {
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'$_invoiceAmount $_unit',
|
||||
'${UnitFormatter.formatBalance(_invoiceAmount, _activeUnit)} $_unitLabel',
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 18,
|
||||
@@ -291,7 +310,7 @@ class _MeltScreenState extends State<MeltScreen> {
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
'Fee reservado:',
|
||||
L10n.of(context)!.feeReserved,
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 14,
|
||||
@@ -299,7 +318,7 @@ class _MeltScreenState extends State<MeltScreen> {
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'~$_feeReserve $_unit',
|
||||
'~${UnitFormatter.formatBalance(_feeReserve, _activeUnit)} $_unitLabel',
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 14,
|
||||
@@ -318,9 +337,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,
|
||||
@@ -328,7 +347,7 @@ class _MeltScreenState extends State<MeltScreen> {
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'$_total $_unit',
|
||||
'${UnitFormatter.formatBalance(_total, _activeUnit)} $_unitLabel',
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 20,
|
||||
@@ -344,7 +363,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,
|
||||
@@ -392,7 +411,7 @@ class _MeltScreenState extends State<MeltScreen> {
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
'Disponible: ',
|
||||
'${L10n.of(context)!.available} ',
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 14,
|
||||
@@ -400,7 +419,7 @@ class _MeltScreenState extends State<MeltScreen> {
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'$_availableBalance $_unit',
|
||||
'${UnitFormatter.formatBalance(_availableBalance, _activeUnit)} $_unitLabel',
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 14,
|
||||
@@ -413,6 +432,7 @@ class _MeltScreenState extends State<MeltScreen> {
|
||||
}
|
||||
|
||||
Widget _buildPayButton() {
|
||||
final l10n = L10n.of(context)!;
|
||||
final canPay = _isValidInvoice &&
|
||||
_quote != null &&
|
||||
!_isProcessing &&
|
||||
@@ -420,7 +440,7 @@ class _MeltScreenState extends State<MeltScreen> {
|
||||
_total <= _availableBalance;
|
||||
|
||||
return PrimaryButton(
|
||||
text: _isProcessing ? 'Pagando...' : 'Pagar invoice',
|
||||
text: _isProcessing ? l10n.paying : l10n.payInvoice,
|
||||
onPressed: canPay ? _showConfirmation : null,
|
||||
);
|
||||
}
|
||||
@@ -440,9 +460,9 @@ class _MeltScreenState extends State<MeltScreen> {
|
||||
_isValidInvoice = false;
|
||||
|
||||
if (value.isEmpty) {
|
||||
_invoiceAmount = 0;
|
||||
_feeReserve = 0;
|
||||
_total = 0;
|
||||
_invoiceAmount = BigInt.zero;
|
||||
_feeReserve = BigInt.zero;
|
||||
_total = BigInt.zero;
|
||||
return;
|
||||
}
|
||||
});
|
||||
@@ -453,7 +473,7 @@ class _MeltScreenState extends State<MeltScreen> {
|
||||
!trimmed.startsWith('lntb') &&
|
||||
!trimmed.startsWith('lnbcrt')) {
|
||||
setState(() {
|
||||
_errorMessage = 'Invoice inválido';
|
||||
_errorMessage = L10n.of(context)!.invalidInvoice;
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -475,8 +495,8 @@ class _MeltScreenState extends State<MeltScreen> {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_quote = quote;
|
||||
_invoiceAmount = quote.amount.toInt();
|
||||
_feeReserve = quote.feeReserve.toInt();
|
||||
_invoiceAmount = quote.amount;
|
||||
_feeReserve = quote.feeReserve;
|
||||
_total = _invoiceAmount + _feeReserve;
|
||||
_isValidInvoice = true;
|
||||
_isLoadingQuote = false;
|
||||
@@ -484,15 +504,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());
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -507,7 +528,7 @@ class _MeltScreenState extends State<MeltScreen> {
|
||||
amount: _invoiceAmount,
|
||||
fee: _feeReserve,
|
||||
total: _total,
|
||||
unit: _unit,
|
||||
unit: _activeUnit,
|
||||
onConfirm: () {
|
||||
Navigator.pop(context);
|
||||
_payInvoice();
|
||||
@@ -532,10 +553,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('-${totalPaid.toInt()} $_unit enviados'),
|
||||
content: Text(l10n.sent('-${UnitFormatter.formatBalance(totalPaid, _activeUnit)}', _unitLabel)),
|
||||
backgroundColor: AppColors.success,
|
||||
duration: const Duration(seconds: 3),
|
||||
),
|
||||
@@ -543,17 +565,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) {
|
||||
@@ -567,9 +593,9 @@ class _MeltScreenState extends State<MeltScreen> {
|
||||
|
||||
/// Modal de confirmación
|
||||
class _ConfirmationModal extends StatelessWidget {
|
||||
final int amount;
|
||||
final int fee;
|
||||
final int total;
|
||||
final BigInt amount;
|
||||
final BigInt fee;
|
||||
final BigInt total;
|
||||
final String unit;
|
||||
final VoidCallback onConfirm;
|
||||
final VoidCallback onCancel;
|
||||
@@ -626,9 +652,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,
|
||||
@@ -639,7 +665,7 @@ class _ConfirmationModal extends StatelessWidget {
|
||||
|
||||
// Detalles
|
||||
Text(
|
||||
'$amount $unit',
|
||||
'${UnitFormatter.formatBalance(amount, unit)} ${UnitFormatter.getUnitLabel(unit)}',
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 28,
|
||||
@@ -648,7 +674,7 @@ class _ConfirmationModal extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'+ ~$fee $unit fee',
|
||||
'+ ~${UnitFormatter.formatBalance(fee, unit)} ${UnitFormatter.getUnitLabel(unit)} fee',
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 14,
|
||||
@@ -657,7 +683,7 @@ class _ConfirmationModal extends StatelessWidget {
|
||||
),
|
||||
const SizedBox(height: AppDimensions.paddingSmall),
|
||||
Text(
|
||||
'Total: $total $unit',
|
||||
'Total: ${UnitFormatter.formatBalance(total, unit)} ${UnitFormatter.getUnitLabel(unit)}',
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 16,
|
||||
@@ -680,10 +706,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,
|
||||
@@ -697,7 +723,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,561 @@
|
||||
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: 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,13 +1,16 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:lucide_icons/lucide_icons.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:cdk_flutter/cdk_flutter.dart' hide WalletProvider;
|
||||
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';
|
||||
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,
|
||||
@@ -42,51 +45,53 @@ class _MintsScreenState extends State<MintsScreen> {
|
||||
body: SafeArea(
|
||||
child: Consumer<WalletProvider>(
|
||||
builder: (context, walletProvider, child) {
|
||||
return FutureBuilder<List<Mint>>(
|
||||
future: walletProvider.listMints(),
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.connectionState == ConnectionState.waiting && !snapshot.hasData) {
|
||||
return const Center(
|
||||
child: CircularProgressIndicator(
|
||||
color: AppColors.primaryAction,
|
||||
),
|
||||
);
|
||||
}
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(AppDimensions.paddingMedium),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// Lista de mints ordenados por balance
|
||||
Expanded(
|
||||
child: FutureBuilder<List<String>>(
|
||||
future: walletProvider.getSortedMintUrls(),
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.connectionState == ConnectionState.waiting) {
|
||||
return const Center(
|
||||
child: CircularProgressIndicator(
|
||||
color: AppColors.primaryAction,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final mints = snapshot.data ?? [];
|
||||
final mintUrls = snapshot.data ?? walletProvider.mintUrls;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(AppDimensions.paddingMedium),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// Lista de mints
|
||||
Expanded(
|
||||
child: mints.isEmpty
|
||||
? _buildEmptyState()
|
||||
: ListView.builder(
|
||||
itemCount: mints.length,
|
||||
itemBuilder: (context, index) {
|
||||
final mint = mints[index];
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
bottom: AppDimensions.paddingMedium,
|
||||
),
|
||||
child: _buildMintCard(
|
||||
mint,
|
||||
walletProvider,
|
||||
),
|
||||
);
|
||||
},
|
||||
if (mintUrls.isEmpty) {
|
||||
return _buildEmptyState();
|
||||
}
|
||||
|
||||
return ListView.builder(
|
||||
itemCount: mintUrls.length,
|
||||
itemBuilder: (context, index) {
|
||||
final mintUrl = mintUrls[index];
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
bottom: AppDimensions.paddingMedium,
|
||||
),
|
||||
),
|
||||
|
||||
// Botón agregar mint
|
||||
_buildAddMintButton(walletProvider),
|
||||
],
|
||||
child: _buildMintCard(
|
||||
mintUrl,
|
||||
walletProvider,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
|
||||
// Botón agregar mint
|
||||
_buildAddMintButton(walletProvider),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
@@ -96,6 +101,7 @@ class _MintsScreenState extends State<MintsScreen> {
|
||||
}
|
||||
|
||||
Widget _buildEmptyState() {
|
||||
final l10n = L10n.of(context)!;
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
@@ -107,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,
|
||||
@@ -117,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,
|
||||
@@ -129,240 +135,274 @@ class _MintsScreenState extends State<MintsScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMintCard(Mint mint, WalletProvider walletProvider) {
|
||||
final isActive = walletProvider.activeMintUrl == mint.url;
|
||||
Widget _buildMintCard(String mintUrl, WalletProvider walletProvider) {
|
||||
final isActive = walletProvider.activeMintUrl == mintUrl;
|
||||
final units = walletProvider.getUnitsForMint(mintUrl);
|
||||
final isCubaBitcoin = mintUrl == WalletProvider.cubaBitcoinMint;
|
||||
|
||||
return FutureBuilder<BigInt>(
|
||||
future: walletProvider.getBalanceForMint(mint.url),
|
||||
return FutureBuilder<Map<String, BigInt>>(
|
||||
future: walletProvider.getBalancesForMint(mintUrl),
|
||||
builder: (context, balanceSnapshot) {
|
||||
final balance = balanceSnapshot.data ?? BigInt.zero;
|
||||
final balances = balanceSnapshot.data ?? {};
|
||||
|
||||
return GlassCard(
|
||||
padding: const EdgeInsets.all(AppDimensions.paddingMedium),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Header: URL + estado
|
||||
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 (solo el host)
|
||||
Expanded(
|
||||
child: Text(
|
||||
_extractHost(mint.url),
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.white,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
|
||||
// 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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 4),
|
||||
|
||||
// URL completa (más pequeña)
|
||||
Text(
|
||||
mint.url,
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 12,
|
||||
color: AppColors.textSecondary.withValues(alpha: 0.6),
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
|
||||
const SizedBox(height: AppDimensions.paddingSmall),
|
||||
|
||||
// Balance
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
'Balance:',
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 14,
|
||||
color: AppColors.textSecondary.withValues(alpha: 0.7),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
if (balanceSnapshot.connectionState == ConnectionState.waiting)
|
||||
SizedBox(
|
||||
width: 14,
|
||||
height: 14,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
)
|
||||
else
|
||||
Text(
|
||||
'${_formatBalance(balance)} sats',
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: AppDimensions.paddingMedium),
|
||||
|
||||
// Acciones
|
||||
Row(
|
||||
children: [
|
||||
// Hacer activo (si no lo es)
|
||||
if (!isActive)
|
||||
Expanded(
|
||||
child: GestureDetector(
|
||||
onTap: () => _setActiveMint(mint.url, 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,
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
if (!isActive) const SizedBox(width: 8),
|
||||
const SizedBox(height: 2),
|
||||
|
||||
// Eliminar
|
||||
Expanded(
|
||||
child: GestureDetector(
|
||||
onTap: () => _showDeleteDialog(mint, balance, 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,
|
||||
// URL
|
||||
Text(
|
||||
mintUrl,
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 12,
|
||||
color: AppColors.textSecondary.withValues(alpha: 0.6),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
'Eliminar',
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: AppColors.error,
|
||||
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),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
// Chevron
|
||||
Icon(
|
||||
LucideIcons.chevronRight,
|
||||
color: Colors.white.withValues(alpha: 0.3),
|
||||
size: 20,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// 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: 12,
|
||||
color: Colors.white.withValues(alpha: 0.6),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
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(),
|
||||
);
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
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: 'Agregar mint',
|
||||
text: l10n.addMint,
|
||||
icon: LucideIcons.plus,
|
||||
onPressed: () => _showAddMintDialog(walletProvider),
|
||||
);
|
||||
}
|
||||
|
||||
String _extractHost(String url) {
|
||||
try {
|
||||
final uri = Uri.parse(url);
|
||||
return uri.host;
|
||||
} catch (e) {
|
||||
return url;
|
||||
}
|
||||
}
|
||||
|
||||
String _formatBalance(BigInt balance) {
|
||||
final value = balance.toInt();
|
||||
if (value >= 1000) {
|
||||
return '${(value / 1000).toStringAsFixed(value % 1000 == 0 ? 0 : 1)}k';
|
||||
}
|
||||
return value.toString();
|
||||
}
|
||||
|
||||
Future<void> _setActiveMint(String mintUrl, WalletProvider walletProvider) async {
|
||||
final l10n = L10n.of(context)!;
|
||||
try {
|
||||
await walletProvider.setActiveMint(mintUrl);
|
||||
|
||||
if (mounted) {
|
||||
setState(() {}); // Rebuild para actualizar UI
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Mint activo actualizado'),
|
||||
SnackBar(
|
||||
content: Text(l10n.activeMintUpdated),
|
||||
backgroundColor: AppColors.success,
|
||||
duration: Duration(seconds: 2),
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -370,53 +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),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _showDeleteDialog(Mint mint, BigInt balance, WalletProvider walletProvider) {
|
||||
final hasBalance = balance > BigInt.zero;
|
||||
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
backgroundColor: Colors.transparent,
|
||||
builder: (context) => _DeleteMintModal(
|
||||
mintUrl: mint.url,
|
||||
hasBalance: hasBalance,
|
||||
balance: balance.toInt(),
|
||||
onConfirm: () async {
|
||||
Navigator.pop(context);
|
||||
await _deleteMint(mint.url, 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),
|
||||
),
|
||||
@@ -439,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 int balance;
|
||||
final VoidCallback onConfirm;
|
||||
final VoidCallback onCancel;
|
||||
|
||||
const _DeleteMintModal({
|
||||
required this.mintUrl,
|
||||
required this.hasBalance,
|
||||
required this.balance,
|
||||
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 $balance sats. 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;
|
||||
@@ -652,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,
|
||||
@@ -668,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,
|
||||
@@ -687,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,
|
||||
@@ -723,7 +533,7 @@ class _AddMintDialogState extends State<_AddMintDialog> {
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Text(
|
||||
'Conectando al mint...',
|
||||
l10n.connectingToMint,
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 14,
|
||||
@@ -739,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,
|
||||
@@ -759,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,
|
||||
@@ -773,6 +583,7 @@ class _AddMintDialogState extends State<_AddMintDialog> {
|
||||
}
|
||||
|
||||
void _validateUrl(String value) {
|
||||
final l10n = L10n.of(context)!;
|
||||
setState(() {
|
||||
_errorMessage = null;
|
||||
});
|
||||
@@ -791,7 +602,7 @@ class _AddMintDialogState extends State<_AddMintDialog> {
|
||||
if (!isValidBasic) {
|
||||
setState(() {
|
||||
_isValid = false;
|
||||
_errorMessage = 'La URL debe comenzar con https://';
|
||||
_errorMessage = l10n.urlMustStartWithHttps;
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -802,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;
|
||||
|
||||
@@ -819,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),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -830,7 +642,7 @@ class _AddMintDialogState extends State<_AddMintDialog> {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isAdding = false;
|
||||
_errorMessage = 'No se pudo conectar al mint';
|
||||
_errorMessage = l10n.couldNotConnectToMint;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,8 +2,10 @@ 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';
|
||||
import '../../providers/settings_provider.dart';
|
||||
import '../../providers/wallet_provider.dart';
|
||||
import '../../widgets/common/gradient_background.dart';
|
||||
@@ -22,6 +24,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,
|
||||
@@ -32,9 +35,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,
|
||||
@@ -50,18 +53,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,
|
||||
@@ -73,10 +76,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) =>
|
||||
@@ -86,19 +89,19 @@ 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),
|
||||
),
|
||||
|
||||
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),
|
||||
),
|
||||
@@ -106,16 +109,16 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
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(
|
||||
@@ -266,6 +269,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(
|
||||
@@ -283,9 +287,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,
|
||||
@@ -303,13 +307,14 @@ 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;
|
||||
default:
|
||||
return 'Español';
|
||||
return l10n.spanish;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -327,8 +332,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,
|
||||
),
|
||||
);
|
||||
@@ -360,8 +365,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,
|
||||
),
|
||||
);
|
||||
@@ -374,8 +379,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,
|
||||
),
|
||||
);
|
||||
@@ -385,6 +390,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
}
|
||||
|
||||
Future<String?> _showCreatePinDialog(BuildContext context) async {
|
||||
final l10n = L10n.of(context)!;
|
||||
String? firstPin;
|
||||
|
||||
// Primer ingreso
|
||||
@@ -392,8 +398,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,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -404,8 +410,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,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -414,8 +420,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,
|
||||
),
|
||||
);
|
||||
@@ -428,12 +434,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,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -444,8 +451,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,
|
||||
),
|
||||
);
|
||||
@@ -457,6 +464,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
/// 3. Selector de idioma
|
||||
void _showLanguageSelector(
|
||||
BuildContext context, SettingsProvider settingsProvider) {
|
||||
final l10n = L10n.of(context)!;
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
backgroundColor: Colors.transparent,
|
||||
@@ -483,9 +491,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
const Text(
|
||||
'Seleccionar idioma',
|
||||
style: TextStyle(
|
||||
Text(
|
||||
l10n.selectLanguage,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
@@ -497,15 +505,15 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
context,
|
||||
settingsProvider,
|
||||
'es',
|
||||
'Español',
|
||||
l10n.spanish,
|
||||
'🇪🇸',
|
||||
),
|
||||
_buildLanguageOption(
|
||||
context,
|
||||
settingsProvider,
|
||||
'en',
|
||||
'English',
|
||||
'🇺🇸',
|
||||
l10n.english,
|
||||
'🇬🇧',
|
||||
),
|
||||
const SizedBox(height: AppDimensions.paddingSmall),
|
||||
],
|
||||
@@ -530,7 +538,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
Navigator.pop(context);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Idioma cambiado a $name'),
|
||||
content: Text(L10n.of(context)!.languageChanged(name)),
|
||||
backgroundColor: AppColors.success,
|
||||
),
|
||||
);
|
||||
@@ -624,7 +632,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Tu wallet de ecash privado',
|
||||
L10n.of(context)!.appTagline,
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 16,
|
||||
@@ -634,7 +642,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,
|
||||
@@ -675,9 +683,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,
|
||||
),
|
||||
@@ -696,8 +704,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,
|
||||
),
|
||||
);
|
||||
@@ -756,7 +764,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,
|
||||
),
|
||||
);
|
||||
@@ -981,6 +989,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,
|
||||
@@ -1026,9 +1036,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,
|
||||
@@ -1055,10 +1065,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,
|
||||
@@ -1070,7 +1080,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,
|
||||
@@ -1090,7 +1100,7 @@ class _DeleteWalletModalState extends State<_DeleteWalletModal> {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Escribe "BORRAR" para confirmar:',
|
||||
l10n.typeDeleteToConfirm,
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 14,
|
||||
@@ -1106,7 +1116,7 @@ class _DeleteWalletModalState extends State<_DeleteWalletModal> {
|
||||
color: Colors.white,
|
||||
),
|
||||
decoration: InputDecoration(
|
||||
hintText: 'BORRAR',
|
||||
hintText: confirmWord,
|
||||
hintStyle: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 16,
|
||||
@@ -1121,7 +1131,7 @@ class _DeleteWalletModalState extends State<_DeleteWalletModal> {
|
||||
),
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_canDelete = value == 'BORRAR';
|
||||
_canDelete = value == confirmWord;
|
||||
});
|
||||
},
|
||||
),
|
||||
@@ -1143,10 +1153,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,
|
||||
@@ -1171,7 +1181,7 @@ class _DeleteWalletModalState extends State<_DeleteWalletModal> {
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
'Borrar wallet',
|
||||
l10n.deleteWallet,
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 16,
|
||||
@@ -1227,10 +1237,9 @@ class _RecoverTokensModalState extends State<_RecoverTokensModal> {
|
||||
|
||||
Future<void> _loadMints() async {
|
||||
final walletProvider = context.read<WalletProvider>();
|
||||
final mints = await walletProvider.listMints();
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_availableMints = mints.map((m) => m.url).toList();
|
||||
_availableMints = walletProvider.mintUrls;
|
||||
_isLoadingMints = false;
|
||||
if (_availableMints.isNotEmpty) {
|
||||
_selectedMintUrl = _availableMints.first;
|
||||
@@ -1293,9 +1302,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 +1315,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 +1354,7 @@ class _RecoverTokensModalState extends State<_RecoverTokensModal> {
|
||||
}
|
||||
|
||||
Widget _buildMnemonicOptions() {
|
||||
final l10n = L10n.of(context)!;
|
||||
return Column(
|
||||
children: [
|
||||
// Opción: Usar mnemonic actual
|
||||
@@ -1379,9 +1389,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 +1399,7 @@ class _RecoverTokensModalState extends State<_RecoverTokensModal> {
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'Escanear mints con las 12 palabras guardadas',
|
||||
l10n.scanWithSavedWords,
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 12,
|
||||
@@ -1438,9 +1448,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 +1458,7 @@ class _RecoverTokensModalState extends State<_RecoverTokensModal> {
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'Recuperar tokens de otras 12 palabras',
|
||||
l10n.recoverFromOtherWords,
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 12,
|
||||
@@ -1467,6 +1477,7 @@ class _RecoverTokensModalState extends State<_RecoverTokensModal> {
|
||||
}
|
||||
|
||||
Widget _buildMintSelector() {
|
||||
final l10n = L10n.of(context)!;
|
||||
if (_isLoadingMints) {
|
||||
return const Padding(
|
||||
padding: EdgeInsets.all(16),
|
||||
@@ -1487,7 +1498,7 @@ class _RecoverTokensModalState extends State<_RecoverTokensModal> {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Mints a escanear:',
|
||||
l10n.mintsToScan,
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 14,
|
||||
@@ -1524,7 +1535,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 +1576,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 +1650,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 +1732,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 +1749,7 @@ class _RecoverTokensModalState extends State<_RecoverTokensModal> {
|
||||
|
||||
Future<void> _startRecover() async {
|
||||
if (!mounted) return;
|
||||
final l10n = L10n.of(context)!;
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
_result = null;
|
||||
@@ -1749,55 +1761,84 @@ class _RecoverTokensModalState extends State<_RecoverTokensModal> {
|
||||
if (_useCurrentMnemonic) {
|
||||
// Usar mnemonic actual
|
||||
if (_scanAllMints) {
|
||||
// Escanear todos los mints
|
||||
// Escanear todos los mints (retorna Map<String, Map<String, BigInt>>)
|
||||
final results = await walletProvider.restoreAllMints();
|
||||
|
||||
BigInt totalRecovered = BigInt.zero;
|
||||
int mintsScanned = 0;
|
||||
int mintsWithError = 0;
|
||||
final recoveredDetails = <String>[];
|
||||
|
||||
for (final entry in results.entries) {
|
||||
if (entry.value >= BigInt.zero) {
|
||||
totalRecovered += entry.value;
|
||||
mintsScanned++;
|
||||
} else {
|
||||
for (final mintEntry in results.entries) {
|
||||
final unitBalances = mintEntry.value;
|
||||
bool hasError = false;
|
||||
BigInt mintTotal = BigInt.zero;
|
||||
|
||||
for (final unitEntry in unitBalances.entries) {
|
||||
final unit = unitEntry.key;
|
||||
final balance = unitEntry.value;
|
||||
if (balance < BigInt.zero) {
|
||||
hasError = true;
|
||||
} else if (balance > BigInt.zero) {
|
||||
mintTotal += balance;
|
||||
final formatted = UnitFormatter.formatBalance(balance, unit);
|
||||
final label = UnitFormatter.getUnitLabel(unit);
|
||||
recoveredDetails.add('$formatted $label');
|
||||
}
|
||||
}
|
||||
|
||||
if (hasError) {
|
||||
mintsWithError++;
|
||||
} else {
|
||||
mintsScanned++;
|
||||
totalRecovered += mintTotal;
|
||||
}
|
||||
}
|
||||
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_isSuccess = true;
|
||||
if (totalRecovered > BigInt.zero) {
|
||||
_result = '¡Recuperados ${totalRecovered.toInt()} sats de $mintsScanned mint(s)!';
|
||||
if (recoveredDetails.isNotEmpty) {
|
||||
_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 {
|
||||
// Escanear mint específico
|
||||
// Escanear mint específico (retorna Map<String, BigInt>)
|
||||
if (_selectedMintUrl == null) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_isSuccess = false;
|
||||
_result = 'Selecciona un mint para escanear';
|
||||
_result = l10n.selectMintToScan;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
final recovered = await walletProvider.restoreFromMint(_selectedMintUrl!);
|
||||
final mintHost = Uri.parse(_selectedMintUrl!).host;
|
||||
final unitBalances = await walletProvider.restoreFromMint(_selectedMintUrl!);
|
||||
final mintHost = UnitFormatter.getMintDisplayName(_selectedMintUrl!);
|
||||
|
||||
final recoveredDetails = <String>[];
|
||||
for (final entry in unitBalances.entries) {
|
||||
final unit = entry.key;
|
||||
final balance = entry.value;
|
||||
if (balance > BigInt.zero) {
|
||||
final formatted = UnitFormatter.formatBalance(balance, unit);
|
||||
final label = UnitFormatter.getUnitLabel(unit);
|
||||
recoveredDetails.add('$formatted $label');
|
||||
}
|
||||
}
|
||||
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_isSuccess = true;
|
||||
if (recovered > BigInt.zero) {
|
||||
_result = '¡Recuperados ${recovered.toInt()} sats de $mintHost!';
|
||||
if (recoveredDetails.isNotEmpty) {
|
||||
_result = l10n.recoveredFromMint(recoveredDetails.join(", "), mintHost);
|
||||
} else {
|
||||
_result = 'No se encontraron tokens en $mintHost.';
|
||||
_result = l10n.noTokensFoundInMint(mintHost);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1810,20 +1851,19 @@ class _RecoverTokensModalState extends State<_RecoverTokensModal> {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_isSuccess = false;
|
||||
_result = 'El mnemonic debe tener 12 o 24 palabras';
|
||||
_result = l10n.mnemonicMustHaveWords;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Obtener lista de mints actuales para escanear
|
||||
final mints = await walletProvider.listMints();
|
||||
final mintUrls = mints.map((m) => m.url).toList();
|
||||
final mintUrls = walletProvider.mintUrls;
|
||||
|
||||
if (mintUrls.isEmpty) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_isSuccess = false;
|
||||
_result = 'No hay mints conectados para escanear';
|
||||
_result = l10n.noConnectedMintsToScan;
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -1837,9 +1877,13 @@ class _RecoverTokensModalState extends State<_RecoverTokensModal> {
|
||||
setState(() {
|
||||
_isSuccess = true;
|
||||
if (recovered > BigInt.zero) {
|
||||
_result = '¡Recuperados y transferidos ${recovered.toInt()} sats a tu wallet!';
|
||||
// Usamos la unidad activa como aproximación para el formato
|
||||
final activeUnit = walletProvider.activeUnit;
|
||||
final formatted = UnitFormatter.formatBalance(recovered, activeUnit);
|
||||
final label = UnitFormatter.getUnitLabel(activeUnit);
|
||||
_result = l10n.recoveredAndTransferred(formatted, label);
|
||||
} else {
|
||||
_result = 'No se encontraron tokens asociados a ese mnemonic.';
|
||||
_result = l10n.noTokensForMnemonic;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1847,7 +1891,7 @@ class _RecoverTokensModalState extends State<_RecoverTokensModal> {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_isSuccess = false;
|
||||
_result = 'Error: $e';
|
||||
_result = '${l10n.error}: $e';
|
||||
});
|
||||
} finally {
|
||||
if (mounted) {
|
||||
|
||||
@@ -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,179 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../core/constants/colors.dart';
|
||||
import '../../core/models/proof.dart';
|
||||
import '../../core/utils/formatters.dart';
|
||||
|
||||
/// Widget para seleccionar proofs (notas de ecash) manualmente.
|
||||
/// Cada proof se muestra como un chip que cambia de color al seleccionar.
|
||||
class ProofSelector extends StatelessWidget {
|
||||
/// Lista de proofs disponibles.
|
||||
final List<LocalProof> proofs;
|
||||
|
||||
/// Set de proofs actualmente seleccionados (por yHex).
|
||||
final Set<String> selectedIds;
|
||||
|
||||
/// Unidad para formatear (sat, usd, eur).
|
||||
final String unit;
|
||||
|
||||
/// Callback cuando se selecciona/deselecciona un proof.
|
||||
final void Function(LocalProof proof) onToggle;
|
||||
|
||||
const ProofSelector({
|
||||
super.key,
|
||||
required this.proofs,
|
||||
required this.selectedIds,
|
||||
required this.unit,
|
||||
required this.onToggle,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (proofs.isEmpty) {
|
||||
return _buildEmptyState();
|
||||
}
|
||||
|
||||
return Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: proofs.map((proof) {
|
||||
final isSelected = selectedIds.contains(proof.yHex);
|
||||
return _ProofChip(
|
||||
proof: proof,
|
||||
unit: unit,
|
||||
isSelected: isSelected,
|
||||
onTap: () => onToggle(proof),
|
||||
);
|
||||
}).toList(),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildEmptyState() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.account_balance_wallet_outlined,
|
||||
size: 48,
|
||||
color: AppColors.textSecondary.withValues(alpha: 0.3),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'No hay notas disponibles',
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 16,
|
||||
color: AppColors.textSecondary.withValues(alpha: 0.5),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Chip individual para un proof.
|
||||
class _ProofChip extends StatelessWidget {
|
||||
final LocalProof proof;
|
||||
final String unit;
|
||||
final bool isSelected;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _ProofChip({
|
||||
required this.proof,
|
||||
required this.unit,
|
||||
required this.isSelected,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Formatear el monto según la unidad
|
||||
final displayAmount = _formatAmount(proof.amount, unit);
|
||||
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 150),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
// Color de fondo: naranja si seleccionado, glass si no
|
||||
color: isSelected
|
||||
? AppColors.primaryAction
|
||||
: Colors.white.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: isSelected
|
||||
? AppColors.primaryAction
|
||||
: Colors.white.withValues(alpha: 0.2),
|
||||
width: 1,
|
||||
),
|
||||
// Sombra sutil cuando seleccionado
|
||||
boxShadow: isSelected
|
||||
? [
|
||||
BoxShadow(
|
||||
color: AppColors.primaryAction.withValues(alpha: 0.3),
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
]
|
||||
: null,
|
||||
),
|
||||
child: Text(
|
||||
displayAmount,
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 16,
|
||||
fontWeight: isSelected ? FontWeight.w600 : FontWeight.w500,
|
||||
color: isSelected
|
||||
? Colors.white
|
||||
: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Formatea el monto según la unidad.
|
||||
/// sat: "512", usd/eur: "5.12"
|
||||
String _formatAmount(BigInt amount, String unit) {
|
||||
final multiplier = UnitFormatter.getMultiplier(unit);
|
||||
|
||||
if (multiplier == 100) {
|
||||
// USD/EUR: mostrar como decimal
|
||||
final value = amount.toDouble() / 100;
|
||||
return value.toStringAsFixed(2);
|
||||
} else {
|
||||
// SAT: mostrar como entero
|
||||
return amount.toString();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Widget que muestra el total seleccionado.
|
||||
class ProofTotalDisplay extends StatelessWidget {
|
||||
final BigInt total;
|
||||
final String unit;
|
||||
|
||||
const ProofTotalDisplay({
|
||||
super.key,
|
||||
required this.total,
|
||||
required this.unit,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final formattedTotal = UnitFormatter.formatBalance(total, unit);
|
||||
final unitLabel = UnitFormatter.getUnitLabel(unit);
|
||||
|
||||
return Text(
|
||||
'$formattedTotal $unitLabel',
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 32,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.white,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import flutter_secure_storage_macos
|
||||
import path_provider_foundation
|
||||
import share_plus
|
||||
import shared_preferences_foundation
|
||||
import sqflite_darwin
|
||||
import url_launcher_macos
|
||||
|
||||
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||
@@ -16,5 +17,6 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
|
||||
SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin"))
|
||||
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
||||
SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin"))
|
||||
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
|
||||
}
|
||||
|
||||
@@ -1,6 +1,22 @@
|
||||
# Generated by pub
|
||||
# See https://dart.dev/tools/pub/glossary#lockfile
|
||||
packages:
|
||||
ansicolor:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: ansicolor
|
||||
sha256: "50e982d500bc863e1d703448afdbf9e5a72eb48840a4f766fa361ffd6877055f"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.3"
|
||||
archive:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: archive
|
||||
sha256: "2fde1607386ab523f7a36bb3e7edb43bd58e6edaf2ffb29d8a6d578b297fdbbd"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.0.7"
|
||||
args:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -33,6 +49,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.0"
|
||||
cbor:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: cbor
|
||||
sha256: e60380c7329da6b415841be93884b8d4380cbd86cd4cecb2067baa221b8d88b5
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.3.5"
|
||||
cdk_flutter:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -50,6 +74,22 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.3.0"
|
||||
checked_yaml:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: checked_yaml
|
||||
sha256: feb6bed21949061731a7a75fc5d2aa727cf160b91af9a3e464c5e3a32e28b5ff
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.3"
|
||||
cli_util:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: cli_util
|
||||
sha256: ff6785f7e9e3c38ac98b2fb035701789de90154024a75b6cb926445e83197d1c
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.4.2"
|
||||
clock:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -66,6 +106,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.19.0"
|
||||
convert:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: convert
|
||||
sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.2"
|
||||
cross_file:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -82,6 +130,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.7"
|
||||
csslib:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: csslib
|
||||
sha256: "09bad715f418841f976c77db72d5398dc1253c21fb9c0c7f0b0b985860b2d58e"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.2"
|
||||
cupertino_icons:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -127,6 +183,14 @@ packages:
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
flutter_launcher_icons:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
name: flutter_launcher_icons
|
||||
sha256: "10f13781741a2e3972126fae08393d3c4e01fa4cd7473326b94b72cf594195e7"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.14.4"
|
||||
flutter_lints:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
@@ -140,6 +204,14 @@ packages:
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
flutter_native_splash:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
name: flutter_native_splash
|
||||
sha256: "7062602e0dbd29141fb8eb19220b5871ca650be5197ab9c1f193a28b17537bc7"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.4"
|
||||
flutter_rust_bridge:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -222,8 +294,24 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.3.0"
|
||||
http:
|
||||
hex:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: hex
|
||||
sha256: "4e7cd54e4b59ba026432a6be2dd9d96e4c5205725194997193bf871703b82c4a"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.2.0"
|
||||
html:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: html
|
||||
sha256: "6d1264f2dffa1b1101c25a91dff0dc2daee4c18e87cd8538729773c073dbf602"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.15.6"
|
||||
http:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: http
|
||||
sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
|
||||
@@ -238,6 +326,22 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.1.2"
|
||||
ieee754:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: ieee754
|
||||
sha256: "7d87451c164a56c156180d34a4e93779372edd191d2c219206100b976203128c"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.3"
|
||||
image:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: image
|
||||
sha256: "492bd52f6c4fbb6ee41f781ff27765ce5f627910e1e0cbecfa3d9add5562604c"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.7.2"
|
||||
intl:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -398,6 +502,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.3.0"
|
||||
petitparser:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: petitparser
|
||||
sha256: c15605cd28af66339f8eb6fbe0e541bfe2d1b72d5825efc6598f3e0a31b9ad27
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.0.2"
|
||||
platform:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -414,6 +526,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.8"
|
||||
posix:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: posix
|
||||
sha256: "6323a5b0fa688b6a010df4905a56b00181479e6d10534cecfecede2aa55add61"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.0.3"
|
||||
provider:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -523,6 +643,62 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.10.0"
|
||||
sqflite:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: sqflite
|
||||
sha256: "2d7299468485dca85efeeadf5d38986909c5eb0cd71fd3db2c2f000e6c9454bb"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.1"
|
||||
sqflite_android:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: sqflite_android
|
||||
sha256: "78f489aab276260cdd26676d2169446c7ecd3484bbd5fead4ca14f3ed4dd9ee3"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.0"
|
||||
sqflite_common:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: sqflite_common
|
||||
sha256: "761b9740ecbd4d3e66b8916d784e581861fd3c3553eda85e167bc49fdb68f709"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.5.4+6"
|
||||
sqflite_common_ffi:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: sqflite_common_ffi
|
||||
sha256: "883dd810b2b49e6e8c3b980df1829ef550a94e3f87deab5d864917d27ca6bf36"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.3.4+4"
|
||||
sqflite_darwin:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: sqflite_darwin
|
||||
sha256: "22adfd9a2c7d634041e96d6241e6e1c8138ca6817018afc5d443fef91dcefa9c"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.1+1"
|
||||
sqflite_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: sqflite_platform_interface
|
||||
sha256: "8dd4515c7bdcae0a785b0062859336de775e8c65db81ae33dd5445f35be61920"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.0"
|
||||
sqlite3:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: sqlite3
|
||||
sha256: "3145bd74dcdb4fd6f5c6dda4d4e4490a8087d7f286a14dee5d37087290f0f8a2"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.9.4"
|
||||
stack_trace:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -547,6 +723,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.3.0"
|
||||
synchronized:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: synchronized
|
||||
sha256: "69fe30f3a8b04a0be0c15ae6490fc859a78ef4c43ae2dd5e8a623d45bfcf9225"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.3.0+3"
|
||||
term_glyph:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -571,6 +755,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.0"
|
||||
universal_io:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: universal_io
|
||||
sha256: f63cbc48103236abf48e345e07a03ce5757ea86285ed313a6a032596ed9301e2
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.3.1"
|
||||
url_launcher:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -636,7 +828,7 @@ packages:
|
||||
source: hosted
|
||||
version: "3.1.4"
|
||||
uuid:
|
||||
dependency: transitive
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: uuid
|
||||
sha256: a11b666489b1954e01d992f3d601b1804a33937b5a8fe677bd26b8a9f96f96e8
|
||||
@@ -683,6 +875,22 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.0"
|
||||
xml:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: xml
|
||||
sha256: b015a8ad1c488f66851d762d3090a21c600e479dc75e68328c52774040cf9226
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.5.0"
|
||||
yaml:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: yaml
|
||||
sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.3"
|
||||
sdks:
|
||||
dart: ">=3.6.0 <4.0.0"
|
||||
flutter: ">=3.27.0"
|
||||
|
||||
@@ -27,6 +27,8 @@ dependencies:
|
||||
shared_preferences: ^2.2.0
|
||||
flutter_secure_storage: ^9.0.0
|
||||
path_provider: ^2.1.0
|
||||
sqflite: ^2.3.0
|
||||
sqflite_common_ffi: ^2.3.0
|
||||
|
||||
# Icons
|
||||
lucide_icons: ^0.257.0
|
||||
@@ -41,10 +43,40 @@ dependencies:
|
||||
# URL Launcher
|
||||
url_launcher: ^6.2.0
|
||||
|
||||
# HTTP client for mint API calls
|
||||
http: ^1.2.0
|
||||
|
||||
# CBOR decoder for V4 tokens
|
||||
cbor: ^6.3.0
|
||||
|
||||
# UUID generator for pending tokens
|
||||
uuid: ^4.3.3
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
flutter_lints: ^5.0.0
|
||||
flutter_launcher_icons: ^0.14.3
|
||||
flutter_native_splash: ^2.4.4
|
||||
|
||||
# App Icon configuration
|
||||
flutter_launcher_icons:
|
||||
android: true
|
||||
ios: false
|
||||
image_path: "assets/img/elcajucubano.png"
|
||||
adaptive_icon_background: "#1A0B2E"
|
||||
adaptive_icon_foreground: "assets/img/elcajucubano.png"
|
||||
min_sdk_android: 24
|
||||
|
||||
# Native Splash Screen configuration
|
||||
flutter_native_splash:
|
||||
color: "#1A0B2E"
|
||||
image: "assets/img/elcajucubano.png"
|
||||
android_12:
|
||||
color: "#1A0B2E"
|
||||
image: "assets/img/elcajucubano.png"
|
||||
android: true
|
||||
ios: false
|
||||
|
||||
flutter:
|
||||
uses-material-design: true
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<!DOCTYPE html><html><head>
|
||||
<!--
|
||||
If you are serving your web app in a path other than the root, change the
|
||||
href value below to reflect the base path you are serving from.
|
||||
@@ -27,12 +25,84 @@
|
||||
<link rel="apple-touch-icon" href="icons/Icon-192.png">
|
||||
|
||||
<!-- Favicon -->
|
||||
<link rel="icon" type="image/png" href="favicon.png"/>
|
||||
<link rel="icon" type="image/png" href="favicon.png">
|
||||
|
||||
<title>elcaju</title>
|
||||
<link rel="manifest" href="manifest.json">
|
||||
<style id="splash-screen-style">
|
||||
html {
|
||||
height: 100%
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100%;
|
||||
background-color: #1A0B2E;
|
||||
background-size: 100% 100%;
|
||||
}
|
||||
|
||||
.center {
|
||||
margin: 0;
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
-ms-transform: translate(-50%, -50%);
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
|
||||
.contain {
|
||||
display:block;
|
||||
width:100%; height:100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.stretch {
|
||||
display:block;
|
||||
width:100%; height:100%;
|
||||
}
|
||||
|
||||
.cover {
|
||||
display:block;
|
||||
width:100%; height:100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.bottom {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 50%;
|
||||
-ms-transform: translate(-50%, 0);
|
||||
transform: translate(-50%, 0);
|
||||
}
|
||||
|
||||
.bottomLeft {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.bottomRight {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
}
|
||||
</style>
|
||||
<script id="splash-screen-script">
|
||||
function removeSplashFromWeb() {
|
||||
document.getElementById("splash")?.remove();
|
||||
document.getElementById("splash-branding")?.remove();
|
||||
document.body.style.background = "transparent";
|
||||
}
|
||||
</script>
|
||||
<meta content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" name="viewport">
|
||||
</head>
|
||||
<body>
|
||||
<script src="flutter_bootstrap.js" async></script>
|
||||
</body>
|
||||
</html>
|
||||
<picture id="splash">
|
||||
<source srcset="splash/img/light-1x.png 1x, splash/img/light-2x.png 2x, splash/img/light-3x.png 3x, splash/img/light-4x.png 4x" media="(prefers-color-scheme: light)">
|
||||
<source srcset="splash/img/dark-1x.png 1x, splash/img/dark-2x.png 2x, splash/img/dark-3x.png 3x, splash/img/dark-4x.png 4x" media="(prefers-color-scheme: dark)">
|
||||
<img class="center" aria-hidden="true" src="splash/img/light-1x.png" alt="">
|
||||
</picture>
|
||||
<script src="flutter_bootstrap.js" async=""></script>
|
||||
|
||||
|
||||
</body></html>
|
||||
|
After Width: | Height: | Size: 97 KiB |
|
After Width: | Height: | Size: 300 KiB |
|
After Width: | Height: | Size: 536 KiB |
|
After Width: | Height: | Size: 786 KiB |
|
After Width: | Height: | Size: 97 KiB |
|
After Width: | Height: | Size: 300 KiB |
|
After Width: | Height: | Size: 536 KiB |
|
After Width: | Height: | Size: 786 KiB |