Compare commits

...
Author SHA1 Message Date
Forte11Cuba 0a932bdd8e feat: add mint detail screen and show mint logo in dashboard 2026-02-06 15:44:41 -06:00
Forte11andGitHub 5934a1db8a Merge pull request #12 from Forte11Cuba/feature/balance-tap-toggle
feat: improve balance interaction UX
2026-02-06 15:14:22 -06:00
Forte11Cuba 9d643af39d feat: improve balance interaction UX 2026-02-06 15:13:29 -06:00
Forte11andGitHub a5dc4d6160 Merge pull request #11 from Forte11Cuba/feature/unit-labels-cashu-style
feat: update unit display to match cashu.me style
2026-02-06 14:55:40 -06:00
Forte11Cuba 922fabc784 feat: update unit display to match cashu.me style 2026-02-06 14:54:46 -06:00
Forte11andGitHub 7540e7e413 Merge pull request #10 from Forte11Cuba/feature/history-screen-qr-dynamic
feat: add dedicated history screen with dynamic QR codes
2026-02-06 14:23:04 -06:00
Forte11Cuba 9ee020ccb2 feat: add dedicated history screen with dynamic QR codes 2026-02-06 14:22:25 -06:00
Forte11andGitHub aeaebee37b Merge pull request #9 from Forte11Cuba/feature/animated-action-buttons
feature/animated-action-buttons
2026-02-06 13:11:12 -06:00
Forte11Cuba 52812c85c6 feature/animated-action-buttons 2026-02-06 13:10:27 -06:00
Forte11andGitHub dac5693d5c Merge pull request #8 from Forte11Cuba/feature/app-icon-splash
Add app icon and native splash screen
2026-02-06 12:46:04 -06:00
8 changed files with 2774 additions and 954 deletions
+39 -4
View File
@@ -34,16 +34,34 @@ class UnitFormatter {
}
/// Formatea un balance con su unidad.
/// Ejemplo: 1000 sat → "1,000 BTC", 500 usd → "5.00 USD"
/// Ejemplo: 1000 sat → "1,000 sat", 500 usd → "5.00 USD"
static String formatBalanceWithUnit(BigInt amount, String unit) {
final formatted = formatBalance(amount, unit);
final label = getUnitLabel(unit);
return '$formatted $label';
}
/// Obtiene la etiqueta de display para una unidad.
/// sat → BTC, usd → USD, eur → EUR, msat → MSAT
/// Obtiene la etiqueta de display para una unidad (para balances).
/// Siguiendo el estándar de cashu.me: minúsculas para sat/msat
/// Ejemplo: "855 sat", "5.00 USD"
static String getUnitLabel(String unit) {
switch (unit.toLowerCase()) {
case 'sat':
return 'sat';
case 'usd':
return 'USD';
case 'eur':
return 'EUR';
case 'msat':
return 'msat';
default:
return unit;
}
}
/// Obtiene la etiqueta para el botón toggle de unidad.
/// sat → "BTC" (indica Bitcoin), otros → mayúsculas
static String getToggleLabel(String unit) {
switch (unit.toLowerCase()) {
case 'sat':
return 'BTC';
@@ -52,7 +70,24 @@ class UnitFormatter {
case 'eur':
return 'EUR';
case 'msat':
return 'MSAT';
return 'mSAT';
default:
return unit.toUpperCase();
}
}
/// Obtiene la etiqueta en mayúsculas (para badges, estados).
/// Ejemplo: "PENDING: 536 SAT"
static String getUnitLabelUppercase(String unit) {
switch (unit.toLowerCase()) {
case 'sat':
return 'SAT';
case 'usd':
return 'USD';
case 'eur':
return 'EUR';
case 'msat':
return 'mSAT';
default:
return unit.toUpperCase();
}
+166
View File
@@ -0,0 +1,166 @@
import 'dart:convert';
import 'package:shared_preferences/shared_preferences.dart';
/// Tipos de transacción
enum TransactionType {
cashu,
lightning,
}
/// Metadata adicional de una transacción.
/// Se usa para guardar datos que el CDK no persiste (token, invoice, tipo).
class TransactionMeta {
final TransactionType type;
final String? token; // Solo para Cashu send
final String? invoice; // Solo para Lightning (mint/melt)
final DateTime createdAt;
TransactionMeta({
required this.type,
this.token,
this.invoice,
DateTime? createdAt,
}) : createdAt = createdAt ?? DateTime.now();
Map<String, dynamic> toJson() => {
'type': type.name,
'token': token,
'invoice': invoice,
'createdAt': createdAt.toIso8601String(),
};
factory TransactionMeta.fromJson(Map<String, dynamic> json) {
return TransactionMeta(
type: TransactionType.values.firstWhere(
(t) => t.name == json['type'],
orElse: () => TransactionType.cashu,
),
token: json['token'] as String?,
invoice: json['invoice'] as String?,
createdAt: json['createdAt'] != null
? DateTime.parse(json['createdAt'] as String)
: DateTime.now(),
);
}
}
/// Storage para metadata de transacciones.
/// Complementa el historial del CDK con datos adicionales.
///
/// Uso:
/// ```dart
/// final storage = TransactionMetaStorage();
/// await storage.init();
///
/// // Guardar metadata
/// await storage.save('tx_123', TransactionMeta(type: TransactionType.cashu, token: 'cashuA...'));
///
/// // Obtener metadata
/// final meta = storage.get('tx_123');
/// ```
class TransactionMetaStorage {
static const _storageKey = 'transaction_meta';
SharedPreferences? _prefs;
final Map<String, TransactionMeta> _cache = {};
/// Singleton
static final TransactionMetaStorage _instance = TransactionMetaStorage._internal();
factory TransactionMetaStorage() => _instance;
TransactionMetaStorage._internal();
/// Inicializa el storage. Llamar antes de usar.
Future<void> init() async {
_prefs = await SharedPreferences.getInstance();
await _loadFromDisk();
}
/// Carga datos desde SharedPreferences al cache en memoria.
Future<void> _loadFromDisk() async {
final jsonStr = _prefs?.getString(_storageKey);
if (jsonStr == null || jsonStr.isEmpty) return;
try {
final decoded = jsonDecode(jsonStr) as Map<String, dynamic>;
_cache.clear();
for (final entry in decoded.entries) {
_cache[entry.key] = TransactionMeta.fromJson(
entry.value as Map<String, dynamic>,
);
}
} catch (e) {
// Si hay error de parsing, empezar limpio
_cache.clear();
}
}
/// Guarda el cache en SharedPreferences.
Future<void> _saveToDisk() async {
final jsonMap = <String, dynamic>{};
for (final entry in _cache.entries) {
jsonMap[entry.key] = entry.value.toJson();
}
await _prefs?.setString(_storageKey, jsonEncode(jsonMap));
}
/// Guarda metadata para una transacción.
Future<void> save(String transactionId, TransactionMeta meta) async {
_cache[transactionId] = meta;
await _saveToDisk();
}
/// Obtiene metadata de una transacción.
TransactionMeta? get(String transactionId) {
return _cache[transactionId];
}
/// Verifica si existe metadata para una transacción.
bool has(String transactionId) {
return _cache.containsKey(transactionId);
}
/// Elimina metadata de una transacción.
Future<void> remove(String transactionId) async {
_cache.remove(transactionId);
await _saveToDisk();
}
/// Elimina todas las metadata.
Future<void> clear() async {
_cache.clear();
await _prefs?.remove(_storageKey);
}
/// Obtiene el tipo de una transacción.
/// Primero busca en metadata del CDK, luego en storage local.
TransactionType getType(String transactionId, Map<String, String>? cdkMetadata) {
// 1. Buscar en metadata del CDK
final cdkType = cdkMetadata?['type'];
if (cdkType == 'cashu') return TransactionType.cashu;
if (cdkType == 'lightning') return TransactionType.lightning;
// 2. Buscar en storage local
final localMeta = get(transactionId);
if (localMeta != null) return localMeta.type;
// 3. Default: cashu (la mayoría de transacciones son cashu)
return TransactionType.cashu;
}
/// Limpia entradas huérfanas (IDs que ya no existen en el CDK).
/// Llamar periódicamente para no acumular basura.
Future<int> cleanupOrphans(Set<String> validIds) async {
final orphans = _cache.keys.where((id) => !validIds.contains(id)).toList();
for (final id in orphans) {
_cache.remove(id);
}
if (orphans.isNotEmpty) {
await _saveToDisk();
}
return orphans.length;
}
}
+222 -1
View File
@@ -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
View File
@@ -1,12 +1,12 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:lucide_icons/lucide_icons.dart';
import 'package:cdk_flutter/cdk_flutter.dart' hide WalletProvider;
import '../../core/constants/colors.dart';
import '../../core/constants/dimensions.dart';
import '../../core/utils/formatters.dart';
import '../../widgets/common/gradient_background.dart';
import '../../widgets/common/glass_card.dart';
import '../../widgets/common/animated_action_button.dart';
import '../../widgets/effects/cashu_confetti.dart';
import '../../providers/wallet_provider.dart';
import '../../providers/settings_provider.dart';
@@ -16,6 +16,7 @@ import '../6_mint/mint_screen.dart';
import '../7_melt/melt_screen.dart';
import '../8_settings/settings_screen.dart';
import '../8_settings/mints_screen.dart';
import '../9_history/history_screen.dart';
/// Pantalla principal - Home
/// Muestra balance, acciones principales e historial
@@ -135,6 +136,9 @@ class _HomeScreenState extends State<HomeScreen> {
final walletProvider = context.watch<WalletProvider>();
final settingsProvider = context.read<SettingsProvider>();
final activeUnit = walletProvider.activeUnit;
// Toggle button: "BTC" para sat (como cashu.me)
final toggleLabel = UnitFormatter.getToggleLabel(activeUnit);
// Balance label: "sat" minúsculas (como cashu.me)
final unitLabel = UnitFormatter.getUnitLabel(activeUnit);
return Padding(
@@ -145,85 +149,59 @@ class _HomeScreenState extends State<HomeScreen> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
// Ojo encima del saldo (centrado)
Center(
child: GestureDetector(
onTap: () {
setState(() {
_isBalanceVisible = !_isBalanceVisible;
});
},
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Icon(
_isBalanceVisible ? LucideIcons.eye : LucideIcons.eyeOff,
color: AppColors.textSecondary.withValues(alpha: 0.6),
size: 24,
),
),
),
// Botón toggle de unidad con efecto de hundirse
_UnitToggleButton(
label: toggleLabel,
onTap: () async {
await walletProvider.cycleUnit();
await settingsProvider.setActiveUnit(walletProvider.activeUnit);
},
),
const SizedBox(height: 8),
// Balance reactivo del mint activo
const SizedBox(height: 32),
// Balance tocable para ocultar/mostrar (sin ojito)
StreamBuilder<BigInt>(
stream: walletProvider.streamBalance(),
builder: (context, snapshot) {
final balance = snapshot.data ?? BigInt.zero;
final formattedBalance = UnitFormatter.formatBalance(balance, activeUnit);
return Text(
_isBalanceVisible ? formattedBalance : '••••••',
style: const TextStyle(
fontFamily: 'Inter',
fontSize: 48,
fontWeight: FontWeight.bold,
color: Colors.white,
return GestureDetector(
onTap: () {
setState(() {
_isBalanceVisible = !_isBalanceVisible;
});
},
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.baseline,
textBaseline: TextBaseline.alphabetic,
children: [
Text(
_isBalanceVisible ? formattedBalance : '••••••',
style: const TextStyle(
fontFamily: 'Inter',
fontSize: 48,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
const SizedBox(width: 8),
Text(
_isBalanceVisible ? unitLabel : '',
style: TextStyle(
fontFamily: 'Inter',
fontSize: 32,
fontWeight: FontWeight.w400,
color: Colors.white.withValues(alpha: 0.7),
),
),
],
),
);
},
),
const SizedBox(height: 4),
// Unidad - tap para ciclar
GestureDetector(
onTap: () async {
// Ciclar unidad
await walletProvider.cycleUnit();
// Guardar en settings
await settingsProvider.setActiveUnit(walletProvider.activeUnit);
},
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
decoration: BoxDecoration(
color: AppColors.primaryAction.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(12),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
unitLabel,
style: const TextStyle(
fontFamily: 'Inter',
fontSize: 18,
fontWeight: FontWeight.w600,
color: AppColors.primaryAction,
),
),
// Mostrar indicador si hay más de una unidad
if (walletProvider.activeUnits.length > 1) ...[
const SizedBox(width: 4),
Icon(
LucideIcons.refreshCw,
color: AppColors.primaryAction.withValues(alpha: 0.7),
size: 14,
),
],
],
),
),
),
],
),
);
@@ -247,47 +225,87 @@ class _HomeScreenState extends State<HomeScreen> {
MaterialPageRoute(builder: (context) => const MintsScreen()),
);
},
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
decoration: BoxDecoration(
color: AppColors.success.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: AppColors.success.withValues(alpha: 0.3),
width: 1,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Logo del mint
if (activeMintUrl != null)
FutureBuilder(
future: walletProvider.fetchMintInfo(activeMintUrl),
builder: (context, snapshot) {
final iconUrl = snapshot.data?.iconUrl;
return Container(
width: 36,
height: 36,
margin: const EdgeInsets.only(bottom: 8),
decoration: BoxDecoration(
color: AppColors.primaryAction.withValues(alpha: 0.15),
shape: BoxShape.circle,
),
child: iconUrl != null
? ClipOval(
child: Image.network(
iconUrl,
fit: BoxFit.cover,
errorBuilder: (_, __, ___) => const Icon(
LucideIcons.landmark,
color: AppColors.primaryAction,
size: 18,
),
),
)
: const Icon(
LucideIcons.landmark,
color: AppColors.primaryAction,
size: 18,
),
);
},
),
// Pill del mint
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
decoration: BoxDecoration(
color: AppColors.success.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: AppColors.success.withValues(alpha: 0.3),
width: 1,
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 8,
height: 8,
decoration: BoxDecoration(
color: walletProvider.isInitialized
? AppColors.success
: AppColors.warning,
shape: BoxShape.circle,
),
),
const SizedBox(width: 10),
Text(
displayMint,
style: const TextStyle(
fontFamily: 'Inter',
fontSize: 14,
fontWeight: FontWeight.w500,
color: Colors.white,
),
),
const SizedBox(width: 6),
Icon(
LucideIcons.chevronDown,
color: Colors.white.withValues(alpha: 0.6),
size: 18,
),
],
),
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 8,
height: 8,
decoration: BoxDecoration(
color: walletProvider.isInitialized
? AppColors.success
: AppColors.warning,
shape: BoxShape.circle,
),
),
const SizedBox(width: 10),
Text(
displayMint,
style: const TextStyle(
fontFamily: 'Inter',
fontSize: 14,
fontWeight: FontWeight.w500,
color: Colors.white,
),
),
const SizedBox(width: 6),
Icon(
LucideIcons.chevronDown,
color: Colors.white.withValues(alpha: 0.6),
size: 18,
),
],
),
],
),
),
);
@@ -298,15 +316,20 @@ class _HomeScreenState extends State<HomeScreen> {
padding: const EdgeInsets.all(AppDimensions.paddingMedium),
child: Row(
children: [
// Enviar (primero) - flecha diagonal arriba derecha
// Enviar (primero) - acción crítica que mueve dinero
Expanded(
child: _ActionButton(label: 'Enviar ↗', onTap: _showSendOptions),
child: AnimatedActionButton(
label: 'Enviar ↗',
type: ButtonType.criticalAction,
onTap: _showSendOptions,
),
),
const SizedBox(width: AppDimensions.paddingMedium),
// Recibir (segundo) - flecha diagonal abajo derecha
// Recibir (segundo) - acción importante pero segura
Expanded(
child: _ActionButton(
child: AnimatedActionButton(
label: '↘ Recibir',
type: ButtonType.primaryAction,
onTap: _showReceiveOptions,
),
),
@@ -390,373 +413,20 @@ class _HomeScreenState extends State<HomeScreen> {
Widget _buildHistoryButton() {
return Padding(
padding: const EdgeInsets.all(AppDimensions.paddingMedium),
child: SizedBox(
width: double.infinity,
child: GlassCard(
onTap: _showHistoryModal,
padding: const EdgeInsets.symmetric(
horizontal: AppDimensions.paddingLarge,
vertical: AppDimensions.paddingMedium + 4,
),
child: const Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(LucideIcons.history, color: Colors.white, size: 28),
SizedBox(width: AppDimensions.paddingSmall),
Text(
'Historial',
style: TextStyle(
fontFamily: 'Inter',
fontSize: 20,
fontWeight: FontWeight.w600,
color: Colors.white,
),
),
],
),
),
child: AnimatedActionButton(
label: 'Historial',
type: ButtonType.navigation,
icon: LucideIcons.history,
showIcon: true,
onTap: () {
Navigator.push(
context,
MaterialPageRoute(builder: (context) => const HistoryScreen()),
);
},
),
);
}
void _showHistoryModal() {
showModalBottomSheet(
context: context,
backgroundColor: Colors.transparent,
isScrollControlled: true,
builder: (context) => const _HistoryModal(),
);
}
}
/// Botón de acción para el home
class _ActionButton extends StatelessWidget {
final String label;
final VoidCallback onTap;
const _ActionButton({required this.label, required this.onTap});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
child: Container(
padding: const EdgeInsets.symmetric(
vertical: AppDimensions.paddingMedium,
horizontal: AppDimensions.paddingSmall,
),
decoration: BoxDecoration(
gradient: const LinearGradient(
colors: AppColors.buttonGradient,
begin: Alignment.centerLeft,
end: Alignment.centerRight,
),
borderRadius: BorderRadius.circular(AppDimensions.cardBorderRadius),
boxShadow: [
BoxShadow(
color: AppColors.primaryAction.withValues(alpha: 0.4),
blurRadius: 12,
offset: const Offset(0, 6),
),
],
),
child: Center(
child: Text(
label,
style: const TextStyle(
fontFamily: 'Inter',
fontSize: 18,
fontWeight: FontWeight.w600,
color: Colors.white,
),
),
),
),
);
}
}
/// Modal del historial de transacciones
class _HistoryModal extends StatelessWidget {
const _HistoryModal();
@override
Widget build(BuildContext context) {
final walletProvider = context.watch<WalletProvider>();
return Container(
height: MediaQuery.of(context).size.height * 0.7,
decoration: BoxDecoration(
color: AppColors.deepVoidPurple,
borderRadius: const BorderRadius.vertical(top: Radius.circular(24)),
border: Border.all(
color: Colors.white.withValues(alpha: 0.1),
width: 1,
),
),
child: Column(
children: [
// Handle
Container(
margin: const EdgeInsets.only(top: 12),
width: 40,
height: 4,
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.3),
borderRadius: BorderRadius.circular(2),
),
),
// Título
const Padding(
padding: EdgeInsets.all(AppDimensions.paddingMedium),
child: Text(
'Historial',
style: TextStyle(
fontFamily: 'Inter',
fontSize: 20,
fontWeight: FontWeight.w600,
color: Colors.white,
),
),
),
// Lista de transacciones
Expanded(
child: FutureBuilder<List<Transaction>>(
future: walletProvider.getAllTransactions(),
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(
child: CircularProgressIndicator(
color: AppColors.primaryAction,
),
);
}
final transactions = snapshot.data ?? [];
if (transactions.isEmpty) {
return _buildEmptyHistory();
}
return ListView.builder(
padding: const EdgeInsets.symmetric(
horizontal: AppDimensions.paddingMedium,
),
itemCount: transactions.length,
itemBuilder: (context, index) {
final tx = transactions[index];
return _TransactionTile(transaction: tx);
},
);
},
),
),
],
),
);
}
Widget _buildEmptyHistory() {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
LucideIcons.history,
size: 48,
color: AppColors.textSecondary.withValues(alpha: 0.3),
),
const SizedBox(height: AppDimensions.paddingMedium),
Text(
'Sin transacciones aún',
style: TextStyle(
fontFamily: 'Inter',
fontSize: 16,
color: AppColors.textSecondary.withValues(alpha: 0.5),
),
),
const SizedBox(height: AppDimensions.paddingSmall),
Text(
'Recibe tokens Cashu para empezar',
style: TextStyle(
fontFamily: 'Inter',
fontSize: 14,
color: AppColors.textSecondary.withValues(alpha: 0.4),
),
),
],
),
);
}
}
/// Tile para mostrar una transacción
class _TransactionTile extends StatelessWidget {
final Transaction transaction;
const _TransactionTile({required this.transaction});
@override
Widget build(BuildContext context) {
final isIncoming = transaction.direction == TransactionDirection.incoming;
final amount = transaction.amount.toInt();
final fee = transaction.fee.toInt();
// Convertir timestamp (BigInt unix) a DateTime
final timestamp = DateTime.fromMillisecondsSinceEpoch(
transaction.timestamp.toInt() * 1000,
);
// Formatear fecha
final dateStr = _formatDate(timestamp);
// Estado (pending o settled)
final isPending = transaction.status == TransactionStatus.pending;
return Container(
margin: const EdgeInsets.only(bottom: AppDimensions.paddingSmall),
padding: const EdgeInsets.all(AppDimensions.paddingMedium),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.05),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: Colors.white.withValues(alpha: 0.1),
width: 1,
),
),
child: Row(
children: [
// Icono de dirección
Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: (isIncoming ? AppColors.success : AppColors.primaryAction)
.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(10),
),
child: Icon(
isIncoming ? LucideIcons.arrowDownLeft : LucideIcons.arrowUpRight,
color: isIncoming ? AppColors.success : AppColors.primaryAction,
size: 20,
),
),
const SizedBox(width: AppDimensions.paddingMedium),
// Info de la transacción
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
isIncoming ? 'Recibido' : 'Enviado',
style: const TextStyle(
fontFamily: 'Inter',
fontSize: 16,
fontWeight: FontWeight.w600,
color: Colors.white,
),
),
if (isPending) ...[
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 6,
vertical: 2,
),
decoration: BoxDecoration(
color: AppColors.warning.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(4),
),
child: const Text(
'Pendiente',
style: TextStyle(
fontFamily: 'Inter',
fontSize: 10,
fontWeight: FontWeight.w500,
color: AppColors.warning,
),
),
),
],
],
),
const SizedBox(height: 2),
Text(
dateStr,
style: TextStyle(
fontFamily: 'Inter',
fontSize: 12,
color: AppColors.textSecondary.withValues(alpha: 0.6),
),
),
if (transaction.memo != null && transaction.memo!.isNotEmpty) ...[
const SizedBox(height: 4),
Text(
transaction.memo!,
style: TextStyle(
fontFamily: 'Inter',
fontSize: 12,
fontStyle: FontStyle.italic,
color: AppColors.textSecondary.withValues(alpha: 0.5),
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
],
),
),
// Monto
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
'${isIncoming ? '+' : '-'}$amount',
style: TextStyle(
fontFamily: 'Inter',
fontSize: 16,
fontWeight: FontWeight.bold,
color: isIncoming ? AppColors.success : AppColors.primaryAction,
),
),
if (fee > 0)
Text(
'fee: $fee',
style: TextStyle(
fontFamily: 'Inter',
fontSize: 10,
color: AppColors.textSecondary.withValues(alpha: 0.5),
),
),
],
),
],
),
);
}
String _formatDate(DateTime date) {
final now = DateTime.now();
final diff = now.difference(date);
if (diff.inMinutes < 1) {
return 'Ahora';
} else if (diff.inHours < 1) {
return 'Hace ${diff.inMinutes} min';
} else if (diff.inDays < 1) {
return 'Hace ${diff.inHours} h';
} else if (diff.inDays < 7) {
return 'Hace ${diff.inDays} días';
} else {
return '${date.day}/${date.month}/${date.year}';
}
}
}
/// Modelo para opciones del selector
@@ -910,3 +580,91 @@ class _MethodOptionTile extends StatelessWidget {
);
}
}
/// Botón de toggle de unidad con efecto de hundirse
class _UnitToggleButton extends StatefulWidget {
final String label;
final VoidCallback onTap;
const _UnitToggleButton({
required this.label,
required this.onTap,
});
@override
State<_UnitToggleButton> createState() => _UnitToggleButtonState();
}
class _UnitToggleButtonState extends State<_UnitToggleButton>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _scaleAnimation;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 100),
);
_scaleAnimation = Tween<double>(
begin: 1.0,
end: 0.95,
).animate(CurvedAnimation(
parent: _controller,
curve: Curves.easeOutCubic,
));
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
void _handleTapDown(TapDownDetails details) {
_controller.forward();
}
void _handleTapUp(TapUpDetails details) {
_controller.reverse();
widget.onTap();
}
void _handleTapCancel() {
_controller.reverse();
}
@override
Widget build(BuildContext context) {
return GestureDetector(
onTapDown: _handleTapDown,
onTapUp: _handleTapUp,
onTapCancel: _handleTapCancel,
child: AnimatedBuilder(
animation: _controller,
builder: (context, child) {
return Transform.scale(
scale: _scaleAnimation.value,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(24),
),
child: Text(
widget.label,
style: const TextStyle(
fontFamily: 'Inter',
fontSize: 16,
fontWeight: FontWeight.w600,
color: Colors.white,
),
),
),
);
},
),
);
}
}
@@ -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),
),
),
],
),
);
}
}
+194 -473
View File
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:lucide_icons/lucide_icons.dart';
import 'package:provider/provider.dart';
import 'package:cdk_flutter/cdk_flutter.dart' show MintInfo;
import '../../core/constants/colors.dart';
import '../../core/constants/dimensions.dart';
import '../../core/utils/formatters.dart';
@@ -8,6 +9,7 @@ import '../../providers/wallet_provider.dart';
import '../../widgets/common/gradient_background.dart';
import '../../widgets/common/glass_card.dart';
import '../../widgets/common/primary_button.dart';
import 'mint_detail_screen.dart';
/// Pantalla para gestionar mints conectados
class MintsScreen extends StatefulWidget {
@@ -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,
),
),
],
),
),
),
);
}
}