Compare commits

...
Author SHA1 Message Date
Forte11Cuba 9e3622435b fix(receive): use localized string for invalid token error 2026-02-07 22:54:54 -06:00
Forte11Cuba b6c5b42cf2 fix(receive): add catch block for save-for-later errors 2026-02-07 22:44:46 -06:00
Forte11Cuba 835f5205c7 feat(receive): single button with mint connectivity check 2026-02-07 22:35:13 -06:00
Forte11andGitHub 0629c00c90 Merge pull request #18 from Forte11Cuba/fix/onboarding-ui-polish
fix: improve onboarding UI with language selector and icon consistency
2026-02-06 21:49:40 -06:00
Forte11Cuba f49e6a9c59 fix flags 2026-02-06 21:43:33 -06:00
Forte11Cuba a0821429dd fix: improve onboarding UI with language selector and icon consistency 2026-02-06 21:33:47 -06:00
Forte11andGitHub 2e0f360ff0 Merge pull request #17 from Forte11Cuba/feature/receive-later
feat: add Receive Later functionality for offline token storage
2026-02-06 20:58:34 -06:00
Forte11Cuba cbddc5aff0 fix some files 2026-02-06 20:56:41 -06:00
Forte11Cuba 9fee4c7ef4 feat: add Receive Later functionality for offline token storage 2026-02-06 20:47:03 -06:00
Forte11andGitHub 7035e2ef00 Merge pull request #16 from Forte11Cuba/feature/i18n
feat: complete i18n implementation for all screens
2026-02-06 20:11:01 -06:00
Forte11Cuba 989fd19dc8 fix some files 2026-02-06 20:07:31 -06:00
Forte11Cuba 5dff3bcf24 feat: complete i18n implementation for all screens 2026-02-06 19:57:17 -06:00
Forte11andGitHub f30d6bac2e Merge pull request #14 from Forte11Cuba/fix/scroll-layout-overlapping
fix: resolve overlapping in screens with keyboard
2026-02-06 18:58:51 -06:00
Forte11Cuba 55560a28fc fix: resolve overlapping in screens with keyboard 2026-02-06 18:53:40 -06:00
Forte11Cuba db2f393ff0 feat: Add Android build support 2026-02-06 17:32:57 -06:00
Forte11andGitHub 9691dda1bc Correct gender reference for ElCaju description 2026-02-06 16:17:55 -06:00
Forte11Cuba be72926a25 Merge branch 'main' of github.com:Forte11Cuba/elcaju 2026-02-06 16:12:14 -06:00
Forte11Cuba ce4ef20177 LICENSE 2026-02-06 16:11:09 -06:00
Forte11andGitHub f101f7098b Fix directory path in README for cloning 2026-02-06 16:07:47 -06:00
Forte11Cuba 6cf9e5b69c readme 2026-02-06 16:06:24 -06:00
Forte11andGitHub b14acba5b7 Merge pull request #13 from Forte11Cuba/feature/mint-info-screen
feat: add mint detail screen and show mint logo in dashboard
2026-02-06 15:46:10 -06:00
Forte11Cuba 0a932bdd8e feat: add mint detail screen and show mint logo in dashboard 2026-02-06 15:44:41 -06:00
Forte11andGitHub 5934a1db8a Merge pull request #12 from Forte11Cuba/feature/balance-tap-toggle
feat: improve balance interaction UX
2026-02-06 15:14:22 -06:00
Forte11Cuba 9d643af39d feat: improve balance interaction UX 2026-02-06 15:13:29 -06:00
Forte11andGitHub a5dc4d6160 Merge pull request #11 from Forte11Cuba/feature/unit-labels-cashu-style
feat: update unit display to match cashu.me style
2026-02-06 14:55:40 -06:00
Forte11Cuba 922fabc784 feat: update unit display to match cashu.me style 2026-02-06 14:54:46 -06:00
Forte11andGitHub 7540e7e413 Merge pull request #10 from Forte11Cuba/feature/history-screen-qr-dynamic
feat: add dedicated history screen with dynamic QR codes
2026-02-06 14:23:04 -06:00
Forte11Cuba 9ee020ccb2 feat: add dedicated history screen with dynamic QR codes 2026-02-06 14:22:25 -06:00
Forte11andGitHub aeaebee37b Merge pull request #9 from Forte11Cuba/feature/animated-action-buttons
feature/animated-action-buttons
2026-02-06 13:11:12 -06:00
Forte11Cuba 52812c85c6 feature/animated-action-buttons 2026-02-06 13:10:27 -06:00
Forte11andGitHub dac5693d5c Merge pull request #8 from Forte11Cuba/feature/app-icon-splash
Add app icon and native splash screen
2026-02-06 12:46:04 -06:00
Forte11Cuba 0e6f06bf7f Add app icon and native splash screen 2026-02-06 12:44:52 -06:00
Forte11andGitHub 5b70ca3773 Merge pull request #7 from Forte11Cuba/feat/addofflinesend
feat: add offline send with manual proof selection
2026-02-06 03:44:49 -06:00
Forte11Cuba f0236df966 feat: add offline send with manual proof selection 2026-02-06 03:43:43 -06:00
Forte11andGitHub 6b1cdabf14 Merge pull request #6 from Forte11Cuba/feat/mint-persistence-and-ordering
feat: persist mints and prioritize Cuba Bitcoin
2026-02-06 02:30:13 -06:00
Forte11Cuba 48ab3d8c4d feat: persist mints and prioritize Cuba Bitcoin 2026-02-06 02:28:57 -06:00
Forte11andGitHub ac6af4b883 Merge pull request #5 from Forte11Cuba/feat/auto-detect-token-unit
feat(receive): auto-detect token unit from V3/V4 tokens
2026-02-06 02:07:58 -06:00
Forte11Cuba 0a935193c2 feat(receive): auto-detect token unit from V3/V4 tokens 2026-02-06 02:05:52 -06:00
Forte11andGitHub a0d55df497 Merge pull request #4 from Forte11Cuba/feature/multi-unit-support
feat: add multi-unit currency support (sat, usd, eur)
2026-02-06 01:20:26 -06:00
Forte11Cuba 8ce9ed37bd feat: add multi-unit currency support (sat, usd, eur) 2026-02-06 01:19:34 -06:00
Forte11andGitHub 862d9e36ac Merge pull request #3 from Forte11Cuba/fix/balance-mint-activo
fix: show active mint balance instead of total balance
2026-02-05 21:26:18 -06:00
Forte11Cuba a3f17210bc fix: show active mint balance instead of total balance 2026-02-05 21:25:49 -06:00
Forte11andGitHub 6a1d956bef Merge pull request #2 from Forte11Cuba/feature/animated-ur-qr-codes
feat: implement animated UR QR codes for token sharing
2026-02-05 21:11:54 -06:00
Forte11Cuba 8896bc83f7 feat: implement animated UR QR codes for token sharing 2026-02-05 21:09:36 -06:00
Forte11andGitHub e607148cca Merge pull request #1 from Forte11Cuba/nut13
feat: implement NUT-13 restore to recover tokens
2026-02-05 20:50:32 -06:00
94 changed files with 8702 additions and 2087 deletions
+21
View File
@@ -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.
+341 -11
View File
@@ -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>
+41
View File
@@ -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 "../.."
}
-43
View File
@@ -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 = "../.."
}
+5 -1
View File
@@ -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
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,4 +1,4 @@
package com.example.elcaju
package me.elcaju
import io.flutter.embedding.android.FlutterActivity
Binary file not shown.

After

Width:  |  Height:  |  Size: 188 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 188 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 188 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 300 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 536 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 786 KiB

Binary file not shown.

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>
Binary file not shown.

After

Width:  |  Height:  |  Size: 300 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 300 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 536 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 536 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 786 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 786 KiB

Binary file not shown.

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>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 544 B

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 442 B

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 721 B

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

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
}
+25
View File
@@ -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"
-25
View File
@@ -1,25 +0,0 @@
pluginManagement {
val flutterSdkPath = run {
val properties = java.util.Properties()
file("local.properties").inputStream().use { properties.load(it) }
val flutterSdkPath = properties.getProperty("flutter.sdk")
require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" }
flutterSdkPath
}
includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
plugins {
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
id("com.android.application") version "8.7.3" apply false
id("org.jetbrains.kotlin.android") version "2.1.0" apply false
}
include(":app")
Binary file not shown.

After

Width:  |  Height:  |  Size: 197 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 441 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 208 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 257 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 283 KiB

+148
View File
@@ -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';
}
}
}
+181
View File
@@ -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;
}
}
+158
View File
@@ -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,
);
}
}
+126
View File
@@ -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)';
}
}
+279
View File
@@ -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();
}
}
+166
View File
@@ -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;
}
}
+262 -6
View File
@@ -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" }
}
}
}
+374 -12
View File
@@ -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" }
}
}
}
+3 -1
View File
@@ -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(),
);
+36
View File
@@ -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();
}
File diff suppressed because it is too large Load Diff
+42
View File
@@ -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
+145 -2
View File
@@ -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,
+330 -478
View File
@@ -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,
),
),
),
);
},
),
);
}
}
+263 -126
View File
@@ -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;
});
}
}
}
+354
View File
@@ -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;
});
}
}
}
+208 -58
View File
@@ -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,
),
+217 -155
View File
@@ -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',
),
);
}
+75 -53
View File
@@ -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),
),
);
}
+65 -41
View File
@@ -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,
),
+128 -102
View File
@@ -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),
),
),
],
),
);
}
}
+303 -491
View File
@@ -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;
});
}
}
+161 -117
View File
@@ -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) {
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,328 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../../core/constants/colors.dart';
import '../../core/constants/dimensions.dart';
/// Tipos de botón según la importancia de la acción
/// La intensidad del efecto = importancia de la acción
enum ButtonType {
/// Acciones críticas que mueven dinero (Enviar)
/// Efecto más fuerte: heavyImpact, scale 0.95, delay 100ms
criticalAction,
/// Acciones importantes pero seguras (Recibir)
/// Efecto medio: mediumImpact, scale 0.97, sin delay
primaryAction,
/// Acciones de navegación (Historial)
/// Efecto sutil: lightImpact, scale 0.98, sin delay
navigation,
}
/// Botón animado con feedback táctil y visual premium
///
/// Diferencia la intensidad del efecto según el tipo de acción:
/// - [ButtonType.criticalAction]: Para acciones que mueven dinero
/// - [ButtonType.primaryAction]: Para acciones importantes pero seguras
/// - [ButtonType.navigation]: Para navegación simple
///
/// Ejemplo de uso:
/// ```dart
/// AnimatedActionButton(
/// label: 'Enviar',
/// type: ButtonType.criticalAction,
/// onTap: () => _handleSend(),
/// )
/// ```
class AnimatedActionButton extends StatefulWidget {
/// Texto del botón
final String label;
/// Callback al tocar (se ejecuta después del micro-delay si aplica)
final VoidCallback onTap;
/// Tipo de botón que determina la intensidad del efecto
final ButtonType type;
/// Gradiente opcional (para botones primarios)
/// Si es null y backgroundColor también, usa el gradiente por defecto
final Gradient? gradient;
/// Color de fondo opcional (para botones de navegación)
/// Se ignora si gradient está definido
final Color? backgroundColor;
/// Icono opcional antes del texto
final IconData? icon;
/// Si mostrar el icono (default: false)
final bool showIcon;
/// Ancho del botón (default: expandir al padre)
final double? width;
/// Alto del botón (default: según tipo)
final double? height;
const AnimatedActionButton({
super.key,
required this.label,
required this.onTap,
required this.type,
this.gradient,
this.backgroundColor,
this.icon,
this.showIcon = false,
this.width,
this.height,
});
@override
State<AnimatedActionButton> createState() => _AnimatedActionButtonState();
}
class _AnimatedActionButtonState extends State<AnimatedActionButton>
with SingleTickerProviderStateMixin {
/// Controller para animaciones coordinadas
late AnimationController _controller;
/// Animación de escala
late Animation<double> _scaleAnimation;
/// Animación de offset de sombra
late Animation<double> _shadowOffsetAnimation;
/// Animación de blur de sombra
late Animation<double> _shadowBlurAnimation;
/// Animación de opacidad de sombra
late Animation<double> _shadowOpacityAnimation;
@override
void initState() {
super.initState();
// Duración de la animación: 150ms para sentirse responsivo
_controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 150),
);
// Configurar animaciones según el tipo de botón
_setupAnimations();
}
void _setupAnimations() {
// Curve suave para salida (easeOutCubic da sensación premium)
const curve = Curves.easeOutCubic;
// === ESCALA ===
// Intensidad según tipo: crítico se hunde más, navegación casi imperceptible
final double targetScale = switch (widget.type) {
ButtonType.criticalAction => 0.95, // Se hunde más - acción crítica
ButtonType.primaryAction => 0.97, // Se hunde menos
ButtonType.navigation => 0.98, // Muy sutil - solo navegación
};
_scaleAnimation = Tween<double>(
begin: 1.0,
end: targetScale,
).animate(CurvedAnimation(parent: _controller, curve: curve));
// === SOMBRA ===
// Los valores de sombra también varían según tipo
final (normalOffset, pressedOffset) = switch (widget.type) {
ButtonType.criticalAction => (6.0, 2.0),
ButtonType.primaryAction => (6.0, 2.0),
ButtonType.navigation => (4.0, 1.0),
};
final (normalBlur, pressedBlur) = switch (widget.type) {
ButtonType.criticalAction => (12.0, 4.0),
ButtonType.primaryAction => (12.0, 4.0),
ButtonType.navigation => (8.0, 3.0),
};
final (normalOpacity, pressedOpacity) = switch (widget.type) {
ButtonType.criticalAction => (0.4, 0.15),
ButtonType.primaryAction => (0.3, 0.1),
ButtonType.navigation => (0.2, 0.1),
};
_shadowOffsetAnimation = Tween<double>(
begin: normalOffset,
end: pressedOffset,
).animate(CurvedAnimation(parent: _controller, curve: curve));
_shadowBlurAnimation = Tween<double>(
begin: normalBlur,
end: pressedBlur,
).animate(CurvedAnimation(parent: _controller, curve: curve));
_shadowOpacityAnimation = Tween<double>(
begin: normalOpacity,
end: pressedOpacity,
).animate(CurvedAnimation(parent: _controller, curve: curve));
}
@override
void didUpdateWidget(AnimatedActionButton oldWidget) {
super.didUpdateWidget(oldWidget);
// Reconfigurar si cambia el tipo
if (oldWidget.type != widget.type) {
_setupAnimations();
}
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
/// Trigger haptic feedback según tipo de acción
void _triggerHaptic() {
switch (widget.type) {
case ButtonType.criticalAction:
// Acción crítica - mueve dinero - feedback fuerte
HapticFeedback.heavyImpact();
break;
case ButtonType.primaryAction:
// Acción importante pero segura - feedback medio
HapticFeedback.mediumImpact();
break;
case ButtonType.navigation:
// Solo navegación - feedback sutil
HapticFeedback.lightImpact();
break;
}
}
/// Delay después del tap según tipo
/// Solo acciones críticas tienen micro-delay (sensación de "peso")
Duration get _callbackDelay {
return widget.type == ButtonType.criticalAction
? const Duration(milliseconds: 100)
: Duration.zero;
}
void _handleTapDown(TapDownDetails details) {
_controller.forward();
// Haptic inmediato al tocar
_triggerHaptic();
}
void _handleTapUp(TapUpDetails details) {
_controller.reverse();
// Ejecutar callback después del delay según tipo
Future.delayed(_callbackDelay, () {
widget.onTap();
});
}
void _handleTapCancel() {
// Revertir animación sin ejecutar callback
_controller.reverse();
}
@override
Widget build(BuildContext context) {
// Determinar si es botón con gradiente o con color sólido
final bool isGradientButton = widget.type != ButtonType.navigation;
// Gradiente por defecto para botones primarios
final effectiveGradient = widget.gradient ??
(isGradientButton
? const LinearGradient(
begin: Alignment.centerLeft,
end: Alignment.centerRight,
colors: AppColors.buttonGradient,
)
: null);
// Color de fondo para botón de navegación (glass effect)
final effectiveBackgroundColor = widget.backgroundColor ??
(!isGradientButton
? AppColors.glassBase.withValues(alpha: AppColors.glassOpacity)
: null);
// Color de sombra según tipo
final shadowColor = isGradientButton
? AppColors.primaryAction
: Colors.black;
// Altura según tipo
final effectiveHeight = widget.height ??
(widget.type == ButtonType.navigation
? AppDimensions.buttonHeight + 4
: AppDimensions.buttonHeight);
return GestureDetector(
onTapDown: _handleTapDown,
onTapUp: _handleTapUp,
onTapCancel: _handleTapCancel,
child: AnimatedBuilder(
animation: _controller,
builder: (context, child) {
return Transform.scale(
scale: _scaleAnimation.value,
child: Container(
width: widget.width ?? double.infinity,
height: effectiveHeight,
decoration: BoxDecoration(
// Gradiente o color sólido
gradient: effectiveGradient,
color: effectiveGradient == null ? effectiveBackgroundColor : null,
borderRadius: BorderRadius.circular(AppDimensions.buttonBorderRadius),
// Borde para botones de navegación (glass effect)
border: !isGradientButton
? Border.all(
color: Colors.white.withValues(alpha: AppColors.glassBorderOpacity),
width: 1,
)
: null,
// Sombra animada
boxShadow: [
BoxShadow(
color: shadowColor.withValues(alpha: _shadowOpacityAnimation.value),
blurRadius: _shadowBlurAnimation.value,
offset: Offset(0, _shadowOffsetAnimation.value),
),
],
),
child: child,
),
);
},
child: Center(
child: Row(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: [
// Icono opcional
if (widget.showIcon && widget.icon != null) ...[
Icon(
widget.icon,
color: Colors.white,
size: widget.type == ButtonType.navigation ? 28 : 20,
),
const SizedBox(width: AppDimensions.paddingSmall),
],
// Texto
Text(
widget.label,
style: TextStyle(
fontFamily: 'Inter',
fontSize: widget.type == ButtonType.navigation ? 20 : 18,
fontWeight: FontWeight.w600,
color: Colors.white,
),
),
],
),
),
),
);
}
}
+179
View File
@@ -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"))
}
+210 -2
View File
@@ -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"
+32
View File
@@ -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
+77 -7
View File
@@ -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>
Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 300 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 536 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 786 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 300 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 536 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 786 KiB