Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0a932bdd8e | ||
|
|
5934a1db8a | ||
|
|
9d643af39d | ||
|
|
a5dc4d6160 | ||
|
|
922fabc784 | ||
|
|
7540e7e413 | ||
|
|
9ee020ccb2 | ||
|
|
aeaebee37b | ||
|
|
52812c85c6 | ||
|
|
dac5693d5c | ||
|
|
0e6f06bf7f | ||
|
|
5b70ca3773 | ||
|
|
f0236df966 | ||
|
|
6b1cdabf14 | ||
|
|
48ab3d8c4d | ||
|
|
ac6af4b883 |
@@ -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,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
|
||||
|
||||
|
After Width: | Height: | Size: 188 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 188 KiB |
|
After Width: | Height: | Size: 97 KiB |
|
After Width: | Height: | Size: 8.4 KiB |
|
After Width: | Height: | Size: 97 KiB |
|
After Width: | Height: | Size: 188 KiB |
|
After Width: | Height: | Size: 97 KiB |
|
After Width: | Height: | Size: 300 KiB |
|
After Width: | Height: | Size: 536 KiB |
|
After Width: | Height: | Size: 786 KiB |
|
After Width: | Height: | Size: 69 B |
@@ -1,12 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Modify this file to customize your launch splash screen -->
|
||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:drawable="?android:colorBackground" />
|
||||
|
||||
<!-- You can insert your own image assets here -->
|
||||
<!-- <item>
|
||||
<bitmap
|
||||
android:gravity="center"
|
||||
android:src="@mipmap/launch_image" />
|
||||
</item> -->
|
||||
<item>
|
||||
<bitmap android:gravity="fill" android:src="@drawable/background"/>
|
||||
</item>
|
||||
<item>
|
||||
<bitmap android:gravity="center" android:src="@drawable/splash"/>
|
||||
</item>
|
||||
</layer-list>
|
||||
|
||||
|
After Width: | Height: | Size: 300 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 300 KiB |
|
After Width: | Height: | Size: 536 KiB |
|
After Width: | Height: | Size: 47 KiB |
|
After Width: | Height: | Size: 536 KiB |
|
After Width: | Height: | Size: 786 KiB |
|
After Width: | Height: | Size: 76 KiB |
|
After Width: | Height: | Size: 786 KiB |
|
After Width: | Height: | Size: 69 B |
@@ -1,12 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Modify this file to customize your launch splash screen -->
|
||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:drawable="@android:color/white" />
|
||||
|
||||
<!-- You can insert your own image assets here -->
|
||||
<!-- <item>
|
||||
<bitmap
|
||||
android:gravity="center"
|
||||
android:src="@mipmap/launch_image" />
|
||||
</item> -->
|
||||
<item>
|
||||
<bitmap android:gravity="fill" android:src="@drawable/background"/>
|
||||
</item>
|
||||
<item>
|
||||
<bitmap android:gravity="center" android:src="@drawable/splash"/>
|
||||
</item>
|
||||
</layer-list>
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_background"/>
|
||||
<foreground>
|
||||
<inset
|
||||
android:drawable="@drawable/ic_launcher_foreground"
|
||||
android:inset="16%" />
|
||||
</foreground>
|
||||
</adaptive-icon>
|
||||
|
Before Width: | Height: | Size: 544 B After Width: | Height: | Size: 4.7 KiB |
|
Before Width: | Height: | Size: 442 B After Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 721 B After Width: | Height: | Size: 7.1 KiB |
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 20 KiB |
@@ -0,0 +1,21 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
|
||||
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
||||
<item name="android:forceDarkAllowed">false</item>
|
||||
<item name="android:windowFullscreen">false</item>
|
||||
<item name="android:windowDrawsSystemBarBackgrounds">false</item>
|
||||
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
|
||||
<item name="android:windowSplashScreenBackground">#1A0B2E</item>
|
||||
<item name="android:windowSplashScreenAnimatedIcon">@drawable/android12splash</item>
|
||||
</style>
|
||||
<!-- Theme applied to the Android Window as soon as the process has started.
|
||||
This theme determines the color of the Android Window while your
|
||||
Flutter UI initializes, as well as behind your Flutter UI while its
|
||||
running.
|
||||
|
||||
This Theme is only used starting with V2 of Flutter's Android embedding. -->
|
||||
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
||||
<item name="android:windowBackground">?android:colorBackground</item>
|
||||
</style>
|
||||
</resources>
|
||||
@@ -5,6 +5,10 @@
|
||||
<!-- Show a splash screen on the activity. Automatically removed when
|
||||
the Flutter engine draws its first frame -->
|
||||
<item name="android:windowBackground">@drawable/launch_background</item>
|
||||
<item name="android:forceDarkAllowed">false</item>
|
||||
<item name="android:windowFullscreen">false</item>
|
||||
<item name="android:windowDrawsSystemBarBackgrounds">false</item>
|
||||
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
|
||||
</style>
|
||||
<!-- Theme applied to the Android Window as soon as the process has started.
|
||||
This theme determines the color of the Android Window while your
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
|
||||
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
|
||||
<item name="android:forceDarkAllowed">false</item>
|
||||
<item name="android:windowFullscreen">false</item>
|
||||
<item name="android:windowDrawsSystemBarBackgrounds">false</item>
|
||||
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
|
||||
<item name="android:windowSplashScreenBackground">#1A0B2E</item>
|
||||
<item name="android:windowSplashScreenAnimatedIcon">@drawable/android12splash</item>
|
||||
</style>
|
||||
<!-- Theme applied to the Android Window as soon as the process has started.
|
||||
This theme determines the color of the Android Window while your
|
||||
Flutter UI initializes, as well as behind your Flutter UI while its
|
||||
running.
|
||||
|
||||
This Theme is only used starting with V2 of Flutter's Android embedding. -->
|
||||
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
|
||||
<item name="android:windowBackground">?android:colorBackground</item>
|
||||
</style>
|
||||
</resources>
|
||||
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="ic_launcher_background">#1A0B2E</color>
|
||||
</resources>
|
||||
@@ -5,6 +5,10 @@
|
||||
<!-- Show a splash screen on the activity. Automatically removed when
|
||||
the Flutter engine draws its first frame -->
|
||||
<item name="android:windowBackground">@drawable/launch_background</item>
|
||||
<item name="android:forceDarkAllowed">false</item>
|
||||
<item name="android:windowFullscreen">false</item>
|
||||
<item name="android:windowDrawsSystemBarBackgrounds">false</item>
|
||||
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
|
||||
</style>
|
||||
<!-- Theme applied to the Android Window as soon as the process has started.
|
||||
This theme determines the color of the Android Window while your
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
/// Modelo de un proof Cashu local.
|
||||
/// Representa una "nota" de ecash con una denominación específica.
|
||||
class LocalProof {
|
||||
/// Primary key (Y point)
|
||||
final Uint8List y;
|
||||
|
||||
/// URL del mint que emitió este proof
|
||||
final String mintUrl;
|
||||
|
||||
/// Estado del proof
|
||||
final ProofState state;
|
||||
|
||||
/// Unidad (sat, usd, eur)
|
||||
final String unit;
|
||||
|
||||
/// Denominación (1, 2, 4, 8, 16, 32, 64, 128, 256, 512...)
|
||||
final BigInt amount;
|
||||
|
||||
/// ID del keyset
|
||||
final String keysetId;
|
||||
|
||||
/// Secret del proof
|
||||
final String secret;
|
||||
|
||||
/// Signature (C point)
|
||||
final Uint8List c;
|
||||
|
||||
/// Witness (opcional, para P2PK)
|
||||
final String? witness;
|
||||
|
||||
/// DLEQ proof components (opcional)
|
||||
final Uint8List? dleqE;
|
||||
final Uint8List? dleqS;
|
||||
final Uint8List? dleqR;
|
||||
|
||||
LocalProof({
|
||||
required this.y,
|
||||
required this.mintUrl,
|
||||
required this.state,
|
||||
required this.unit,
|
||||
required this.amount,
|
||||
required this.keysetId,
|
||||
required this.secret,
|
||||
required this.c,
|
||||
this.witness,
|
||||
this.dleqE,
|
||||
this.dleqS,
|
||||
this.dleqR,
|
||||
});
|
||||
|
||||
/// Crea un LocalProof desde un row de SQLite.
|
||||
factory LocalProof.fromSqlite(Map<String, dynamic> row) {
|
||||
return LocalProof(
|
||||
y: row['y'] as Uint8List,
|
||||
mintUrl: row['mint_url'] as String,
|
||||
state: ProofState.fromString(row['state'] as String),
|
||||
unit: row['unit'] as String,
|
||||
amount: BigInt.from(row['amount'] as int),
|
||||
keysetId: row['keyset_id'] as String,
|
||||
secret: row['secret'] as String,
|
||||
c: row['c'] as Uint8List,
|
||||
witness: row['witness'] as String?,
|
||||
dleqE: row['dleq_e'] as Uint8List?,
|
||||
dleqS: row['dleq_s'] as Uint8List?,
|
||||
dleqR: row['dleq_r'] as Uint8List?,
|
||||
);
|
||||
}
|
||||
|
||||
/// Convierte a formato JSON para token V3.
|
||||
Map<String, dynamic> toTokenProof() {
|
||||
final proof = <String, dynamic>{
|
||||
'id': keysetId,
|
||||
'amount': amount.toInt(),
|
||||
'secret': secret,
|
||||
'C': _bytesToHex(c),
|
||||
};
|
||||
|
||||
// Agregar DLEQ si existe
|
||||
if (dleqE != null && dleqS != null) {
|
||||
proof['dleq'] = {
|
||||
'e': _bytesToHex(dleqE!),
|
||||
's': _bytesToHex(dleqS!),
|
||||
if (dleqR != null) 'r': _bytesToHex(dleqR!),
|
||||
};
|
||||
}
|
||||
|
||||
// Agregar witness si existe
|
||||
if (witness != null && witness!.isNotEmpty) {
|
||||
proof['witness'] = witness;
|
||||
}
|
||||
|
||||
return proof;
|
||||
}
|
||||
|
||||
/// Convierte bytes a hex string.
|
||||
static String _bytesToHex(Uint8List bytes) {
|
||||
return bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join();
|
||||
}
|
||||
|
||||
/// Convierte Y a hex para identificación.
|
||||
String get yHex => _bytesToHex(y);
|
||||
|
||||
@override
|
||||
String toString() => 'LocalProof(amount: $amount, unit: $unit, state: $state)';
|
||||
}
|
||||
|
||||
/// Estados posibles de un proof.
|
||||
enum ProofState {
|
||||
unspent,
|
||||
spent,
|
||||
pending,
|
||||
reserved,
|
||||
pendingSpent;
|
||||
|
||||
static ProofState fromString(String s) {
|
||||
switch (s.toUpperCase()) {
|
||||
case 'UNSPENT':
|
||||
return ProofState.unspent;
|
||||
case 'SPENT':
|
||||
return ProofState.spent;
|
||||
case 'PENDING':
|
||||
return ProofState.pending;
|
||||
case 'RESERVED':
|
||||
return ProofState.reserved;
|
||||
case 'PENDING_SPENT':
|
||||
return ProofState.pendingSpent;
|
||||
default:
|
||||
return ProofState.unspent;
|
||||
}
|
||||
}
|
||||
|
||||
String toSqlite() {
|
||||
switch (this) {
|
||||
case ProofState.unspent:
|
||||
return 'UNSPENT';
|
||||
case ProofState.spent:
|
||||
return 'SPENT';
|
||||
case ProofState.pending:
|
||||
return 'PENDING';
|
||||
case ProofState.reserved:
|
||||
return 'RESERVED';
|
||||
case ProofState.pendingSpent:
|
||||
return 'PENDING_SPENT';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
|
||||
import '../models/proof.dart';
|
||||
|
||||
/// Servicio para acceder a los proofs locales en SQLite.
|
||||
/// Permite leer proofs, crear tokens offline y actualizar estados.
|
||||
class ProofService {
|
||||
Database? _db;
|
||||
static bool _ffiInitialized = false;
|
||||
|
||||
/// Inicializa sqflite FFI para plataformas desktop (Linux, Windows, macOS).
|
||||
static void _initFfiIfNeeded() {
|
||||
if (_ffiInitialized) return;
|
||||
|
||||
if (Platform.isLinux || Platform.isWindows || Platform.isMacOS) {
|
||||
sqfliteFfiInit();
|
||||
databaseFactory = databaseFactoryFfi;
|
||||
debugPrint('sqflite FFI initialized for desktop');
|
||||
}
|
||||
_ffiInitialized = true;
|
||||
}
|
||||
|
||||
/// Obtiene la instancia de la base de datos.
|
||||
Future<Database> get database async {
|
||||
if (_db != null) return _db!;
|
||||
|
||||
// Inicializar FFI para desktop
|
||||
_initFfiIfNeeded();
|
||||
|
||||
final dir = await getApplicationDocumentsDirectory();
|
||||
final dbPath = '${dir.path}/elcaju_wallet.sqlite';
|
||||
|
||||
_db = await openDatabase(dbPath);
|
||||
return _db!;
|
||||
}
|
||||
|
||||
/// Obtiene todos los proofs UNSPENT de un mint y unidad específicos.
|
||||
/// Ordenados de mayor a menor denominación.
|
||||
Future<List<LocalProof>> getUnspentProofs({
|
||||
required String mintUrl,
|
||||
required String unit,
|
||||
}) async {
|
||||
final db = await database;
|
||||
|
||||
final rows = await db.query(
|
||||
'proof',
|
||||
where: 'mint_url = ? AND unit = ? AND state = ?',
|
||||
whereArgs: [mintUrl, unit, 'UNSPENT'],
|
||||
orderBy: 'amount DESC',
|
||||
);
|
||||
|
||||
return rows.map((row) => LocalProof.fromSqlite(row)).toList();
|
||||
}
|
||||
|
||||
/// Agrupa proofs por denominación.
|
||||
/// Retorna Map ordenado de mayor a menor.
|
||||
Map<BigInt, List<LocalProof>> groupByDenomination(List<LocalProof> proofs) {
|
||||
final grouped = <BigInt, List<LocalProof>>{};
|
||||
|
||||
for (final proof in proofs) {
|
||||
grouped.putIfAbsent(proof.amount, () => []).add(proof);
|
||||
}
|
||||
|
||||
// Ordenar por denominación descendente
|
||||
final sorted = Map.fromEntries(
|
||||
grouped.entries.toList()..sort((a, b) => b.key.compareTo(a.key)),
|
||||
);
|
||||
|
||||
return sorted;
|
||||
}
|
||||
|
||||
/// Calcula el total de proofs seleccionados.
|
||||
BigInt calculateTotal(List<LocalProof> proofs) {
|
||||
return proofs.fold(BigInt.zero, (sum, p) => sum + p.amount);
|
||||
}
|
||||
|
||||
/// Selecciona proofs que sumen exactamente el monto.
|
||||
/// Retorna null si no hay combinación exacta.
|
||||
List<LocalProof>? selectExactProofs(
|
||||
List<LocalProof> available,
|
||||
BigInt targetAmount,
|
||||
) {
|
||||
final selected = <LocalProof>[];
|
||||
var remaining = targetAmount;
|
||||
|
||||
// Ordenar de mayor a menor
|
||||
final sorted = List<LocalProof>.from(available)
|
||||
..sort((a, b) => b.amount.compareTo(a.amount));
|
||||
|
||||
// Greedy selection
|
||||
for (final proof in sorted) {
|
||||
if (remaining >= proof.amount) {
|
||||
selected.add(proof);
|
||||
remaining -= proof.amount;
|
||||
}
|
||||
if (remaining == BigInt.zero) break;
|
||||
}
|
||||
|
||||
return remaining == BigInt.zero ? selected : null;
|
||||
}
|
||||
|
||||
/// Marca proofs como PENDING_SPENT en la base de datos.
|
||||
Future<void> markProofsPendingSpent(List<LocalProof> proofs) async {
|
||||
final db = await database;
|
||||
|
||||
await db.transaction((txn) async {
|
||||
for (final proof in proofs) {
|
||||
await txn.update(
|
||||
'proof',
|
||||
{'state': 'PENDING_SPENT'},
|
||||
where: 'y = ?',
|
||||
whereArgs: [proof.y],
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
debugPrint('Marked ${proofs.length} proofs as PENDING_SPENT');
|
||||
}
|
||||
|
||||
/// Marca proofs como UNSPENT (para revertir si falla el envío).
|
||||
Future<void> markProofsUnspent(List<LocalProof> proofs) async {
|
||||
final db = await database;
|
||||
|
||||
await db.transaction((txn) async {
|
||||
for (final proof in proofs) {
|
||||
await txn.update(
|
||||
'proof',
|
||||
{'state': 'UNSPENT'},
|
||||
where: 'y = ?',
|
||||
whereArgs: [proof.y],
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
debugPrint('Reverted ${proofs.length} proofs to UNSPENT');
|
||||
}
|
||||
|
||||
/// Crea un token Cashu V3 desde proofs seleccionados.
|
||||
/// Formato: cashuA + base64(JSON)
|
||||
String createTokenV3({
|
||||
required String mintUrl,
|
||||
required List<LocalProof> proofs,
|
||||
String? memo,
|
||||
String? unit,
|
||||
}) {
|
||||
// Estructura del token V3
|
||||
final tokenData = <String, dynamic>{
|
||||
'token': [
|
||||
{
|
||||
'mint': mintUrl,
|
||||
'proofs': proofs.map((p) => p.toTokenProof()).toList(),
|
||||
}
|
||||
],
|
||||
};
|
||||
|
||||
// Agregar memo si existe
|
||||
if (memo != null && memo.isNotEmpty) {
|
||||
tokenData['memo'] = memo;
|
||||
}
|
||||
|
||||
// Agregar unit si existe
|
||||
if (unit != null && unit.isNotEmpty) {
|
||||
tokenData['unit'] = unit;
|
||||
}
|
||||
|
||||
// Codificar a JSON y luego base64
|
||||
final jsonStr = jsonEncode(tokenData);
|
||||
final base64Str = base64.encode(utf8.encode(jsonStr));
|
||||
|
||||
return 'cashuA$base64Str';
|
||||
}
|
||||
|
||||
/// Cierra la conexión a la base de datos.
|
||||
Future<void> close() async {
|
||||
await _db?.close();
|
||||
_db = null;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,328 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import '../../core/constants/colors.dart';
|
||||
import '../../core/constants/dimensions.dart';
|
||||
|
||||
/// Tipos de botón según la importancia de la acción
|
||||
/// La intensidad del efecto = importancia de la acción
|
||||
enum ButtonType {
|
||||
/// Acciones críticas que mueven dinero (Enviar)
|
||||
/// Efecto más fuerte: heavyImpact, scale 0.95, delay 100ms
|
||||
criticalAction,
|
||||
|
||||
/// Acciones importantes pero seguras (Recibir)
|
||||
/// Efecto medio: mediumImpact, scale 0.97, sin delay
|
||||
primaryAction,
|
||||
|
||||
/// Acciones de navegación (Historial)
|
||||
/// Efecto sutil: lightImpact, scale 0.98, sin delay
|
||||
navigation,
|
||||
}
|
||||
|
||||
/// Botón animado con feedback táctil y visual premium
|
||||
///
|
||||
/// Diferencia la intensidad del efecto según el tipo de acción:
|
||||
/// - [ButtonType.criticalAction]: Para acciones que mueven dinero
|
||||
/// - [ButtonType.primaryAction]: Para acciones importantes pero seguras
|
||||
/// - [ButtonType.navigation]: Para navegación simple
|
||||
///
|
||||
/// Ejemplo de uso:
|
||||
/// ```dart
|
||||
/// AnimatedActionButton(
|
||||
/// label: 'Enviar',
|
||||
/// type: ButtonType.criticalAction,
|
||||
/// onTap: () => _handleSend(),
|
||||
/// )
|
||||
/// ```
|
||||
class AnimatedActionButton extends StatefulWidget {
|
||||
/// Texto del botón
|
||||
final String label;
|
||||
|
||||
/// Callback al tocar (se ejecuta después del micro-delay si aplica)
|
||||
final VoidCallback onTap;
|
||||
|
||||
/// Tipo de botón que determina la intensidad del efecto
|
||||
final ButtonType type;
|
||||
|
||||
/// Gradiente opcional (para botones primarios)
|
||||
/// Si es null y backgroundColor también, usa el gradiente por defecto
|
||||
final Gradient? gradient;
|
||||
|
||||
/// Color de fondo opcional (para botones de navegación)
|
||||
/// Se ignora si gradient está definido
|
||||
final Color? backgroundColor;
|
||||
|
||||
/// Icono opcional antes del texto
|
||||
final IconData? icon;
|
||||
|
||||
/// Si mostrar el icono (default: false)
|
||||
final bool showIcon;
|
||||
|
||||
/// Ancho del botón (default: expandir al padre)
|
||||
final double? width;
|
||||
|
||||
/// Alto del botón (default: según tipo)
|
||||
final double? height;
|
||||
|
||||
const AnimatedActionButton({
|
||||
super.key,
|
||||
required this.label,
|
||||
required this.onTap,
|
||||
required this.type,
|
||||
this.gradient,
|
||||
this.backgroundColor,
|
||||
this.icon,
|
||||
this.showIcon = false,
|
||||
this.width,
|
||||
this.height,
|
||||
});
|
||||
|
||||
@override
|
||||
State<AnimatedActionButton> createState() => _AnimatedActionButtonState();
|
||||
}
|
||||
|
||||
class _AnimatedActionButtonState extends State<AnimatedActionButton>
|
||||
with SingleTickerProviderStateMixin {
|
||||
/// Controller para animaciones coordinadas
|
||||
late AnimationController _controller;
|
||||
|
||||
/// Animación de escala
|
||||
late Animation<double> _scaleAnimation;
|
||||
|
||||
/// Animación de offset de sombra
|
||||
late Animation<double> _shadowOffsetAnimation;
|
||||
|
||||
/// Animación de blur de sombra
|
||||
late Animation<double> _shadowBlurAnimation;
|
||||
|
||||
/// Animación de opacidad de sombra
|
||||
late Animation<double> _shadowOpacityAnimation;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
// Duración de la animación: 150ms para sentirse responsivo
|
||||
_controller = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 150),
|
||||
);
|
||||
|
||||
// Configurar animaciones según el tipo de botón
|
||||
_setupAnimations();
|
||||
}
|
||||
|
||||
void _setupAnimations() {
|
||||
// Curve suave para salida (easeOutCubic da sensación premium)
|
||||
const curve = Curves.easeOutCubic;
|
||||
|
||||
// === ESCALA ===
|
||||
// Intensidad según tipo: crítico se hunde más, navegación casi imperceptible
|
||||
final double targetScale = switch (widget.type) {
|
||||
ButtonType.criticalAction => 0.95, // Se hunde más - acción crítica
|
||||
ButtonType.primaryAction => 0.97, // Se hunde menos
|
||||
ButtonType.navigation => 0.98, // Muy sutil - solo navegación
|
||||
};
|
||||
|
||||
_scaleAnimation = Tween<double>(
|
||||
begin: 1.0,
|
||||
end: targetScale,
|
||||
).animate(CurvedAnimation(parent: _controller, curve: curve));
|
||||
|
||||
// === SOMBRA ===
|
||||
// Los valores de sombra también varían según tipo
|
||||
final (normalOffset, pressedOffset) = switch (widget.type) {
|
||||
ButtonType.criticalAction => (6.0, 2.0),
|
||||
ButtonType.primaryAction => (6.0, 2.0),
|
||||
ButtonType.navigation => (4.0, 1.0),
|
||||
};
|
||||
|
||||
final (normalBlur, pressedBlur) = switch (widget.type) {
|
||||
ButtonType.criticalAction => (12.0, 4.0),
|
||||
ButtonType.primaryAction => (12.0, 4.0),
|
||||
ButtonType.navigation => (8.0, 3.0),
|
||||
};
|
||||
|
||||
final (normalOpacity, pressedOpacity) = switch (widget.type) {
|
||||
ButtonType.criticalAction => (0.4, 0.15),
|
||||
ButtonType.primaryAction => (0.3, 0.1),
|
||||
ButtonType.navigation => (0.2, 0.1),
|
||||
};
|
||||
|
||||
_shadowOffsetAnimation = Tween<double>(
|
||||
begin: normalOffset,
|
||||
end: pressedOffset,
|
||||
).animate(CurvedAnimation(parent: _controller, curve: curve));
|
||||
|
||||
_shadowBlurAnimation = Tween<double>(
|
||||
begin: normalBlur,
|
||||
end: pressedBlur,
|
||||
).animate(CurvedAnimation(parent: _controller, curve: curve));
|
||||
|
||||
_shadowOpacityAnimation = Tween<double>(
|
||||
begin: normalOpacity,
|
||||
end: pressedOpacity,
|
||||
).animate(CurvedAnimation(parent: _controller, curve: curve));
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(AnimatedActionButton oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
// Reconfigurar si cambia el tipo
|
||||
if (oldWidget.type != widget.type) {
|
||||
_setupAnimations();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// Trigger haptic feedback según tipo de acción
|
||||
void _triggerHaptic() {
|
||||
switch (widget.type) {
|
||||
case ButtonType.criticalAction:
|
||||
// Acción crítica - mueve dinero - feedback fuerte
|
||||
HapticFeedback.heavyImpact();
|
||||
break;
|
||||
case ButtonType.primaryAction:
|
||||
// Acción importante pero segura - feedback medio
|
||||
HapticFeedback.mediumImpact();
|
||||
break;
|
||||
case ButtonType.navigation:
|
||||
// Solo navegación - feedback sutil
|
||||
HapticFeedback.lightImpact();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/// Delay después del tap según tipo
|
||||
/// Solo acciones críticas tienen micro-delay (sensación de "peso")
|
||||
Duration get _callbackDelay {
|
||||
return widget.type == ButtonType.criticalAction
|
||||
? const Duration(milliseconds: 100)
|
||||
: Duration.zero;
|
||||
}
|
||||
|
||||
void _handleTapDown(TapDownDetails details) {
|
||||
_controller.forward();
|
||||
// Haptic inmediato al tocar
|
||||
_triggerHaptic();
|
||||
}
|
||||
|
||||
void _handleTapUp(TapUpDetails details) {
|
||||
_controller.reverse();
|
||||
|
||||
// Ejecutar callback después del delay según tipo
|
||||
Future.delayed(_callbackDelay, () {
|
||||
widget.onTap();
|
||||
});
|
||||
}
|
||||
|
||||
void _handleTapCancel() {
|
||||
// Revertir animación sin ejecutar callback
|
||||
_controller.reverse();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Determinar si es botón con gradiente o con color sólido
|
||||
final bool isGradientButton = widget.type != ButtonType.navigation;
|
||||
|
||||
// Gradiente por defecto para botones primarios
|
||||
final effectiveGradient = widget.gradient ??
|
||||
(isGradientButton
|
||||
? const LinearGradient(
|
||||
begin: Alignment.centerLeft,
|
||||
end: Alignment.centerRight,
|
||||
colors: AppColors.buttonGradient,
|
||||
)
|
||||
: null);
|
||||
|
||||
// Color de fondo para botón de navegación (glass effect)
|
||||
final effectiveBackgroundColor = widget.backgroundColor ??
|
||||
(!isGradientButton
|
||||
? AppColors.glassBase.withValues(alpha: AppColors.glassOpacity)
|
||||
: null);
|
||||
|
||||
// Color de sombra según tipo
|
||||
final shadowColor = isGradientButton
|
||||
? AppColors.primaryAction
|
||||
: Colors.black;
|
||||
|
||||
// Altura según tipo
|
||||
final effectiveHeight = widget.height ??
|
||||
(widget.type == ButtonType.navigation
|
||||
? AppDimensions.buttonHeight + 4
|
||||
: AppDimensions.buttonHeight);
|
||||
|
||||
return GestureDetector(
|
||||
onTapDown: _handleTapDown,
|
||||
onTapUp: _handleTapUp,
|
||||
onTapCancel: _handleTapCancel,
|
||||
child: AnimatedBuilder(
|
||||
animation: _controller,
|
||||
builder: (context, child) {
|
||||
return Transform.scale(
|
||||
scale: _scaleAnimation.value,
|
||||
child: Container(
|
||||
width: widget.width ?? double.infinity,
|
||||
height: effectiveHeight,
|
||||
decoration: BoxDecoration(
|
||||
// Gradiente o color sólido
|
||||
gradient: effectiveGradient,
|
||||
color: effectiveGradient == null ? effectiveBackgroundColor : null,
|
||||
borderRadius: BorderRadius.circular(AppDimensions.buttonBorderRadius),
|
||||
// Borde para botones de navegación (glass effect)
|
||||
border: !isGradientButton
|
||||
? Border.all(
|
||||
color: Colors.white.withValues(alpha: AppColors.glassBorderOpacity),
|
||||
width: 1,
|
||||
)
|
||||
: null,
|
||||
// Sombra animada
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: shadowColor.withValues(alpha: _shadowOpacityAnimation.value),
|
||||
blurRadius: _shadowBlurAnimation.value,
|
||||
offset: Offset(0, _shadowOffsetAnimation.value),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
},
|
||||
child: Center(
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
// Icono opcional
|
||||
if (widget.showIcon && widget.icon != null) ...[
|
||||
Icon(
|
||||
widget.icon,
|
||||
color: Colors.white,
|
||||
size: widget.type == ButtonType.navigation ? 28 : 20,
|
||||
),
|
||||
const SizedBox(width: AppDimensions.paddingSmall),
|
||||
],
|
||||
// Texto
|
||||
Text(
|
||||
widget.label,
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: widget.type == ButtonType.navigation ? 20 : 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../core/constants/colors.dart';
|
||||
import '../../core/models/proof.dart';
|
||||
import '../../core/utils/formatters.dart';
|
||||
|
||||
/// Widget para seleccionar proofs (notas de ecash) manualmente.
|
||||
/// Cada proof se muestra como un chip que cambia de color al seleccionar.
|
||||
class ProofSelector extends StatelessWidget {
|
||||
/// Lista de proofs disponibles.
|
||||
final List<LocalProof> proofs;
|
||||
|
||||
/// Set de proofs actualmente seleccionados (por yHex).
|
||||
final Set<String> selectedIds;
|
||||
|
||||
/// Unidad para formatear (sat, usd, eur).
|
||||
final String unit;
|
||||
|
||||
/// Callback cuando se selecciona/deselecciona un proof.
|
||||
final void Function(LocalProof proof) onToggle;
|
||||
|
||||
const ProofSelector({
|
||||
super.key,
|
||||
required this.proofs,
|
||||
required this.selectedIds,
|
||||
required this.unit,
|
||||
required this.onToggle,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (proofs.isEmpty) {
|
||||
return _buildEmptyState();
|
||||
}
|
||||
|
||||
return Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: proofs.map((proof) {
|
||||
final isSelected = selectedIds.contains(proof.yHex);
|
||||
return _ProofChip(
|
||||
proof: proof,
|
||||
unit: unit,
|
||||
isSelected: isSelected,
|
||||
onTap: () => onToggle(proof),
|
||||
);
|
||||
}).toList(),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildEmptyState() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.account_balance_wallet_outlined,
|
||||
size: 48,
|
||||
color: AppColors.textSecondary.withValues(alpha: 0.3),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'No hay notas disponibles',
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 16,
|
||||
color: AppColors.textSecondary.withValues(alpha: 0.5),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Chip individual para un proof.
|
||||
class _ProofChip extends StatelessWidget {
|
||||
final LocalProof proof;
|
||||
final String unit;
|
||||
final bool isSelected;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _ProofChip({
|
||||
required this.proof,
|
||||
required this.unit,
|
||||
required this.isSelected,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Formatear el monto según la unidad
|
||||
final displayAmount = _formatAmount(proof.amount, unit);
|
||||
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 150),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
// Color de fondo: naranja si seleccionado, glass si no
|
||||
color: isSelected
|
||||
? AppColors.primaryAction
|
||||
: Colors.white.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: isSelected
|
||||
? AppColors.primaryAction
|
||||
: Colors.white.withValues(alpha: 0.2),
|
||||
width: 1,
|
||||
),
|
||||
// Sombra sutil cuando seleccionado
|
||||
boxShadow: isSelected
|
||||
? [
|
||||
BoxShadow(
|
||||
color: AppColors.primaryAction.withValues(alpha: 0.3),
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
]
|
||||
: null,
|
||||
),
|
||||
child: Text(
|
||||
displayAmount,
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 16,
|
||||
fontWeight: isSelected ? FontWeight.w600 : FontWeight.w500,
|
||||
color: isSelected
|
||||
? Colors.white
|
||||
: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Formatea el monto según la unidad.
|
||||
/// sat: "512", usd/eur: "5.12"
|
||||
String _formatAmount(BigInt amount, String unit) {
|
||||
final multiplier = UnitFormatter.getMultiplier(unit);
|
||||
|
||||
if (multiplier == 100) {
|
||||
// USD/EUR: mostrar como decimal
|
||||
final value = amount.toDouble() / 100;
|
||||
return value.toStringAsFixed(2);
|
||||
} else {
|
||||
// SAT: mostrar como entero
|
||||
return amount.toString();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Widget que muestra el total seleccionado.
|
||||
class ProofTotalDisplay extends StatelessWidget {
|
||||
final BigInt total;
|
||||
final String unit;
|
||||
|
||||
const ProofTotalDisplay({
|
||||
super.key,
|
||||
required this.total,
|
||||
required this.unit,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final formattedTotal = UnitFormatter.formatBalance(total, unit);
|
||||
final unitLabel = UnitFormatter.getUnitLabel(unit);
|
||||
|
||||
return Text(
|
||||
'$formattedTotal $unitLabel',
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 32,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.white,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import flutter_secure_storage_macos
|
||||
import path_provider_foundation
|
||||
import share_plus
|
||||
import shared_preferences_foundation
|
||||
import sqflite_darwin
|
||||
import url_launcher_macos
|
||||
|
||||
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||
@@ -16,5 +17,6 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
|
||||
SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin"))
|
||||
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
||||
SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin"))
|
||||
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
|
||||
}
|
||||
|
||||
@@ -1,6 +1,22 @@
|
||||
# Generated by pub
|
||||
# See https://dart.dev/tools/pub/glossary#lockfile
|
||||
packages:
|
||||
ansicolor:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: ansicolor
|
||||
sha256: "50e982d500bc863e1d703448afdbf9e5a72eb48840a4f766fa361ffd6877055f"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.3"
|
||||
archive:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: archive
|
||||
sha256: "2fde1607386ab523f7a36bb3e7edb43bd58e6edaf2ffb29d8a6d578b297fdbbd"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.0.7"
|
||||
args:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<!DOCTYPE html><html><head>
|
||||
<!--
|
||||
If you are serving your web app in a path other than the root, change the
|
||||
href value below to reflect the base path you are serving from.
|
||||
@@ -27,12 +25,84 @@
|
||||
<link rel="apple-touch-icon" href="icons/Icon-192.png">
|
||||
|
||||
<!-- Favicon -->
|
||||
<link rel="icon" type="image/png" href="favicon.png"/>
|
||||
<link rel="icon" type="image/png" href="favicon.png">
|
||||
|
||||
<title>elcaju</title>
|
||||
<link rel="manifest" href="manifest.json">
|
||||
<style id="splash-screen-style">
|
||||
html {
|
||||
height: 100%
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100%;
|
||||
background-color: #1A0B2E;
|
||||
background-size: 100% 100%;
|
||||
}
|
||||
|
||||
.center {
|
||||
margin: 0;
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
-ms-transform: translate(-50%, -50%);
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
|
||||
.contain {
|
||||
display:block;
|
||||
width:100%; height:100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.stretch {
|
||||
display:block;
|
||||
width:100%; height:100%;
|
||||
}
|
||||
|
||||
.cover {
|
||||
display:block;
|
||||
width:100%; height:100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.bottom {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 50%;
|
||||
-ms-transform: translate(-50%, 0);
|
||||
transform: translate(-50%, 0);
|
||||
}
|
||||
|
||||
.bottomLeft {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.bottomRight {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
}
|
||||
</style>
|
||||
<script id="splash-screen-script">
|
||||
function removeSplashFromWeb() {
|
||||
document.getElementById("splash")?.remove();
|
||||
document.getElementById("splash-branding")?.remove();
|
||||
document.body.style.background = "transparent";
|
||||
}
|
||||
</script>
|
||||
<meta content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" name="viewport">
|
||||
</head>
|
||||
<body>
|
||||
<script src="flutter_bootstrap.js" async></script>
|
||||
</body>
|
||||
</html>
|
||||
<picture id="splash">
|
||||
<source srcset="splash/img/light-1x.png 1x, splash/img/light-2x.png 2x, splash/img/light-3x.png 3x, splash/img/light-4x.png 4x" media="(prefers-color-scheme: light)">
|
||||
<source srcset="splash/img/dark-1x.png 1x, splash/img/dark-2x.png 2x, splash/img/dark-3x.png 3x, splash/img/dark-4x.png 4x" media="(prefers-color-scheme: dark)">
|
||||
<img class="center" aria-hidden="true" src="splash/img/light-1x.png" alt="">
|
||||
</picture>
|
||||
<script src="flutter_bootstrap.js" async=""></script>
|
||||
|
||||
|
||||
</body></html>
|
||||
|
After Width: | Height: | Size: 97 KiB |
|
After Width: | Height: | Size: 300 KiB |
|
After Width: | Height: | Size: 536 KiB |
|
After Width: | Height: | Size: 786 KiB |
|
After Width: | Height: | Size: 97 KiB |
|
After Width: | Height: | Size: 300 KiB |
|
After Width: | Height: | Size: 536 KiB |
|
After Width: | Height: | Size: 786 KiB |