Compare commits

...
Author SHA1 Message Date
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
63 changed files with 4314 additions and 1029 deletions
+2 -2
View File
@@ -6,7 +6,7 @@ plugins {
}
android {
namespace = "org.cubabitcoin.elcaju"
namespace = "me.elcaju"
compileSdk = flutter.compileSdkVersion
ndkVersion = "27.0.12077973"
@@ -20,7 +20,7 @@ android {
}
defaultConfig {
applicationId = "org.cubabitcoin.elcaju"
applicationId = "me.elcaju"
// You can update the following values to match your application needs.
// For more information, see: https://flutter.dev/to/review-gradle-config.
minSdk = flutter.minSdkVersion
+1 -1
View File
@@ -1,6 +1,6 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application
android:label="elcaju"
android:label="ElCaju"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
<activity
@@ -1,4 +1,4 @@
package com.example.elcaju
package me.elcaju
import io.flutter.embedding.android.FlutterActivity
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
+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;
}
}
+39 -4
View File
@@ -34,16 +34,34 @@ class UnitFormatter {
}
/// Formatea un balance con su unidad.
/// Ejemplo: 1000 sat → "1,000 BTC", 500 usd → "5.00 USD"
/// Ejemplo: 1000 sat → "1,000 sat", 500 usd → "5.00 USD"
static String formatBalanceWithUnit(BigInt amount, String unit) {
final formatted = formatBalance(amount, unit);
final label = getUnitLabel(unit);
return '$formatted $label';
}
/// Obtiene la etiqueta de display para una unidad.
/// sat → BTC, usd → USD, eur → EUR, msat → MSAT
/// Obtiene la etiqueta de display para una unidad (para balances).
/// Siguiendo el estándar de cashu.me: minúsculas para sat/msat
/// Ejemplo: "855 sat", "5.00 USD"
static String getUnitLabel(String unit) {
switch (unit.toLowerCase()) {
case 'sat':
return 'sat';
case 'usd':
return 'USD';
case 'eur':
return 'EUR';
case 'msat':
return 'msat';
default:
return unit;
}
}
/// Obtiene la etiqueta para el botón toggle de unidad.
/// sat → "BTC" (indica Bitcoin), otros → mayúsculas
static String getToggleLabel(String unit) {
switch (unit.toLowerCase()) {
case 'sat':
return 'BTC';
@@ -52,7 +70,24 @@ class UnitFormatter {
case 'eur':
return 'EUR';
case 'msat':
return 'MSAT';
return 'mSAT';
default:
return unit.toUpperCase();
}
}
/// Obtiene la etiqueta en mayúsculas (para badges, estados).
/// Ejemplo: "PENDING: 536 SAT"
static String getUnitLabelUppercase(String unit) {
switch (unit.toLowerCase()) {
case 'sat':
return 'SAT';
case 'usd':
return 'USD';
case 'eur':
return 'EUR';
case 'msat':
return 'mSAT';
default:
return unit.toUpperCase();
}
+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;
}
}
+382 -31
View File
@@ -1,11 +1,12 @@
import 'dart:convert';
import 'dart:io';
import 'dart:typed_data';
import 'package:flutter/foundation.dart';
import 'package:cbor/cbor.dart';
import 'package:cdk_flutter/cdk_flutter.dart';
import 'package:http/http.dart' as http;
import 'package:path_provider/path_provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../data/transaction_meta_storage.dart';
/// Helper class para info de token parseado
class TokenInfo {
@@ -29,6 +30,9 @@ class WalletProvider extends ChangeNotifier {
WalletDatabase? _db;
String? _mnemonic;
/// Storage para metadata de transacciones (tipo, token, invoice)
final TransactionMetaStorage _txMetaStorage = TransactionMetaStorage();
/// Mints conocidos con sus unidades soportadas.
/// Ejemplo: {'mint.cubabitcoin.org': ['sat', 'usd']}
final Map<String, List<String>> _mintUnits = {};
@@ -41,12 +45,23 @@ class WalletProvider extends ChangeNotifier {
/// Ejemplo: {'00abc123': 'sat', '00def456': 'usd'}
final Map<String, String> _keysetUnits = {};
/// Caché de MintInfo por URL (nombre, logo, contactos, etc.)
final Map<String, MintInfo> _mintInfoCache = {};
/// Mint activo actualmente
String? _activeMintUrl;
/// Unidad activa actualmente
String _activeUnit = 'sat';
/// Claves para persistencia en SharedPreferences
static const _mintsKey = 'wallet_mints';
static const _activeMintKey = 'wallet_active_mint';
static const _activeUnitKey = 'wallet_active_unit';
/// Mint de Cuba Bitcoin - siempre aparece primero en la lista
static const cubaBitcoinMint = 'https://mint.cubabitcoin.org';
// ============================================================
// GETTERS
// ============================================================
@@ -68,18 +83,198 @@ class WalletProvider extends ChangeNotifier {
/// Todos los wallets creados (para verificación de proofs)
Iterable<Wallet> get allWallets => _wallets.values;
/// Lista de URLs de mints conocidos
/// Lista de URLs de mints conocidos (orden de inserción)
List<String> get mintUrls => _mintUnits.keys.toList();
/// Lista de mints ordenados por balance.
/// Cuba Bitcoin siempre primero, luego ordenados por: sats → usd → eur → otros.
Future<List<String>> getSortedMintUrls() async {
final mints = _mintUnits.keys.toList();
if (mints.length <= 1) return mints;
// Obtener balances de cada mint
final mintBalances = <String, Map<String, BigInt>>{};
for (final mint in mints) {
mintBalances[mint] = await getBalancesForMint(mint);
}
// Orden de prioridad de unidades
const unitPriority = ['sat', 'usd', 'eur'];
// Ordenar mints (excepto Cuba Bitcoin)
final otherMints = mints.where((m) => m != cubaBitcoinMint).toList();
otherMints.sort((a, b) {
final balancesA = mintBalances[a] ?? {};
final balancesB = mintBalances[b] ?? {};
// Comparar por cada unidad en orden de prioridad
for (final unit in unitPriority) {
final balA = balancesA[unit] ?? BigInt.zero;
final balB = balancesB[unit] ?? BigInt.zero;
if (balA != balB) {
return balB.compareTo(balA); // Mayor primero
}
}
// Si empatan en las prioritarias, comparar otras unidades alfabéticamente
final otherUnitsA = balancesA.keys.where((u) => !unitPriority.contains(u)).toList()..sort();
final otherUnitsB = balancesB.keys.where((u) => !unitPriority.contains(u)).toList()..sort();
for (final unit in {...otherUnitsA, ...otherUnitsB}) {
final balA = balancesA[unit] ?? BigInt.zero;
final balB = balancesB[unit] ?? BigInt.zero;
if (balA != balB) {
return balB.compareTo(balA);
}
}
return 0;
});
// Cuba Bitcoin siempre primero
final result = <String>[];
if (mints.contains(cubaBitcoinMint)) {
result.add(cubaBitcoinMint);
}
result.addAll(otherMints);
return result;
}
// ============================================================
// MINT INFO
// ============================================================
/// Obtiene la info de un mint (nombre, logo, contactos, etc.)
/// Usa caché para evitar llamadas repetidas.
Future<MintInfo?> fetchMintInfo(String mintUrl) async {
// Revisar caché primero
if (_mintInfoCache.containsKey(mintUrl)) {
return _mintInfoCache[mintUrl];
}
try {
final info = await getMintInfo(mintUrl: mintUrl);
_mintInfoCache[mintUrl] = info;
return info;
} catch (e) {
debugPrint('Error fetching mint info for $mintUrl: $e');
return null;
}
}
/// Obtiene MintInfo del caché (sin fetch)
MintInfo? getCachedMintInfo(String mintUrl) {
return _mintInfoCache[mintUrl];
}
/// Obtiene el nombre del mint (del caché o display name)
String getMintName(String mintUrl) {
final cached = _mintInfoCache[mintUrl];
if (cached?.name != null && cached!.name!.isNotEmpty) {
return cached.name!;
}
// Fallback: extraer nombre del URL
return _getMintDisplayName(mintUrl);
}
/// Obtiene el URL del icono del mint (del caché)
String? getMintIconUrl(String mintUrl) {
return _mintInfoCache[mintUrl]?.iconUrl;
}
/// Helper: extrae nombre legible del URL del mint
String _getMintDisplayName(String mintUrl) {
try {
final uri = Uri.parse(mintUrl);
var host = uri.host;
host = host.replaceFirst('mint.', '');
host = host.replaceFirst('www.', '');
return host;
} catch (e) {
return mintUrl;
}
}
// ============================================================
// PERSISTENCIA DE MINTS
// ============================================================
/// Guarda la lista de mints y sus unidades en SharedPreferences.
Future<void> _saveMints() async {
final prefs = await SharedPreferences.getInstance();
// Guardar mints como JSON: {"url": ["sat", "usd"], ...}
final mintsJson = jsonEncode(_mintUnits);
await prefs.setString(_mintsKey, mintsJson);
// Guardar mint y unidad activos
if (_activeMintUrl != null) {
await prefs.setString(_activeMintKey, _activeMintUrl!);
}
await prefs.setString(_activeUnitKey, _activeUnit);
debugPrint('Mints guardados: ${_mintUnits.keys.length}');
}
/// Carga la lista de mints desde SharedPreferences.
/// Retorna true si había mints guardados.
Future<bool> _loadMints() async {
final prefs = await SharedPreferences.getInstance();
final mintsJson = prefs.getString(_mintsKey);
if (mintsJson == null || mintsJson.isEmpty) {
return false;
}
try {
final decoded = jsonDecode(mintsJson) as Map<String, dynamic>;
_mintUnits.clear();
for (final entry in decoded.entries) {
final units = (entry.value as List<dynamic>).cast<String>();
_mintUnits[entry.key] = units;
}
// Cargar mint y unidad activos
_activeMintUrl = prefs.getString(_activeMintKey);
_activeUnit = prefs.getString(_activeUnitKey) ?? 'sat';
// Verificar que el mint activo existe
if (_activeMintUrl != null && !_mintUnits.containsKey(_activeMintUrl)) {
_activeMintUrl = _mintUnits.keys.firstOrNull;
}
// Verificar que la unidad activa es válida para el mint
if (_activeMintUrl != null) {
final units = _mintUnits[_activeMintUrl]!;
if (!units.contains(_activeUnit)) {
_activeUnit = units.first;
}
}
debugPrint('Mints cargados: ${_mintUnits.keys.length}');
return _mintUnits.isNotEmpty;
} catch (e) {
debugPrint('Error cargando mints: $e');
return false;
}
}
// ============================================================
// INICIALIZACION
// ============================================================
/// Inicializa el provider con un mnemonic.
/// Carga mints y unidades desde la información guardada.
/// Carga mints y unidades desde SharedPreferences.
Future<void> initialize(String mnemonic) async {
_mnemonic = mnemonic;
// Inicializar storage de metadata de transacciones
await _txMetaStorage.init();
// Obtener directorio de documentos (path absoluto requerido)
final dir = await getApplicationDocumentsDirectory();
final dbPath = '${dir.path}/elcaju_wallet.sqlite';
@@ -87,35 +282,23 @@ class WalletProvider extends ChangeNotifier {
// Crear base de datos (se crea automáticamente si no existe)
_db = await WalletDatabase.newInstance(path: dbPath);
// Verificar si es primera vez
// Creamos un wallet temporal para ver si hay mints guardados
final tempWallet = Wallet(
mintUrl: 'https://mint.cubabitcoin.org',
unit: 'sat',
mnemonic: mnemonic,
db: _db!,
);
// Intentar cargar mints desde SharedPreferences
final hasSavedMints = await _loadMints();
// Intentar obtener balance - si tiene datos, ya fue inicializado antes
try {
final balance = await tempWallet.balance();
// Si llegamos aquí, el mint existe en la DB
// Agregar mint por defecto con sus unidades
await _detectAndAddMint('https://mint.cubabitcoin.org');
// Guardar el wallet
_wallets['https://mint.cubabitcoin.org:sat'] = tempWallet;
// Establecer como activo
_activeMintUrl = 'https://mint.cubabitcoin.org';
_activeUnit = 'sat';
// Si tiene balance > 0, definitivamente existía
if (balance > BigInt.zero) {
debugPrint('Wallet restaurado con balance: $balance');
if (hasSavedMints) {
// Cargar keysets para cada mint guardado
for (final mintUrl in _mintUnits.keys) {
try {
await _fetchKeysets(mintUrl);
// Crear wallet para la primera unidad de cada mint
final units = _mintUnits[mintUrl]!;
await getWallet(mintUrl, units.first);
} catch (e) {
debugPrint('Error cargando mint $mintUrl: $e');
}
}
} catch (e) {
debugPrint('Mints restaurados: ${_mintUnits.keys.length}');
} else {
// Primera vez - agregar mint por defecto
await addMint('https://mint.cubabitcoin.org');
}
@@ -260,6 +443,9 @@ class WalletProvider extends ChangeNotifier {
_activeUnit = units.first;
}
// Persistir cambios
await _saveMints();
notifyListeners();
}
@@ -307,6 +493,9 @@ class WalletProvider extends ChangeNotifier {
}
}
// Persistir cambios
await _saveMints();
notifyListeners();
}
@@ -331,6 +520,9 @@ class WalletProvider extends ChangeNotifier {
// Asegurar que el wallet existe
await getWallet(mintUrl, _activeUnit);
// Persistir cambio
await _saveMints();
notifyListeners();
}
@@ -348,6 +540,9 @@ class WalletProvider extends ChangeNotifier {
// Asegurar que el wallet existe
await getWallet(_activeMintUrl!, unit);
// Persistir cambio
await _saveMints();
notifyListeners();
}
@@ -590,6 +785,7 @@ class WalletProvider extends ChangeNotifier {
}
/// Recibe un token con una unidad específica.
/// Guarda metadata type=cashu para identificar en historial.
Future<BigInt> _receiveWithUnit(
String encodedToken,
String mintUrl,
@@ -599,11 +795,38 @@ class WalletProvider extends ChangeNotifier {
final token = Token.parse(encoded: encodedToken);
final amount = await wallet.receive(token: token);
// Guardar metadata para la transacción recién creada
await _saveMetaForRecentReceive(wallet, encodedToken);
debugPrint('Token recibido: $amount $unit en $mintUrl');
notifyListeners();
return amount;
}
/// Guarda metadata para la transacción de receive más reciente.
Future<void> _saveMetaForRecentReceive(Wallet wallet, String tokenEncoded) async {
try {
final txs = await wallet.listTransactions(
direction: TransactionDirection.incoming,
);
if (txs.isNotEmpty) {
final recentTx = txs.first;
await _txMetaStorage.save(
recentTx.id,
TransactionMeta(
type: TransactionType.cashu,
token: tokenEncoded,
),
);
debugPrint('Receive metadata guardada para tx ${recentTx.id}');
}
} catch (e) {
debugPrint('Error guardando receive metadata: $e');
}
}
/// Reclama un token P2PK (bloqueado a una clave pública).
Future<BigInt> receiveP2pkToken(
String encodedToken,
@@ -659,6 +882,7 @@ class WalletProvider extends ChangeNotifier {
}
/// Confirma un envío preparado y retorna el token encoded.
/// Guarda metadata type=cashu para identificar en historial.
Future<String> confirmSend(PreparedSend prepared, String? memo) async {
final wallet = await getActiveWallet();
@@ -668,10 +892,41 @@ class WalletProvider extends ChangeNotifier {
includeMemo: memo != null && memo.isNotEmpty,
);
// Guardar token en storage local para mostrar en detalles del historial.
// Usamos hash del token como key temporal; después buscaremos la transacción.
await _saveTokenForRecentTransaction(token.encoded);
notifyListeners();
return token.encoded;
}
/// Guarda el token para la transacción más reciente de tipo send.
Future<void> _saveTokenForRecentTransaction(String tokenEncoded) async {
try {
// Obtener transacciones outgoing más recientes
final wallet = await getActiveWallet();
final txs = await wallet.listTransactions(
direction: TransactionDirection.outgoing,
);
if (txs.isNotEmpty) {
// La más reciente debería ser la que acabamos de crear
final recentTx = txs.first;
await _txMetaStorage.save(
recentTx.id,
TransactionMeta(
type: TransactionType.cashu,
token: tokenEncoded,
),
);
debugPrint('Token guardado para tx ${recentTx.id}');
}
} catch (e) {
debugPrint('Error guardando token metadata: $e');
}
}
/// Cancela un envío preparado (libera proofs reservados).
Future<void> cancelSend(PreparedSend prepared) async {
final wallet = await getActiveWallet();
@@ -700,36 +955,113 @@ class WalletProvider extends ChangeNotifier {
/// Inicia un depósito via Lightning.
/// Retorna Stream con estados: unpaid -> paid -> issued.
/// Guarda metadata type=lightning cuando se completa.
Stream<MintQuote> mintTokens(BigInt amount, String? description) {
final wallet = activeWallet;
if (wallet == null) {
throw Exception('No hay wallet activo');
}
String? invoiceBolt11;
// Wrapper del stream para capturar el invoice y guardar metadata
return wallet.mint(
amount: amount,
description: description,
);
).map((quote) {
// Capturar el invoice cuando está en estado unpaid
if (quote.state == MintQuoteState.unpaid) {
invoiceBolt11 = quote.request;
}
// Cuando se completa, guardar metadata
if (quote.state == MintQuoteState.issued && invoiceBolt11 != null) {
_saveMintMetadata(wallet, invoiceBolt11!);
}
return quote;
});
}
/// Guarda metadata para una transacción de mint (Lightning deposit).
Future<void> _saveMintMetadata(Wallet wallet, String invoice) async {
try {
final txs = await wallet.listTransactions(
direction: TransactionDirection.incoming,
);
if (txs.isNotEmpty) {
final recentTx = txs.first;
await _txMetaStorage.save(
recentTx.id,
TransactionMeta(
type: TransactionType.lightning,
invoice: invoice,
),
);
debugPrint('Mint metadata guardada para tx ${recentTx.id}');
}
} catch (e) {
debugPrint('Error guardando mint metadata: $e');
}
}
// ============================================================
// MELT (Retirar a Lightning)
// ============================================================
/// Invoice temporal para guardar metadata después de melt
String? _pendingMeltInvoice;
/// Obtiene quote para pagar un invoice BOLT11.
/// Guarda el invoice temporalmente para asociarlo a la transacción después.
Future<MeltQuote> getMeltQuote(String bolt11Invoice) async {
_pendingMeltInvoice = bolt11Invoice;
final wallet = await getActiveWallet();
return await wallet.meltQuote(request: bolt11Invoice);
}
/// Ejecuta el pago del invoice.
/// Guarda metadata type=lightning para identificar en historial.
Future<BigInt> melt(MeltQuote quote) async {
final wallet = await getActiveWallet();
final totalPaid = await wallet.melt(quote: quote);
// Guardar metadata con el invoice
if (_pendingMeltInvoice != null) {
await _saveMeltMetadata(wallet, _pendingMeltInvoice!);
_pendingMeltInvoice = null;
}
notifyListeners();
return totalPaid;
}
/// Guarda metadata para una transacción de melt (Lightning withdrawal).
Future<void> _saveMeltMetadata(Wallet wallet, String invoice) async {
try {
final txs = await wallet.listTransactions(
direction: TransactionDirection.outgoing,
);
if (txs.isNotEmpty) {
final recentTx = txs.first;
await _txMetaStorage.save(
recentTx.id,
TransactionMeta(
type: TransactionType.lightning,
invoice: invoice,
),
);
debugPrint('Melt metadata guardada para tx ${recentTx.id}');
}
} catch (e) {
debugPrint('Error guardando melt metadata: $e');
}
}
// ============================================================
// HISTORIAL
// ============================================================
@@ -764,6 +1096,22 @@ class WalletProvider extends ChangeNotifier {
return allTransactions;
}
/// Obtiene el tipo de una transacción (cashu o lightning).
/// Busca primero en metadata del CDK, luego en storage local.
TransactionType getTransactionType(Transaction tx) {
return _txMetaStorage.getType(tx.id, tx.metadata);
}
/// Obtiene metadata adicional de una transacción.
TransactionMeta? getTransactionMeta(String transactionId) {
return _txMetaStorage.get(transactionId);
}
/// Verifica si una transacción tiene metadata guardada.
bool hasTransactionMeta(String transactionId) {
return _txMetaStorage.has(transactionId);
}
// ============================================================
// VERIFICACION DE PROOFS
// ============================================================
@@ -930,6 +1278,9 @@ class WalletProvider extends ChangeNotifier {
_mnemonic = null;
_db = null;
// Limpiar metadata de transacciones
await _txMetaStorage.clear();
// Borrar archivo
final dir = await getApplicationDocumentsDirectory();
final dbPath = '${dir.path}/elcaju_wallet.sqlite';
+234 -476
View File
@@ -1,12 +1,12 @@
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 '../../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';
@@ -16,6 +16,7 @@ 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
@@ -135,6 +136,9 @@ class _HomeScreenState extends State<HomeScreen> {
final walletProvider = context.watch<WalletProvider>();
final settingsProvider = context.read<SettingsProvider>();
final activeUnit = walletProvider.activeUnit;
// Toggle button: "BTC" para sat (como cashu.me)
final toggleLabel = UnitFormatter.getToggleLabel(activeUnit);
// Balance label: "sat" minúsculas (como cashu.me)
final unitLabel = UnitFormatter.getUnitLabel(activeUnit);
return Padding(
@@ -145,85 +149,59 @@ class _HomeScreenState extends State<HomeScreen> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
// Ojo encima del saldo (centrado)
Center(
child: GestureDetector(
onTap: () {
setState(() {
_isBalanceVisible = !_isBalanceVisible;
});
},
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Icon(
_isBalanceVisible ? LucideIcons.eye : LucideIcons.eyeOff,
color: AppColors.textSecondary.withValues(alpha: 0.6),
size: 24,
),
),
),
// Botón toggle de unidad con efecto de hundirse
_UnitToggleButton(
label: toggleLabel,
onTap: () async {
await walletProvider.cycleUnit();
await settingsProvider.setActiveUnit(walletProvider.activeUnit);
},
),
const SizedBox(height: 8),
// Balance reactivo del mint activo
const SizedBox(height: 32),
// Balance tocable para ocultar/mostrar (sin ojito)
StreamBuilder<BigInt>(
stream: walletProvider.streamBalance(),
builder: (context, snapshot) {
final balance = snapshot.data ?? BigInt.zero;
final formattedBalance = UnitFormatter.formatBalance(balance, activeUnit);
return Text(
_isBalanceVisible ? formattedBalance : '••••••',
style: const TextStyle(
fontFamily: 'Inter',
fontSize: 48,
fontWeight: FontWeight.bold,
color: Colors.white,
return GestureDetector(
onTap: () {
setState(() {
_isBalanceVisible = !_isBalanceVisible;
});
},
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.baseline,
textBaseline: TextBaseline.alphabetic,
children: [
Text(
_isBalanceVisible ? formattedBalance : '••••••',
style: const TextStyle(
fontFamily: 'Inter',
fontSize: 48,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
const SizedBox(width: 8),
Text(
_isBalanceVisible ? unitLabel : '',
style: TextStyle(
fontFamily: 'Inter',
fontSize: 32,
fontWeight: FontWeight.w400,
color: Colors.white.withValues(alpha: 0.7),
),
),
],
),
);
},
),
const SizedBox(height: 4),
// Unidad - tap para ciclar
GestureDetector(
onTap: () async {
// Ciclar unidad
await walletProvider.cycleUnit();
// Guardar en settings
await settingsProvider.setActiveUnit(walletProvider.activeUnit);
},
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
decoration: BoxDecoration(
color: AppColors.primaryAction.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(12),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
unitLabel,
style: const TextStyle(
fontFamily: 'Inter',
fontSize: 18,
fontWeight: FontWeight.w600,
color: AppColors.primaryAction,
),
),
// Mostrar indicador si hay más de una unidad
if (walletProvider.activeUnits.length > 1) ...[
const SizedBox(width: 4),
Icon(
LucideIcons.refreshCw,
color: AppColors.primaryAction.withValues(alpha: 0.7),
size: 14,
),
],
],
),
),
),
],
),
);
@@ -247,47 +225,87 @@ 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,
),
],
),
],
),
),
);
@@ -298,15 +316,20 @@ class _HomeScreenState extends State<HomeScreen> {
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: 'Enviar ↗',
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(
child: AnimatedActionButton(
label: '↘ Recibir',
type: ButtonType.primaryAction,
onTap: _showReceiveOptions,
),
),
@@ -390,373 +413,20 @@ class _HomeScreenState extends State<HomeScreen> {
Widget _buildHistoryButton() {
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,
),
),
],
),
),
child: AnimatedActionButton(
label: 'Historial',
type: ButtonType.navigation,
icon: LucideIcons.history,
showIcon: true,
onTap: () {
Navigator.push(
context,
MaterialPageRoute(builder: (context) => const HistoryScreen()),
);
},
),
);
}
void _showHistoryModal() {
showModalBottomSheet(
context: context,
backgroundColor: Colors.transparent,
isScrollControlled: true,
builder: (context) => const _HistoryModal(),
);
}
}
/// Botón de acción para el home
class _ActionButton extends StatelessWidget {
final String label;
final VoidCallback onTap;
const _ActionButton({required this.label, required this.onTap});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
child: Container(
padding: const EdgeInsets.symmetric(
vertical: AppDimensions.paddingMedium,
horizontal: AppDimensions.paddingSmall,
),
decoration: BoxDecoration(
gradient: const LinearGradient(
colors: AppColors.buttonGradient,
begin: Alignment.centerLeft,
end: Alignment.centerRight,
),
borderRadius: BorderRadius.circular(AppDimensions.cardBorderRadius),
boxShadow: [
BoxShadow(
color: AppColors.primaryAction.withValues(alpha: 0.4),
blurRadius: 12,
offset: const Offset(0, 6),
),
],
),
child: Center(
child: Text(
label,
style: const TextStyle(
fontFamily: 'Inter',
fontSize: 18,
fontWeight: FontWeight.w600,
color: Colors.white,
),
),
),
),
);
}
}
/// Modal del historial de transacciones
class _HistoryModal extends StatelessWidget {
const _HistoryModal();
@override
Widget build(BuildContext context) {
final walletProvider = context.watch<WalletProvider>();
return Container(
height: MediaQuery.of(context).size.height * 0.7,
decoration: BoxDecoration(
color: AppColors.deepVoidPurple,
borderRadius: const BorderRadius.vertical(top: Radius.circular(24)),
border: Border.all(
color: Colors.white.withValues(alpha: 0.1),
width: 1,
),
),
child: Column(
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),
),
),
// 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),
),
),
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,
),
),
if (fee > 0)
Text(
'fee: $fee',
style: TextStyle(
fontFamily: 'Inter',
fontSize: 10,
color: AppColors.textSecondary.withValues(alpha: 0.5),
),
),
],
),
],
),
);
}
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
@@ -910,3 +580,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,
),
),
),
);
},
),
);
}
}
+350
View File
@@ -0,0 +1,350 @@
import 'package:flutter/material.dart';
import 'package:lucide_icons/lucide_icons.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 = 'Error cargando proofs: $e';
_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: const Text(
'Envio Offline',
style: 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(
'Selecciona las notas que deseas enviar:',
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 ? 'Creando...' : 'Crear token',
onPressed: _selectedIds.isNotEmpty && !_isCreating
? _createOfflineToken
: null,
),
],
),
);
}
Widget _buildTotalDisplay() {
return GlassCard(
padding: const EdgeInsets.all(AppDimensions.paddingMedium),
child: Column(
children: [
Text(
'Total a enviar',
style: TextStyle(
fontFamily: 'Inter',
fontSize: 14,
color: AppColors.textSecondary,
),
),
const SizedBox(height: 8),
ProofTotalDisplay(
total: _selectedTotal,
unit: widget.unit,
),
const SizedBox(height: 8),
Text(
'${_selectedIds.length} notas seleccionadas',
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: 'Memo (opcional)',
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) {
setState(() {
_errorMessage = 'Error creando token: $e';
_isCreating = false;
});
}
}
}
+134 -2
View File
@@ -1,4 +1,5 @@
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 '../../core/constants/colors.dart';
@@ -9,6 +10,7 @@ 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 {
@@ -79,6 +81,14 @@ class _SendScreenState extends State<SendScreen> {
color: Colors.white,
),
),
actions: [
// Botón para modo offline (selección manual de proofs)
IconButton(
icon: const Icon(LucideIcons.coins, color: AppColors.primaryAction),
tooltip: 'Seleccionar notas manualmente',
onPressed: _goToOfflineMode,
),
],
),
body: SafeArea(
child: Padding(
@@ -313,7 +323,60 @@ class _SendScreenState extends State<SendScreen> {
});
}
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 = 'No hay mint activo';
});
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 = 'No hay mint activo';
});
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,
@@ -330,6 +393,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;
@@ -358,8 +433,20 @@ class _SendScreenState extends State<SendScreen> {
);
}
} 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;
}
setState(() {
final errorStr = e.toString().toLowerCase();
if (errorStr.contains('insufficient') || errorStr.contains('not enough')) {
_errorMessage = 'Balance insuficiente';
} else {
@@ -374,6 +461,51 @@ 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 = 'No hay mint activo';
});
return;
}
// Mostrar snackbar informativo
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Sin conexion. Usando modo offline...'),
backgroundColor: AppColors.primaryAction,
duration: Duration(seconds: 2),
),
);
// Navegar a modo offline
Navigator.pushReplacement(
context,
MaterialPageRoute(
builder: (context) => OfflineSendScreen(
mintUrl: mintUrl,
unit: _activeUnit,
),
),
);
}
}
/// Modal de confirmacion
@@ -0,0 +1,557 @@
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 '../../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: const Text(
'Mint activo',
style: 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: [
const Text(
'Mint Message',
style: 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) {
// 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}'
: 'Unknown';
return Column(
children: [
_buildDetailRow(
icon: LucideIcons.link,
label: 'URL',
value: widget.mintUrl,
canCopy: true,
),
_buildDetailRow(
icon: LucideIcons.coins,
label: 'Currency',
value: currencyDisplay,
),
_buildDetailRow(
icon: LucideIcons.box,
label: '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() {
return Column(
children: [
// Usar este mint (si no es el activo)
if (!widget.isActive && widget.onSetActive != null)
_buildActionButton(
icon: LucideIcons.star,
label: 'Usar este mint',
onTap: () {
widget.onSetActive!();
Navigator.pop(context);
},
),
// Copiar URL
_buildActionButton(
icon: LucideIcons.copy,
label: 'Copiar URL del mint',
onTap: () => _copyToClipboard(widget.mintUrl, 'URL'),
),
// Eliminar mint
if (widget.onDelete != null)
_buildActionButton(
icon: LucideIcons.trash2,
label: 'Eliminar mint',
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('$label copiado'),
backgroundColor: AppColors.success,
duration: const Duration(seconds: 2),
),
);
}
void _showDeleteConfirmation() {
showDialog(
context: context,
builder: (context) => AlertDialog(
backgroundColor: AppColors.deepVoidPurple,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
title: const Text(
'Eliminar mint',
style: TextStyle(
fontFamily: 'Inter',
fontWeight: FontWeight.w600,
color: Colors.white,
),
),
content: Text(
'Si tienes balance en este mint, se perderá. ¿Estás seguro?',
style: TextStyle(
fontFamily: 'Inter',
color: Colors.white.withValues(alpha: 0.8),
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text(
'Cancelar',
style: TextStyle(color: AppColors.textSecondary),
),
),
TextButton(
onPressed: () {
Navigator.pop(context); // Cerrar diálogo
Navigator.pop(context); // Volver a lista
widget.onDelete!();
},
child: const Text(
'Eliminar',
style: TextStyle(color: AppColors.error),
),
),
],
),
);
}
}
+225 -487
View File
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:lucide_icons/lucide_icons.dart';
import 'package:provider/provider.dart';
import 'package:cdk_flutter/cdk_flutter.dart' show MintInfo;
import '../../core/constants/colors.dart';
import '../../core/constants/dimensions.dart';
import '../../core/utils/formatters.dart';
@@ -8,6 +9,7 @@ import '../../providers/wallet_provider.dart';
import '../../widgets/common/gradient_background.dart';
import '../../widgets/common/glass_card.dart';
import '../../widgets/common/primary_button.dart';
import 'mint_detail_screen.dart';
/// Pantalla para gestionar mints conectados
class MintsScreen extends StatefulWidget {
@@ -42,32 +44,47 @@ class _MintsScreenState extends State<MintsScreen> {
body: SafeArea(
child: Consumer<WalletProvider>(
builder: (context, walletProvider, child) {
final mintUrls = walletProvider.mintUrls;
return Padding(
padding: const EdgeInsets.all(AppDimensions.paddingMedium),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Lista de mints
// Lista de mints ordenados por balance
Expanded(
child: mintUrls.isEmpty
? _buildEmptyState()
: ListView.builder(
itemCount: mintUrls.length,
itemBuilder: (context, index) {
final mintUrl = mintUrls[index];
return Padding(
padding: const EdgeInsets.only(
bottom: AppDimensions.paddingMedium,
),
child: _buildMintCard(
mintUrl,
walletProvider,
),
);
},
),
child: FutureBuilder<List<String>>(
future: walletProvider.getSortedMintUrls(),
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(
child: CircularProgressIndicator(
color: AppColors.primaryAction,
),
);
}
final mintUrls = snapshot.data ?? walletProvider.mintUrls;
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,
),
child: _buildMintCard(
mintUrl,
walletProvider,
),
);
},
);
},
),
),
// Botón agregar mint
@@ -119,274 +136,234 @@ class _MintsScreenState extends State<MintsScreen> {
Widget _buildMintCard(String mintUrl, WalletProvider walletProvider) {
final isActive = walletProvider.activeMintUrl == mintUrl;
final units = walletProvider.getUnitsForMint(mintUrl);
final isCubaBitcoin = mintUrl == WalletProvider.cubaBitcoinMint;
return FutureBuilder<Map<String, BigInt>>(
future: walletProvider.getBalancesForMint(mintUrl),
builder: (context, balanceSnapshot) {
final balances = balanceSnapshot.data ?? {};
return GlassCard(
padding: const EdgeInsets.all(AppDimensions.paddingMedium),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header: URL + estado + refresh
Row(
children: [
// Icono estado conexión (verde si es el activo)
Container(
width: 8,
height: 8,
decoration: BoxDecoration(
color: isActive ? AppColors.success : AppColors.textSecondary,
shape: BoxShape.circle,
),
),
const SizedBox(width: 8),
return FutureBuilder<MintInfo?>(
future: walletProvider.fetchMintInfo(mintUrl),
builder: (context, infoSnapshot) {
final mintInfo = infoSnapshot.data;
final mintName = mintInfo?.name ?? UnitFormatter.getMintDisplayName(mintUrl);
final iconUrl = mintInfo?.iconUrl;
// URL del mint (display name)
Expanded(
child: Text(
UnitFormatter.getMintDisplayName(mintUrl),
style: const TextStyle(
fontFamily: 'Inter',
fontSize: 16,
fontWeight: FontWeight.w600,
color: Colors.white,
),
overflow: TextOverflow.ellipsis,
),
),
// Botón refresh
GestureDetector(
onTap: () => _refreshMint(mintUrl, walletProvider),
child: Padding(
padding: const EdgeInsets.all(4),
child: Icon(
LucideIcons.refreshCw,
color: AppColors.textSecondary,
size: 18,
),
),
),
const SizedBox(width: 8),
// Badge activo
if (isActive)
return GestureDetector(
onTap: () => _openMintDetails(
mintUrl,
walletProvider,
mintInfo,
balances,
isActive,
isCubaBitcoin,
),
child: GlassCard(
padding: const EdgeInsets.all(AppDimensions.paddingMedium),
child: Row(
children: [
// Logo del mint
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
width: 48,
height: 48,
decoration: BoxDecoration(
color: AppColors.primaryAction.withValues(alpha: 0.2),
color: AppColors.primaryAction.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(12),
),
child: Text(
'Activo',
style: TextStyle(
fontFamily: 'Inter',
fontSize: 12,
fontWeight: FontWeight.w600,
color: AppColors.primaryAction,
),
),
),
],
),
const SizedBox(height: 4),
// URL completa (más pequeña)
Text(
mintUrl,
style: TextStyle(
fontFamily: 'Inter',
fontSize: 12,
color: AppColors.textSecondary.withValues(alpha: 0.6),
),
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: AppDimensions.paddingSmall),
// Balances por unidad
if (balanceSnapshot.connectionState == ConnectionState.waiting)
Row(
children: [
Text(
'Balance:',
style: TextStyle(
fontFamily: 'Inter',
fontSize: 14,
color: AppColors.textSecondary.withValues(alpha: 0.7),
),
),
const SizedBox(width: 8),
SizedBox(
width: 14,
height: 14,
child: CircularProgressIndicator(
strokeWidth: 2,
color: AppColors.textSecondary,
),
),
],
)
else
_buildBalancesList(units, balances),
const SizedBox(height: AppDimensions.paddingMedium),
// Acciones
Row(
children: [
// Hacer activo (si no lo es)
if (!isActive)
Expanded(
child: GestureDetector(
onTap: () => _setActiveMint(mintUrl, walletProvider),
child: Container(
padding: const EdgeInsets.symmetric(vertical: 10),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(10),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
LucideIcons.star,
color: AppColors.textSecondary,
size: 16,
),
const SizedBox(width: 6),
Text(
'Usar este mint',
style: TextStyle(
fontFamily: 'Inter',
fontSize: 14,
fontWeight: FontWeight.w500,
color: AppColors.textSecondary,
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(mintUrl, balances, walletProvider),
child: Container(
padding: const EdgeInsets.symmetric(vertical: 10),
decoration: BoxDecoration(
color: AppColors.error.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(10),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
LucideIcons.trash2,
color: AppColors.error,
size: 16,
// 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,
),
],
),
),
],
),
);
},
);
},
);
}
/// Construye la lista de balances por unidad.
Widget _buildBalancesList(List<String> units, Map<String, BigInt> balances) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Balances:',
/// Badges de balance compactos (estilo cashu.me)
Widget _buildBalanceBadges(List<String> units, Map<String, BigInt> balances) {
final nonZeroBalances = balances.entries
.where((e) => e.value > BigInt.zero)
.toList();
if (nonZeroBalances.isEmpty) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
),
child: Text(
'0 ${units.isNotEmpty ? units.first : "sat"}',
style: TextStyle(
fontFamily: 'Inter',
fontSize: 14,
color: AppColors.textSecondary.withValues(alpha: 0.7),
fontSize: 12,
color: Colors.white.withValues(alpha: 0.6),
),
),
const SizedBox(height: 4),
...units.map((unit) {
final balance = balances[unit] ?? BigInt.zero;
final formattedBalance = UnitFormatter.formatBalance(balance, unit);
final unitLabel = UnitFormatter.getUnitLabel(unit);
);
}
return Padding(
padding: const EdgeInsets.only(left: 8, top: 2),
child: Row(
children: [
Container(
width: 6,
height: 6,
decoration: BoxDecoration(
color: balance > BigInt.zero
? AppColors.success
: AppColors.textSecondary.withValues(alpha: 0.3),
shape: BoxShape.circle,
),
),
const SizedBox(width: 8),
Text(
'$formattedBalance $unitLabel',
style: TextStyle(
fontFamily: 'Inter',
fontSize: 14,
fontWeight: balance > BigInt.zero ? FontWeight.w600 : FontWeight.normal,
color: balance > BigInt.zero
? Colors.white
: AppColors.textSecondary.withValues(alpha: 0.7),
),
),
],
return Wrap(
spacing: 6,
runSpacing: 4,
children: nonZeroBalances.map((entry) {
final unit = entry.key;
final balance = entry.value;
final formatted = UnitFormatter.formatBalance(balance, unit);
final label = UnitFormatter.getUnitLabel(unit);
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: AppColors.primaryAction.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(8),
),
child: Text(
'$formatted $label',
style: const TextStyle(
fontFamily: 'Inter',
fontSize: 12,
fontWeight: FontWeight.w500,
color: AppColors.primaryAction,
),
);
}),
],
),
);
}).toList(),
);
}
/// Refresca la información del mint (detecta nuevas unidades).
Future<void> _refreshMint(String mintUrl, WalletProvider walletProvider) async {
try {
final units = await walletProvider.refreshMint(mintUrl);
/// 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 {
try {
await walletProvider.removeMint(mintUrl);
if (mounted) {
setState(() {}); // Rebuild para actualizar UI
setState(() {});
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Unidades detectadas: ${units.join(", ")}'),
const SnackBar(
content: Text('Mint eliminado'),
backgroundColor: AppColors.success,
duration: const Duration(seconds: 2),
),
);
}
@@ -396,7 +373,6 @@ class _MintsScreenState extends State<MintsScreen> {
SnackBar(
content: Text('Error: $e'),
backgroundColor: AppColors.error,
duration: const Duration(seconds: 3),
),
);
}
@@ -438,59 +414,6 @@ class _MintsScreenState extends State<MintsScreen> {
}
}
void _showDeleteDialog(String mintUrl, Map<String, BigInt> balances, WalletProvider walletProvider) {
// Verificar si hay balance en alguna unidad
final hasBalance = balances.values.any((b) => b > BigInt.zero);
// Calcular balance total para mostrar (simplificado)
final balanceStrings = balances.entries
.where((e) => e.value > BigInt.zero)
.map((e) => '${UnitFormatter.formatBalance(e.value, e.key)} ${UnitFormatter.getUnitLabel(e.key)}')
.toList();
showModalBottomSheet(
context: context,
backgroundColor: Colors.transparent,
builder: (context) => _DeleteMintModal(
mintUrl: mintUrl,
hasBalance: hasBalance,
balanceDescription: balanceStrings.isEmpty ? '' : balanceStrings.join(', '),
onConfirm: () async {
Navigator.pop(context);
await _deleteMint(mintUrl, walletProvider);
},
onCancel: () => Navigator.pop(context),
),
);
}
Future<void> _deleteMint(String mintUrl, WalletProvider walletProvider) async {
try {
await walletProvider.removeMint(mintUrl);
if (mounted) {
setState(() {}); // Rebuild para actualizar UI
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Mint eliminado'),
backgroundColor: AppColors.success,
duration: Duration(seconds: 2),
),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Error: $e'),
backgroundColor: AppColors.error,
duration: const Duration(seconds: 3),
),
);
}
}
}
void _showAddMintDialog(WalletProvider walletProvider) {
showDialog(
context: context,
@@ -505,191 +428,6 @@ class _MintsScreenState extends State<MintsScreen> {
}
}
/// Modal para confirmar eliminación de mint
class _DeleteMintModal extends StatelessWidget {
final String mintUrl;
final bool hasBalance;
final String balanceDescription;
final VoidCallback onConfirm;
final VoidCallback onCancel;
const _DeleteMintModal({
required this.mintUrl,
required this.hasBalance,
required this.balanceDescription,
required this.onConfirm,
required this.onCancel,
});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(AppDimensions.paddingMedium),
decoration: BoxDecoration(
color: AppColors.deepVoidPurple,
borderRadius: const BorderRadius.vertical(top: Radius.circular(24)),
border: Border.all(
color: Colors.white.withValues(alpha: 0.1),
width: 1,
),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Handle
Container(
margin: const EdgeInsets.only(bottom: 16),
width: 40,
height: 4,
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.3),
borderRadius: BorderRadius.circular(2),
),
),
// Icono advertencia
Container(
width: 64,
height: 64,
decoration: BoxDecoration(
color: AppColors.error.withValues(alpha: 0.2),
shape: BoxShape.circle,
),
child: Icon(
LucideIcons.alertTriangle,
color: AppColors.error,
size: 32,
),
),
const SizedBox(height: AppDimensions.paddingMedium),
// Título
const Text(
'¿Eliminar mint?',
style: TextStyle(
fontFamily: 'Inter',
fontSize: 20,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
const SizedBox(height: AppDimensions.paddingSmall),
// URL del mint
Padding(
padding: const EdgeInsets.symmetric(horizontal: 32),
child: Text(
mintUrl,
style: TextStyle(
fontFamily: 'Inter',
fontSize: 14,
color: AppColors.textSecondary.withValues(alpha: 0.7),
),
textAlign: TextAlign.center,
),
),
const SizedBox(height: AppDimensions.paddingMedium),
// Advertencia según balance
Container(
margin: const EdgeInsets.symmetric(horizontal: 32),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: hasBalance
? AppColors.secondaryAction.withValues(alpha: 0.2)
: Colors.white.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
hasBalance ? LucideIcons.alertCircle : LucideIcons.info,
color: hasBalance ? AppColors.secondaryAction : AppColors.textSecondary,
size: 16,
),
const SizedBox(width: 8),
Expanded(
child: Text(
hasBalance
? 'Este mint tiene $balanceDescription. Puedes recuperarlos después agregando el mint de nuevo.'
: 'Perderás acceso a este mint',
style: TextStyle(
fontFamily: 'Inter',
fontSize: 12,
color: hasBalance
? AppColors.secondaryAction
: AppColors.textSecondary.withValues(alpha: 0.7),
),
),
),
],
),
),
const SizedBox(height: AppDimensions.paddingLarge),
// Botones
Row(
children: [
Expanded(
child: GestureDetector(
onTap: onCancel,
child: Container(
padding: const EdgeInsets.symmetric(vertical: 16),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(16),
),
child: const Center(
child: Text(
'Cancelar',
style: TextStyle(
fontFamily: 'Inter',
fontSize: 16,
fontWeight: FontWeight.w600,
color: Colors.white,
),
),
),
),
),
),
const SizedBox(width: AppDimensions.paddingMedium),
Expanded(
child: GestureDetector(
onTap: onConfirm,
child: Container(
padding: const EdgeInsets.symmetric(vertical: 16),
decoration: BoxDecoration(
color: AppColors.error,
borderRadius: BorderRadius.circular(16),
),
child: const Center(
child: Text(
'Eliminar',
style: TextStyle(
fontFamily: 'Inter',
fontSize: 16,
fontWeight: FontWeight.w600,
color: Colors.white,
),
),
),
),
),
),
],
),
const SizedBox(height: AppDimensions.paddingSmall),
],
),
);
}
}
/// Diálogo para agregar nuevo mint
class _AddMintDialog extends StatefulWidget {
final WalletProvider walletProvider;
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"))
}
+176
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:
@@ -58,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:
@@ -98,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:
@@ -143,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:
@@ -156,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:
@@ -246,6 +302,14 @@ packages:
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:
@@ -270,6 +334,14 @@ packages:
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:
@@ -430,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:
@@ -446,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:
@@ -555,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:
@@ -579,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:
@@ -603,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:
@@ -715,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"
+23
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
@@ -51,6 +53,27 @@ 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