Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0a932bdd8e | ||
|
|
5934a1db8a | ||
|
|
9d643af39d | ||
|
|
a5dc4d6160 | ||
|
|
922fabc784 | ||
|
|
7540e7e413 | ||
|
|
9ee020ccb2 | ||
|
|
aeaebee37b | ||
|
|
52812c85c6 | ||
|
|
dac5693d5c |
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ 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,6 +45,9 @@ class WalletProvider extends ChangeNotifier {
|
||||
/// Ejemplo: {'00abc123': 'sat', '00def456': 'usd'}
|
||||
final Map<String, String> _keysetUnits = {};
|
||||
|
||||
/// Caché de MintInfo por URL (nombre, logo, contactos, etc.)
|
||||
final Map<String, MintInfo> _mintInfoCache = {};
|
||||
|
||||
/// Mint activo actualmente
|
||||
String? _activeMintUrl;
|
||||
|
||||
@@ -136,6 +143,61 @@ class WalletProvider extends ChangeNotifier {
|
||||
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
|
||||
// ============================================================
|
||||
@@ -210,6 +272,9 @@ class WalletProvider extends ChangeNotifier {
|
||||
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';
|
||||
@@ -720,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,
|
||||
@@ -729,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,
|
||||
@@ -789,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();
|
||||
|
||||
@@ -798,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();
|
||||
@@ -830,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
|
||||
// ============================================================
|
||||
@@ -894,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
|
||||
// ============================================================
|
||||
@@ -1060,6 +1278,9 @@ class WalletProvider extends ChangeNotifier {
|
||||
_mnemonic = null;
|
||||
_db = null;
|
||||
|
||||
// Limpiar metadata de transacciones
|
||||
await _txMetaStorage.clear();
|
||||
|
||||
// Borrar archivo
|
||||
final dir = await getApplicationDocumentsDirectory();
|
||||
final dbPath = '${dir.path}/elcaju_wallet.sqlite';
|
||||
|
||||
+234
-476
@@ -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,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 {
|
||||
@@ -141,269 +143,227 @@ class _MintsScreenState extends State<MintsScreen> {
|
||||
builder: (context, balanceSnapshot) {
|
||||
final balances = balanceSnapshot.data ?? {};
|
||||
|
||||
return GlassCard(
|
||||
padding: const EdgeInsets.all(AppDimensions.paddingMedium),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Header: URL + estado + refresh
|
||||
Row(
|
||||
children: [
|
||||
// Icono estado conexión (verde si es el activo)
|
||||
Container(
|
||||
width: 8,
|
||||
height: 8,
|
||||
decoration: BoxDecoration(
|
||||
color: isActive ? AppColors.success : AppColors.textSecondary,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
return FutureBuilder<MintInfo?>(
|
||||
future: walletProvider.fetchMintInfo(mintUrl),
|
||||
builder: (context, infoSnapshot) {
|
||||
final mintInfo = infoSnapshot.data;
|
||||
final mintName = mintInfo?.name ?? UnitFormatter.getMintDisplayName(mintUrl);
|
||||
final iconUrl = mintInfo?.iconUrl;
|
||||
|
||||
// URL del mint (display name)
|
||||
Expanded(
|
||||
child: Text(
|
||||
UnitFormatter.getMintDisplayName(mintUrl),
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.white,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
|
||||
// Botón refresh
|
||||
GestureDetector(
|
||||
onTap: () => _refreshMint(mintUrl, walletProvider),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(4),
|
||||
child: Icon(
|
||||
LucideIcons.refreshCw,
|
||||
color: AppColors.textSecondary,
|
||||
size: 18,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(width: 8),
|
||||
|
||||
// Badge activo
|
||||
if (isActive)
|
||||
return GestureDetector(
|
||||
onTap: () => _openMintDetails(
|
||||
mintUrl,
|
||||
walletProvider,
|
||||
mintInfo,
|
||||
balances,
|
||||
isActive,
|
||||
isCubaBitcoin,
|
||||
),
|
||||
child: GlassCard(
|
||||
padding: const EdgeInsets.all(AppDimensions.paddingMedium),
|
||||
child: Row(
|
||||
children: [
|
||||
// Logo del mint
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 4,
|
||||
),
|
||||
width: 48,
|
||||
height: 48,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.primaryAction.withValues(alpha: 0.2),
|
||||
color: AppColors.primaryAction.withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Text(
|
||||
'Activo',
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.primaryAction,
|
||||
),
|
||||
child: iconUrl != null
|
||||
? ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Image.network(
|
||||
iconUrl,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (_, __, ___) => const Icon(
|
||||
LucideIcons.landmark,
|
||||
color: AppColors.primaryAction,
|
||||
size: 24,
|
||||
),
|
||||
),
|
||||
)
|
||||
: const Icon(
|
||||
LucideIcons.landmark,
|
||||
color: AppColors.primaryAction,
|
||||
size: 24,
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(width: 12),
|
||||
|
||||
// Info del mint
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Nombre + badge activo
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
mintName,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.white,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
if (isActive)
|
||||
Container(
|
||||
margin: const EdgeInsets.only(left: 8),
|
||||
width: 8,
|
||||
height: 8,
|
||||
decoration: const BoxDecoration(
|
||||
color: AppColors.success,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 2),
|
||||
|
||||
// URL
|
||||
Text(
|
||||
mintUrl,
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 12,
|
||||
color: AppColors.textSecondary.withValues(alpha: 0.6),
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
|
||||
const SizedBox(height: 6),
|
||||
|
||||
// Balance badge
|
||||
if (balanceSnapshot.connectionState == ConnectionState.waiting)
|
||||
SizedBox(
|
||||
width: 12,
|
||||
height: 12,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
)
|
||||
else
|
||||
_buildBalanceBadges(units, balances),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 4),
|
||||
|
||||
// URL completa (más pequeña)
|
||||
Text(
|
||||
mintUrl,
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 12,
|
||||
color: AppColors.textSecondary.withValues(alpha: 0.6),
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
|
||||
const SizedBox(height: AppDimensions.paddingSmall),
|
||||
|
||||
// Balances por unidad
|
||||
if (balanceSnapshot.connectionState == ConnectionState.waiting)
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
'Balance:',
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 14,
|
||||
color: AppColors.textSecondary.withValues(alpha: 0.7),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
SizedBox(
|
||||
width: 14,
|
||||
height: 14,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
// Chevron
|
||||
Icon(
|
||||
LucideIcons.chevronRight,
|
||||
color: Colors.white.withValues(alpha: 0.3),
|
||||
size: 20,
|
||||
),
|
||||
],
|
||||
)
|
||||
else
|
||||
_buildBalancesList(units, balances),
|
||||
|
||||
const SizedBox(height: AppDimensions.paddingMedium),
|
||||
|
||||
// Acciones
|
||||
Row(
|
||||
children: [
|
||||
// Hacer activo (si no lo es)
|
||||
if (!isActive)
|
||||
Expanded(
|
||||
child: GestureDetector(
|
||||
onTap: () => _setActiveMint(mintUrl, walletProvider),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
LucideIcons.star,
|
||||
color: AppColors.textSecondary,
|
||||
size: 16,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
'Usar este mint',
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
if (!isActive && !isCubaBitcoin) const SizedBox(width: 8),
|
||||
|
||||
// Eliminar (no disponible para Cuba Bitcoin)
|
||||
if (!isCubaBitcoin)
|
||||
Expanded(
|
||||
child: GestureDetector(
|
||||
onTap: () => _showDeleteDialog(mintUrl, balances, walletProvider),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.error.withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
LucideIcons.trash2,
|
||||
color: AppColors.error,
|
||||
size: 16,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
'Eliminar',
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: AppColors.error,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Construye la lista de balances por unidad.
|
||||
Widget _buildBalancesList(List<String> units, Map<String, BigInt> balances) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Balances:',
|
||||
/// Badges de balance compactos (estilo cashu.me)
|
||||
Widget _buildBalanceBadges(List<String> units, Map<String, BigInt> balances) {
|
||||
final nonZeroBalances = balances.entries
|
||||
.where((e) => e.value > BigInt.zero)
|
||||
.toList();
|
||||
|
||||
if (nonZeroBalances.isEmpty) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
'0 ${units.isNotEmpty ? units.first : "sat"}',
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 14,
|
||||
color: AppColors.textSecondary.withValues(alpha: 0.7),
|
||||
fontSize: 12,
|
||||
color: Colors.white.withValues(alpha: 0.6),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
...units.map((unit) {
|
||||
final balance = balances[unit] ?? BigInt.zero;
|
||||
final formattedBalance = UnitFormatter.formatBalance(balance, unit);
|
||||
final unitLabel = UnitFormatter.getUnitLabel(unit);
|
||||
);
|
||||
}
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(left: 8, top: 2),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 6,
|
||||
height: 6,
|
||||
decoration: BoxDecoration(
|
||||
color: balance > BigInt.zero
|
||||
? AppColors.success
|
||||
: AppColors.textSecondary.withValues(alpha: 0.3),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'$formattedBalance $unitLabel',
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 14,
|
||||
fontWeight: balance > BigInt.zero ? FontWeight.w600 : FontWeight.normal,
|
||||
color: balance > BigInt.zero
|
||||
? Colors.white
|
||||
: AppColors.textSecondary.withValues(alpha: 0.7),
|
||||
),
|
||||
),
|
||||
],
|
||||
return Wrap(
|
||||
spacing: 6,
|
||||
runSpacing: 4,
|
||||
children: nonZeroBalances.map((entry) {
|
||||
final unit = entry.key;
|
||||
final balance = entry.value;
|
||||
final formatted = UnitFormatter.formatBalance(balance, unit);
|
||||
final label = UnitFormatter.getUnitLabel(unit);
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.primaryAction.withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
'$formatted $label',
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: AppColors.primaryAction,
|
||||
),
|
||||
);
|
||||
}),
|
||||
],
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
);
|
||||
}
|
||||
|
||||
/// Refresca la información del mint (detecta nuevas unidades).
|
||||
Future<void> _refreshMint(String mintUrl, WalletProvider walletProvider) async {
|
||||
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),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -413,7 +373,6 @@ class _MintsScreenState extends State<MintsScreen> {
|
||||
SnackBar(
|
||||
content: Text('Error: $e'),
|
||||
backgroundColor: AppColors.error,
|
||||
duration: const Duration(seconds: 3),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -455,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,
|
||||
@@ -522,191 +428,6 @@ class _MintsScreenState extends State<MintsScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Modal para confirmar eliminación de mint
|
||||
class _DeleteMintModal extends StatelessWidget {
|
||||
final String mintUrl;
|
||||
final bool hasBalance;
|
||||
final String balanceDescription;
|
||||
final VoidCallback onConfirm;
|
||||
final VoidCallback onCancel;
|
||||
|
||||
const _DeleteMintModal({
|
||||
required this.mintUrl,
|
||||
required this.hasBalance,
|
||||
required this.balanceDescription,
|
||||
required this.onConfirm,
|
||||
required this.onCancel,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(AppDimensions.paddingMedium),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.deepVoidPurple,
|
||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(24)),
|
||||
border: Border.all(
|
||||
color: Colors.white.withValues(alpha: 0.1),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Handle
|
||||
Container(
|
||||
margin: const EdgeInsets.only(bottom: 16),
|
||||
width: 40,
|
||||
height: 4,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: 0.3),
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
|
||||
// Icono advertencia
|
||||
Container(
|
||||
width: 64,
|
||||
height: 64,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.error.withValues(alpha: 0.2),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(
|
||||
LucideIcons.alertTriangle,
|
||||
color: AppColors.error,
|
||||
size: 32,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppDimensions.paddingMedium),
|
||||
|
||||
// Título
|
||||
const Text(
|
||||
'¿Eliminar mint?',
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppDimensions.paddingSmall),
|
||||
|
||||
// URL del mint
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 32),
|
||||
child: Text(
|
||||
mintUrl,
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 14,
|
||||
color: AppColors.textSecondary.withValues(alpha: 0.7),
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: AppDimensions.paddingMedium),
|
||||
|
||||
// Advertencia según balance
|
||||
Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 32),
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: hasBalance
|
||||
? AppColors.secondaryAction.withValues(alpha: 0.2)
|
||||
: Colors.white.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(
|
||||
hasBalance ? LucideIcons.alertCircle : LucideIcons.info,
|
||||
color: hasBalance ? AppColors.secondaryAction : AppColors.textSecondary,
|
||||
size: 16,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
hasBalance
|
||||
? 'Este mint tiene $balanceDescription. Puedes recuperarlos después agregando el mint de nuevo.'
|
||||
: 'Perderás acceso a este mint',
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 12,
|
||||
color: hasBalance
|
||||
? AppColors.secondaryAction
|
||||
: AppColors.textSecondary.withValues(alpha: 0.7),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: AppDimensions.paddingLarge),
|
||||
|
||||
// Botones
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: GestureDetector(
|
||||
onTap: onCancel,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: const Center(
|
||||
child: Text(
|
||||
'Cancelar',
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: AppDimensions.paddingMedium),
|
||||
Expanded(
|
||||
child: GestureDetector(
|
||||
onTap: onConfirm,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.error,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: const Center(
|
||||
child: Text(
|
||||
'Eliminar',
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: AppDimensions.paddingSmall),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Diálogo para agregar nuevo mint
|
||||
class _AddMintDialog extends StatefulWidget {
|
||||
final WalletProvider walletProvider;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,328 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import '../../core/constants/colors.dart';
|
||||
import '../../core/constants/dimensions.dart';
|
||||
|
||||
/// Tipos de botón según la importancia de la acción
|
||||
/// La intensidad del efecto = importancia de la acción
|
||||
enum ButtonType {
|
||||
/// Acciones críticas que mueven dinero (Enviar)
|
||||
/// Efecto más fuerte: heavyImpact, scale 0.95, delay 100ms
|
||||
criticalAction,
|
||||
|
||||
/// Acciones importantes pero seguras (Recibir)
|
||||
/// Efecto medio: mediumImpact, scale 0.97, sin delay
|
||||
primaryAction,
|
||||
|
||||
/// Acciones de navegación (Historial)
|
||||
/// Efecto sutil: lightImpact, scale 0.98, sin delay
|
||||
navigation,
|
||||
}
|
||||
|
||||
/// Botón animado con feedback táctil y visual premium
|
||||
///
|
||||
/// Diferencia la intensidad del efecto según el tipo de acción:
|
||||
/// - [ButtonType.criticalAction]: Para acciones que mueven dinero
|
||||
/// - [ButtonType.primaryAction]: Para acciones importantes pero seguras
|
||||
/// - [ButtonType.navigation]: Para navegación simple
|
||||
///
|
||||
/// Ejemplo de uso:
|
||||
/// ```dart
|
||||
/// AnimatedActionButton(
|
||||
/// label: 'Enviar',
|
||||
/// type: ButtonType.criticalAction,
|
||||
/// onTap: () => _handleSend(),
|
||||
/// )
|
||||
/// ```
|
||||
class AnimatedActionButton extends StatefulWidget {
|
||||
/// Texto del botón
|
||||
final String label;
|
||||
|
||||
/// Callback al tocar (se ejecuta después del micro-delay si aplica)
|
||||
final VoidCallback onTap;
|
||||
|
||||
/// Tipo de botón que determina la intensidad del efecto
|
||||
final ButtonType type;
|
||||
|
||||
/// Gradiente opcional (para botones primarios)
|
||||
/// Si es null y backgroundColor también, usa el gradiente por defecto
|
||||
final Gradient? gradient;
|
||||
|
||||
/// Color de fondo opcional (para botones de navegación)
|
||||
/// Se ignora si gradient está definido
|
||||
final Color? backgroundColor;
|
||||
|
||||
/// Icono opcional antes del texto
|
||||
final IconData? icon;
|
||||
|
||||
/// Si mostrar el icono (default: false)
|
||||
final bool showIcon;
|
||||
|
||||
/// Ancho del botón (default: expandir al padre)
|
||||
final double? width;
|
||||
|
||||
/// Alto del botón (default: según tipo)
|
||||
final double? height;
|
||||
|
||||
const AnimatedActionButton({
|
||||
super.key,
|
||||
required this.label,
|
||||
required this.onTap,
|
||||
required this.type,
|
||||
this.gradient,
|
||||
this.backgroundColor,
|
||||
this.icon,
|
||||
this.showIcon = false,
|
||||
this.width,
|
||||
this.height,
|
||||
});
|
||||
|
||||
@override
|
||||
State<AnimatedActionButton> createState() => _AnimatedActionButtonState();
|
||||
}
|
||||
|
||||
class _AnimatedActionButtonState extends State<AnimatedActionButton>
|
||||
with SingleTickerProviderStateMixin {
|
||||
/// Controller para animaciones coordinadas
|
||||
late AnimationController _controller;
|
||||
|
||||
/// Animación de escala
|
||||
late Animation<double> _scaleAnimation;
|
||||
|
||||
/// Animación de offset de sombra
|
||||
late Animation<double> _shadowOffsetAnimation;
|
||||
|
||||
/// Animación de blur de sombra
|
||||
late Animation<double> _shadowBlurAnimation;
|
||||
|
||||
/// Animación de opacidad de sombra
|
||||
late Animation<double> _shadowOpacityAnimation;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
// Duración de la animación: 150ms para sentirse responsivo
|
||||
_controller = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 150),
|
||||
);
|
||||
|
||||
// Configurar animaciones según el tipo de botón
|
||||
_setupAnimations();
|
||||
}
|
||||
|
||||
void _setupAnimations() {
|
||||
// Curve suave para salida (easeOutCubic da sensación premium)
|
||||
const curve = Curves.easeOutCubic;
|
||||
|
||||
// === ESCALA ===
|
||||
// Intensidad según tipo: crítico se hunde más, navegación casi imperceptible
|
||||
final double targetScale = switch (widget.type) {
|
||||
ButtonType.criticalAction => 0.95, // Se hunde más - acción crítica
|
||||
ButtonType.primaryAction => 0.97, // Se hunde menos
|
||||
ButtonType.navigation => 0.98, // Muy sutil - solo navegación
|
||||
};
|
||||
|
||||
_scaleAnimation = Tween<double>(
|
||||
begin: 1.0,
|
||||
end: targetScale,
|
||||
).animate(CurvedAnimation(parent: _controller, curve: curve));
|
||||
|
||||
// === SOMBRA ===
|
||||
// Los valores de sombra también varían según tipo
|
||||
final (normalOffset, pressedOffset) = switch (widget.type) {
|
||||
ButtonType.criticalAction => (6.0, 2.0),
|
||||
ButtonType.primaryAction => (6.0, 2.0),
|
||||
ButtonType.navigation => (4.0, 1.0),
|
||||
};
|
||||
|
||||
final (normalBlur, pressedBlur) = switch (widget.type) {
|
||||
ButtonType.criticalAction => (12.0, 4.0),
|
||||
ButtonType.primaryAction => (12.0, 4.0),
|
||||
ButtonType.navigation => (8.0, 3.0),
|
||||
};
|
||||
|
||||
final (normalOpacity, pressedOpacity) = switch (widget.type) {
|
||||
ButtonType.criticalAction => (0.4, 0.15),
|
||||
ButtonType.primaryAction => (0.3, 0.1),
|
||||
ButtonType.navigation => (0.2, 0.1),
|
||||
};
|
||||
|
||||
_shadowOffsetAnimation = Tween<double>(
|
||||
begin: normalOffset,
|
||||
end: pressedOffset,
|
||||
).animate(CurvedAnimation(parent: _controller, curve: curve));
|
||||
|
||||
_shadowBlurAnimation = Tween<double>(
|
||||
begin: normalBlur,
|
||||
end: pressedBlur,
|
||||
).animate(CurvedAnimation(parent: _controller, curve: curve));
|
||||
|
||||
_shadowOpacityAnimation = Tween<double>(
|
||||
begin: normalOpacity,
|
||||
end: pressedOpacity,
|
||||
).animate(CurvedAnimation(parent: _controller, curve: curve));
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(AnimatedActionButton oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
// Reconfigurar si cambia el tipo
|
||||
if (oldWidget.type != widget.type) {
|
||||
_setupAnimations();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// Trigger haptic feedback según tipo de acción
|
||||
void _triggerHaptic() {
|
||||
switch (widget.type) {
|
||||
case ButtonType.criticalAction:
|
||||
// Acción crítica - mueve dinero - feedback fuerte
|
||||
HapticFeedback.heavyImpact();
|
||||
break;
|
||||
case ButtonType.primaryAction:
|
||||
// Acción importante pero segura - feedback medio
|
||||
HapticFeedback.mediumImpact();
|
||||
break;
|
||||
case ButtonType.navigation:
|
||||
// Solo navegación - feedback sutil
|
||||
HapticFeedback.lightImpact();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/// Delay después del tap según tipo
|
||||
/// Solo acciones críticas tienen micro-delay (sensación de "peso")
|
||||
Duration get _callbackDelay {
|
||||
return widget.type == ButtonType.criticalAction
|
||||
? const Duration(milliseconds: 100)
|
||||
: Duration.zero;
|
||||
}
|
||||
|
||||
void _handleTapDown(TapDownDetails details) {
|
||||
_controller.forward();
|
||||
// Haptic inmediato al tocar
|
||||
_triggerHaptic();
|
||||
}
|
||||
|
||||
void _handleTapUp(TapUpDetails details) {
|
||||
_controller.reverse();
|
||||
|
||||
// Ejecutar callback después del delay según tipo
|
||||
Future.delayed(_callbackDelay, () {
|
||||
widget.onTap();
|
||||
});
|
||||
}
|
||||
|
||||
void _handleTapCancel() {
|
||||
// Revertir animación sin ejecutar callback
|
||||
_controller.reverse();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Determinar si es botón con gradiente o con color sólido
|
||||
final bool isGradientButton = widget.type != ButtonType.navigation;
|
||||
|
||||
// Gradiente por defecto para botones primarios
|
||||
final effectiveGradient = widget.gradient ??
|
||||
(isGradientButton
|
||||
? const LinearGradient(
|
||||
begin: Alignment.centerLeft,
|
||||
end: Alignment.centerRight,
|
||||
colors: AppColors.buttonGradient,
|
||||
)
|
||||
: null);
|
||||
|
||||
// Color de fondo para botón de navegación (glass effect)
|
||||
final effectiveBackgroundColor = widget.backgroundColor ??
|
||||
(!isGradientButton
|
||||
? AppColors.glassBase.withValues(alpha: AppColors.glassOpacity)
|
||||
: null);
|
||||
|
||||
// Color de sombra según tipo
|
||||
final shadowColor = isGradientButton
|
||||
? AppColors.primaryAction
|
||||
: Colors.black;
|
||||
|
||||
// Altura según tipo
|
||||
final effectiveHeight = widget.height ??
|
||||
(widget.type == ButtonType.navigation
|
||||
? AppDimensions.buttonHeight + 4
|
||||
: AppDimensions.buttonHeight);
|
||||
|
||||
return GestureDetector(
|
||||
onTapDown: _handleTapDown,
|
||||
onTapUp: _handleTapUp,
|
||||
onTapCancel: _handleTapCancel,
|
||||
child: AnimatedBuilder(
|
||||
animation: _controller,
|
||||
builder: (context, child) {
|
||||
return Transform.scale(
|
||||
scale: _scaleAnimation.value,
|
||||
child: Container(
|
||||
width: widget.width ?? double.infinity,
|
||||
height: effectiveHeight,
|
||||
decoration: BoxDecoration(
|
||||
// Gradiente o color sólido
|
||||
gradient: effectiveGradient,
|
||||
color: effectiveGradient == null ? effectiveBackgroundColor : null,
|
||||
borderRadius: BorderRadius.circular(AppDimensions.buttonBorderRadius),
|
||||
// Borde para botones de navegación (glass effect)
|
||||
border: !isGradientButton
|
||||
? Border.all(
|
||||
color: Colors.white.withValues(alpha: AppColors.glassBorderOpacity),
|
||||
width: 1,
|
||||
)
|
||||
: null,
|
||||
// Sombra animada
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: shadowColor.withValues(alpha: _shadowOpacityAnimation.value),
|
||||
blurRadius: _shadowBlurAnimation.value,
|
||||
offset: Offset(0, _shadowOffsetAnimation.value),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
},
|
||||
child: Center(
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
// Icono opcional
|
||||
if (widget.showIcon && widget.icon != null) ...[
|
||||
Icon(
|
||||
widget.icon,
|
||||
color: Colors.white,
|
||||
size: widget.type == ButtonType.navigation ? 28 : 20,
|
||||
),
|
||||
const SizedBox(width: AppDimensions.paddingSmall),
|
||||
],
|
||||
// Texto
|
||||
Text(
|
||||
widget.label,
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: widget.type == ButtonType.navigation ? 20 : 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user