Compare commits

...
Author SHA1 Message Date
Forte11Cuba 0a935193c2 feat(receive): auto-detect token unit from V3/V4 tokens 2026-02-06 02:05:52 -06:00
Forte11andGitHub a0d55df497 Merge pull request #4 from Forte11Cuba/feature/multi-unit-support
feat: add multi-unit currency support (sat, usd, eur)
2026-02-06 01:20:26 -06:00
Forte11Cuba 8ce9ed37bd feat: add multi-unit currency support (sat, usd, eur) 2026-02-06 01:19:34 -06:00
Forte11andGitHub 862d9e36ac Merge pull request #3 from Forte11Cuba/fix/balance-mint-activo
fix: show active mint balance instead of total balance
2026-02-05 21:26:18 -06:00
Forte11Cuba a3f17210bc fix: show active mint balance instead of total balance 2026-02-05 21:25:49 -06:00
Forte11andGitHub 6a1d956bef Merge pull request #2 from Forte11Cuba/feature/animated-ur-qr-codes
feat: implement animated UR QR codes for token sharing
2026-02-05 21:11:54 -06:00
Forte11Cuba 8896bc83f7 feat: implement animated UR QR codes for token sharing 2026-02-05 21:09:36 -06:00
Forte11andGitHub e607148cca Merge pull request #1 from Forte11Cuba/nut13
feat: implement NUT-13 restore to recover tokens
2026-02-05 20:50:32 -06:00
15 changed files with 1462 additions and 591 deletions
+123
View File
@@ -0,0 +1,123 @@
import 'package:intl/intl.dart';
/// Utilidades para formatear montos y unidades en El Caju.
///
/// USD y EUR usan centavos (multiplicador 100).
/// SAT y otras unidades usan valores enteros.
class UnitFormatter {
/// Multiplicador para convertir a unidad base.
/// USD/EUR almacenan centavos, display en unidades.
static int getMultiplier(String unit) {
switch (unit.toLowerCase()) {
case 'usd':
case 'eur':
return 100; // centavos
default:
return 1;
}
}
/// Formatea un balance para display.
/// Ejemplo: 1000 sat → "1,000", 500 usd → "5.00"
static String formatBalance(BigInt amount, String unit) {
final multiplier = getMultiplier(unit);
if (multiplier == 100) {
// USD/EUR: mostrar con 2 decimales
final value = amount.toDouble() / 100;
return value.toStringAsFixed(2);
} else {
// SAT y otros: entero con separador de miles
return NumberFormat('#,###').format(amount.toInt());
}
}
/// Formatea un balance con su unidad.
/// Ejemplo: 1000 sat → "1,000 BTC", 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
static String getUnitLabel(String unit) {
switch (unit.toLowerCase()) {
case 'sat':
return 'BTC';
case 'usd':
return 'USD';
case 'eur':
return 'EUR';
case 'msat':
return 'MSAT';
default:
return unit.toUpperCase();
}
}
/// Convierte input del usuario a BigInt para la unidad.
/// Ejemplo: "5.00" USD → BigInt(500)
static BigInt parseUserInput(String input, String unit) {
final multiplier = getMultiplier(unit);
// Limpiar input
final cleaned = input.replaceAll(',', '').replaceAll(' ', '');
if (multiplier == 100) {
// USD/EUR: parsear como decimal
final value = double.tryParse(cleaned) ?? 0.0;
return BigInt.from((value * 100).round());
} else {
// SAT: parsear como entero
return BigInt.from(int.tryParse(cleaned) ?? 0);
}
}
/// Obtiene el nombre del host de un mint URL.
/// Ejemplo: 'https://mint.cubabitcoin.org' → 'cubabitcoin.org'
static String getMintDisplayName(String mintUrl) {
try {
final uri = Uri.parse(mintUrl);
var host = uri.host;
// Limpiar prefijos comunes
host = host.replaceFirst('mint.', '');
host = host.replaceFirst('www.', '');
return host;
} catch (e) {
return mintUrl;
}
}
/// Formatea una fecha relativa.
/// Ejemplo: hace 5 min, hace 2 h, ayer, 15 ene
static String formatRelativeDate(DateTime date) {
final now = DateTime.now();
final difference = now.difference(date);
if (difference.inSeconds < 60) {
return 'Ahora';
} else if (difference.inMinutes < 60) {
return 'Hace ${difference.inMinutes} min';
} else if (difference.inHours < 24) {
return 'Hace ${difference.inHours} h';
} else if (difference.inDays == 1) {
return 'Ayer';
} else if (difference.inDays < 7) {
return 'Hace ${difference.inDays} días';
} else {
return DateFormat('d MMM').format(date);
}
}
/// Convierte timestamp BigInt (unix seconds) a DateTime.
static DateTime timestampToDateTime(BigInt timestamp) {
return DateTime.fromMillisecondsSinceEpoch(
timestamp.toInt() * 1000,
);
}
}
+36
View File
@@ -16,6 +16,8 @@ class SettingsProvider extends ChangeNotifier {
static const _defaultMintKey = 'default_mint';
static const _localeKey = 'locale';
static const _pinEnabledKey = 'pin_enabled';
static const _activeUnitKey = 'active_unit';
static const _activeMintUrlKey = 'active_mint_url';
// Estado interno
bool _isInitialized = false;
@@ -24,6 +26,8 @@ class SettingsProvider extends ChangeNotifier {
String? _pin;
String _locale = 'es';
String _defaultMint = 'https://mint.cubabitcoin.org';
String _activeUnit = 'sat';
String? _activeMintUrl;
// Getters
bool get isInitialized => _isInitialized;
@@ -32,6 +36,8 @@ class SettingsProvider extends ChangeNotifier {
bool get hasPin => _pin != null && _pin!.isNotEmpty;
String get locale => _locale;
String get defaultMint => _defaultMint;
String get activeUnit => _activeUnit;
String? get activeMintUrl => _activeMintUrl;
// ============================================================
// INICIALIZACION
@@ -49,6 +55,8 @@ class SettingsProvider extends ChangeNotifier {
_locale = _prefs?.getString(_localeKey) ?? 'es';
_defaultMint = _prefs?.getString(_defaultMintKey) ?? 'https://mint.cubabitcoin.org';
_pinEnabled = _prefs?.getBool(_pinEnabledKey) ?? false;
_activeUnit = _prefs?.getString(_activeUnitKey) ?? 'sat';
_activeMintUrl = _prefs?.getString(_activeMintUrlKey);
// Cargar PIN si existe
if (_pinEnabled) {
@@ -142,6 +150,28 @@ class SettingsProvider extends ChangeNotifier {
notifyListeners();
}
// ============================================================
// UNIDAD Y MINT ACTIVO
// ============================================================
/// Guarda la unidad activa (sat, usd, eur, etc.).
Future<void> setActiveUnit(String unit) async {
await _prefs?.setString(_activeUnitKey, unit);
_activeUnit = unit;
notifyListeners();
}
/// Guarda el mint activo.
Future<void> setActiveMintUrl(String? mintUrl) async {
if (mintUrl != null) {
await _prefs?.setString(_activeMintUrlKey, mintUrl);
} else {
await _prefs?.remove(_activeMintUrlKey);
}
_activeMintUrl = mintUrl;
notifyListeners();
}
// ============================================================
// BORRAR WALLET
// ============================================================
@@ -156,12 +186,16 @@ class SettingsProvider extends ChangeNotifier {
// Resetear preferencias
await _prefs?.setBool(_hasWalletKey, false);
await _prefs?.setBool(_pinEnabledKey, false);
await _prefs?.remove(_activeUnitKey);
await _prefs?.remove(_activeMintUrlKey);
// Mantener locale y defaultMint (preferencias de app, no de wallet)
// Resetear estado
_hasWallet = false;
_pinEnabled = false;
_pin = null;
_activeUnit = 'sat';
_activeMintUrl = null;
notifyListeners();
}
@@ -180,6 +214,8 @@ class SettingsProvider extends ChangeNotifier {
_pin = null;
_locale = 'es';
_defaultMint = 'https://mint.cubabitcoin.org';
_activeUnit = 'sat';
_activeMintUrl = null;
notifyListeners();
}
File diff suppressed because it is too large Load Diff
+42
View File
@@ -1,3 +1,4 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
@@ -65,6 +66,12 @@ class _SplashScreenState extends State<SplashScreen>
if (!mounted) return;
// Restaurar mint y unidad activa desde settings
await _restoreActiveState(settingsProvider, walletProvider);
// Verificar proofs en background (no bloquea navegación)
unawaited(_checkPendingTransactions(walletProvider));
// Ir al Home
_navigateTo(const HomeScreen());
} else {
@@ -91,6 +98,41 @@ class _SplashScreenState extends State<SplashScreen>
}
}
/// Restaura el mint y unidad activa desde SettingsProvider.
Future<void> _restoreActiveState(
SettingsProvider settings,
WalletProvider wallet,
) async {
try {
// Restaurar mint activo si está guardado
final savedMintUrl = settings.activeMintUrl;
if (savedMintUrl != null && wallet.mintUrls.contains(savedMintUrl)) {
await wallet.setActiveMint(savedMintUrl);
}
// Restaurar unidad activa si está guardada y es soportada
final savedUnit = settings.activeUnit;
if (wallet.activeUnits.contains(savedUnit)) {
await wallet.setActiveUnit(savedUnit);
}
} catch (e) {
debugPrint('Error restaurando estado activo: $e');
// No bloquear si falla, usar defaults
}
}
/// Verifica proofs pendientes en background.
/// Se ejecuta sin bloquear la navegación.
Future<void> _checkPendingTransactions(WalletProvider wallet) async {
try {
await wallet.checkPendingTransactions();
debugPrint('Verificación de proofs completada');
} catch (e) {
debugPrint('Error verificando proofs: $e');
// Silencioso - no mostrar error al usuario
}
}
void _navigateTo(Widget screen) {
Navigator.pushReplacement(
context,
+49 -39
View File
@@ -4,10 +4,12 @@ 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/effects/cashu_confetti.dart';
import '../../providers/wallet_provider.dart';
import '../../providers/settings_provider.dart';
import '../4_receive/receive_screen.dart';
import '../5_send/send_screen.dart';
import '../6_mint/mint_screen.dart';
@@ -131,6 +133,9 @@ class _HomeScreenState extends State<HomeScreen> {
Widget _buildBalanceSection() {
final walletProvider = context.watch<WalletProvider>();
final settingsProvider = context.read<SettingsProvider>();
final activeUnit = walletProvider.activeUnit;
final unitLabel = UnitFormatter.getUnitLabel(activeUnit);
return Padding(
padding: const EdgeInsets.symmetric(
@@ -160,15 +165,15 @@ class _HomeScreenState extends State<HomeScreen> {
),
const SizedBox(height: 8),
// Balance reactivo con StreamBuilder
// Balance reactivo del mint activo
StreamBuilder<BigInt>(
stream: walletProvider.streamTotalBalance(),
stream: walletProvider.streamBalance(),
builder: (context, snapshot) {
final balance = snapshot.data ?? BigInt.zero;
final balanceInt = balance.toInt();
final formattedBalance = UnitFormatter.formatBalance(balance, activeUnit);
return Text(
_isBalanceVisible ? _formatBalance(balanceInt) : '••••••',
_isBalanceVisible ? formattedBalance : '••••••',
style: const TextStyle(
fontFamily: 'Inter',
fontSize: 48,
@@ -180,20 +185,42 @@ class _HomeScreenState extends State<HomeScreen> {
),
const SizedBox(height: 4),
// Unidad (siempre sats por ahora)
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
decoration: BoxDecoration(
color: AppColors.primaryAction.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(12),
),
child: const Text(
'sats',
style: TextStyle(
fontFamily: 'Inter',
fontSize: 18,
fontWeight: FontWeight.w600,
color: AppColors.primaryAction,
// 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,
),
],
],
),
),
),
@@ -206,16 +233,10 @@ class _HomeScreenState extends State<HomeScreen> {
final walletProvider = context.watch<WalletProvider>();
final activeMintUrl = walletProvider.activeMintUrl;
// Extraer solo el host del URL para mostrar
String displayMint = 'Sin mint';
if (activeMintUrl != null) {
try {
final uri = Uri.parse(activeMintUrl);
displayMint = uri.host;
} catch (_) {
displayMint = activeMintUrl;
}
}
// Extraer nombre del mint para mostrar
final displayMint = activeMintUrl != null
? UnitFormatter.getMintDisplayName(activeMintUrl)
: 'Sin mint';
return Padding(
padding: const EdgeInsets.symmetric(vertical: AppDimensions.paddingSmall),
@@ -406,17 +427,6 @@ class _HomeScreenState extends State<HomeScreen> {
builder: (context) => const _HistoryModal(),
);
}
String _formatBalance(int amount) {
if (amount >= 1000) {
final formatted = amount.toString().replaceAllMapped(
RegExp(r'(\d{1,3})(?=(\d{3})+(?!\d))'),
(Match m) => '${m[1]},',
);
return formatted;
}
return amount.toString();
}
}
/// Botón de acción para el home
+50 -28
View File
@@ -4,6 +4,7 @@ import 'package:provider/provider.dart';
import 'package:lucide_icons/lucide_icons.dart';
import '../../core/constants/colors.dart';
import '../../core/constants/dimensions.dart';
import '../../core/utils/formatters.dart';
import '../../widgets/common/gradient_background.dart';
import '../../widgets/common/glass_card.dart';
import '../../widgets/common/primary_button.dart';
@@ -26,7 +27,8 @@ class _ReceiveScreenState extends State<ReceiveScreen> {
bool _isValidToken = false;
bool _isProcessing = false;
bool _showSuccess = false;
int _receivedAmount = 0;
BigInt _receivedAmount = BigInt.zero;
String? _receivedUnit; // Se asigna al reclamar desde walletProvider.activeUnit
TokenInfo? _tokenInfo;
String? _errorMessage;
@@ -132,14 +134,19 @@ class _ReceiveScreenState extends State<ReceiveScreen> {
const SizedBox(height: AppDimensions.paddingLarge),
// Monto recibido
Text(
'+$_receivedAmount sats',
style: const TextStyle(
fontFamily: 'Inter',
fontSize: 36,
fontWeight: FontWeight.bold,
color: Colors.white,
),
Builder(
builder: (context) {
final unit = _receivedUnit ?? context.read<WalletProvider>().activeUnit;
return Text(
'+${UnitFormatter.formatBalance(_receivedAmount, unit)} ${UnitFormatter.getUnitLabel(unit)}',
style: const TextStyle(
fontFamily: 'Inter',
fontSize: 36,
fontWeight: FontWeight.bold,
color: Colors.white,
),
);
},
),
const SizedBox(height: AppDimensions.paddingSmall),
@@ -229,14 +236,12 @@ class _ReceiveScreenState extends State<ReceiveScreen> {
}
Widget _buildTokenPreview() {
final amount = _tokenInfo!.amount.toInt();
// Extraer solo el host del mint URL
String mintDisplay = _tokenInfo!.mintUrl;
try {
final uri = Uri.parse(_tokenInfo!.mintUrl);
mintDisplay = uri.host;
} catch (_) {}
final walletProvider = context.read<WalletProvider>();
final amount = _tokenInfo!.amount;
// Usar unidad detectada del token, o fallback a unidad activa
final tokenUnit = _tokenInfo!.unit ?? walletProvider.activeUnit;
final mintDisplay = UnitFormatter.getMintDisplayName(_tokenInfo!.mintUrl);
final unitDetected = _tokenInfo!.unit != null;
return GlassCard(
padding: const EdgeInsets.all(AppDimensions.paddingMedium),
@@ -271,7 +276,7 @@ class _ReceiveScreenState extends State<ReceiveScreen> {
),
const SizedBox(height: AppDimensions.paddingMedium),
// Monto
// Monto con unidad detectada
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
@@ -283,14 +288,27 @@ class _ReceiveScreenState extends State<ReceiveScreen> {
color: AppColors.textSecondary.withValues(alpha: 0.7),
),
),
Text(
'$amount sats',
style: const TextStyle(
fontFamily: 'Inter',
fontSize: 18,
fontWeight: FontWeight.bold,
color: Colors.white,
),
Row(
children: [
Text(
'${UnitFormatter.formatBalance(amount, tokenUnit)} ${UnitFormatter.getUnitLabel(tokenUnit)}',
style: const TextStyle(
fontFamily: 'Inter',
fontSize: 18,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
// Indicador si la unidad fue detectada automáticamente
if (!unitDetected) ...[
const SizedBox(width: 4),
Icon(
LucideIcons.helpCircle,
color: AppColors.textSecondary.withValues(alpha: 0.5),
size: 14,
),
],
],
),
],
),
@@ -410,14 +428,18 @@ class _ReceiveScreenState extends State<ReceiveScreen> {
try {
final walletProvider = context.read<WalletProvider>();
// Reclamar token real
// Guardar unidad detectada del token ANTES de reclamar
final detectedUnit = _tokenInfo?.unit ?? walletProvider.activeUnit;
// Reclamar token (usa unidad detectada internamente)
final amountReceived = await walletProvider.receiveToken(
_tokenController.text.trim(),
);
if (mounted) {
setState(() {
_receivedAmount = amountReceived.toInt();
_receivedAmount = amountReceived;
_receivedUnit = detectedUnit; // Usar unidad del token
_showSuccess = true;
_isProcessing = false;
});
+24 -19
View File
@@ -3,6 +3,7 @@ import 'package:provider/provider.dart';
import 'package:lucide_icons/lucide_icons.dart';
import '../../core/constants/colors.dart';
import '../../core/constants/dimensions.dart';
import '../../core/utils/formatters.dart';
import '../../widgets/common/gradient_background.dart';
import '../../widgets/common/glass_card.dart';
import '../../widgets/common/primary_button.dart';
@@ -21,24 +22,24 @@ class _SendScreenState extends State<SendScreen> {
final TextEditingController _amountController = TextEditingController();
final TextEditingController _memoController = TextEditingController();
final String _unit = 'sats';
bool _isProcessing = false;
String? _errorMessage;
int _availableBalance = 0;
BigInt _availableBalance = BigInt.zero;
late String _activeUnit;
@override
void initState() {
super.initState();
_activeUnit = context.read<WalletProvider>().activeUnit;
_loadBalance();
}
Future<void> _loadBalance() async {
final walletProvider = context.read<WalletProvider>();
final balance = await walletProvider.getTotalBalance();
final balance = await walletProvider.getBalance();
if (mounted) {
setState(() {
_availableBalance = balance.toInt();
_availableBalance = balance;
});
}
}
@@ -50,8 +51,13 @@ class _SendScreenState extends State<SendScreen> {
super.dispose();
}
int get _amount => int.tryParse(_amountController.text) ?? 0;
bool get _isValidAmount => _amount > 0 && _amount <= _availableBalance;
/// Obtiene la etiqueta de la unidad para display
String get _unitLabel => UnitFormatter.getUnitLabel(_activeUnit);
/// Parsea el input del usuario a BigInt según la unidad
BigInt get _amount => UnitFormatter.parseUserInput(_amountController.text, _activeUnit);
bool get _isValidAmount => _amount > BigInt.zero && _amount <= _availableBalance;
@override
Widget build(BuildContext context) {
@@ -154,7 +160,7 @@ class _SendScreenState extends State<SendScreen> {
),
),
Text(
_unit,
_unitLabel,
style: TextStyle(
fontFamily: 'Inter',
fontSize: 18,
@@ -185,7 +191,7 @@ class _SendScreenState extends State<SendScreen> {
child: Row(
children: [
Text(
'$_availableBalance $_unit',
'${UnitFormatter.formatBalance(_availableBalance, _activeUnit)} $_unitLabel',
style: TextStyle(
fontFamily: 'Inter',
fontSize: 14,
@@ -300,7 +306,8 @@ class _SendScreenState extends State<SendScreen> {
}
void _setMaxAmount() {
_amountController.text = _availableBalance.toString();
// Formatear el balance para el input (sin separadores de miles)
_amountController.text = UnitFormatter.formatBalance(_availableBalance, _activeUnit).replaceAll(',', '');
setState(() {
_errorMessage = null;
});
@@ -312,7 +319,7 @@ class _SendScreenState extends State<SendScreen> {
backgroundColor: Colors.transparent,
builder: (context) => _ConfirmationModal(
amount: _amount,
unit: _unit,
unit: _activeUnit,
memo: _memoController.text.isNotEmpty ? _memoController.text : null,
onConfirm: () {
Navigator.pop(context);
@@ -334,10 +341,8 @@ class _SendScreenState extends State<SendScreen> {
// Crear token real con cdk-flutter
final memo = _memoController.text.isNotEmpty ? _memoController.text : null;
final token = await walletProvider.sendTokens(
BigInt.from(_amount),
memo,
);
final amount = _amount;
final token = await walletProvider.sendTokens(amount, memo);
if (mounted) {
Navigator.pushReplacement(
@@ -345,8 +350,8 @@ class _SendScreenState extends State<SendScreen> {
MaterialPageRoute(
builder: (context) => ShareTokenScreen(
token: token,
amount: _amount,
unit: _unit,
amount: amount,
unit: _activeUnit,
memo: memo,
),
),
@@ -373,7 +378,7 @@ class _SendScreenState extends State<SendScreen> {
/// Modal de confirmacion
class _ConfirmationModal extends StatelessWidget {
final int amount;
final BigInt amount;
final String unit;
final String? memo;
final VoidCallback onConfirm;
@@ -443,7 +448,7 @@ class _ConfirmationModal extends StatelessWidget {
// Monto
Text(
'$amount $unit',
'${UnitFormatter.formatBalance(amount, unit)} ${UnitFormatter.getUnitLabel(unit)}',
style: const TextStyle(
fontFamily: 'Inter',
fontSize: 32,
+167 -115
View File
@@ -4,8 +4,10 @@ import 'package:flutter/services.dart';
import 'package:qr_flutter/qr_flutter.dart';
import 'package:lucide_icons/lucide_icons.dart';
import 'package:share_plus/share_plus.dart';
import 'package:cdk_flutter/cdk_flutter.dart' as cdk;
import '../../core/constants/colors.dart';
import '../../core/constants/dimensions.dart';
import '../../core/utils/formatters.dart';
import '../../widgets/common/gradient_background.dart';
import '../../widgets/common/glass_card.dart';
import '../../widgets/common/primary_button.dart';
@@ -13,7 +15,7 @@ import '../../widgets/common/primary_button.dart';
/// Pantalla para compartir el token Cashu generado
class ShareTokenScreen extends StatefulWidget {
final String token;
final int amount;
final BigInt amount;
final String unit;
final String? memo;
@@ -30,61 +32,74 @@ class ShareTokenScreen extends StatefulWidget {
}
class _ShareTokenScreenState extends State<ShareTokenScreen> {
late PageController _pageController;
late List<String> _qrParts;
int _currentPage = 0;
Timer? _timer;
List<String> _urFragments = [];
int _currentFragment = 0;
Timer? _animationTimer;
// Configuración de velocidad (como cashu.me)
static const int _intervalFast = 100; // ms
static const int _intervalMedium = 200; // ms
static const int _intervalSlow = 400; // ms
int _currentInterval = _intervalMedium;
String _speedLabel = 'M';
@override
void initState() {
super.initState();
_pageController = PageController();
_qrParts = _encodeTokenToQR();
_startQRAnimation();
_encodeTokenToUR();
}
@override
void dispose() {
_timer?.cancel();
_pageController.dispose();
_animationTimer?.cancel();
super.dispose();
}
List<String> _encodeTokenToQR() {
// TODO: Implementar con cdk-flutter cuando esté disponible
// final qrParts = encodeQrToken(token: widget.token);
void _encodeTokenToUR() {
// Parsear el token string a objeto Token de cdk-flutter
final token = cdk.Token.parse(encoded: widget.token);
// Por ahora, simulamos fragmentos UR para el token
if (widget.token.length <= 200) {
return [widget.token];
}
// Codificar a fragmentos UR usando cdk-flutter
// maxFragmentLength por defecto es 150 bytes
_urFragments = cdk.encodeQrToken(token: token);
// Simular fragmentación para tokens largos
final parts = <String>[];
const chunkSize = 200;
for (int i = 0; i < widget.token.length; i += chunkSize) {
final end = (i + chunkSize < widget.token.length)
? i + chunkSize
: widget.token.length;
parts.add(widget.token.substring(i, end));
// Si hay múltiples fragmentos, iniciar animación
if (_urFragments.length > 1) {
_startAnimation();
}
return parts;
}
void _startQRAnimation() {
// Solo animar si hay múltiples fragmentos
if (_qrParts.length <= 1) return;
void _startAnimation() {
_animationTimer?.cancel();
_animationTimer = Timer.periodic(
Duration(milliseconds: _currentInterval),
(timer) {
if (mounted) {
setState(() {
_currentFragment = (_currentFragment + 1) % _urFragments.length;
});
}
},
);
}
_timer = Timer.periodic(const Duration(seconds: 2), (timer) {
if (mounted) {
final nextPage = (_currentPage + 1) % _qrParts.length;
_pageController.animateToPage(
nextPage,
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
);
void _cycleSpeed() {
setState(() {
if (_currentInterval == _intervalMedium) {
_currentInterval = _intervalSlow;
_speedLabel = 'S';
} else if (_currentInterval == _intervalSlow) {
_currentInterval = _intervalFast;
_speedLabel = 'F';
} else {
_currentInterval = _intervalMedium;
_speedLabel = 'M';
}
});
// Reiniciar timer con nueva velocidad
if (_urFragments.length > 1) {
_startAnimation();
}
}
@override
@@ -133,11 +148,6 @@ class _ShareTokenScreenState extends State<ShareTokenScreen> {
// Token truncado
_buildTokenTextDisplay(),
const SizedBox(height: AppDimensions.paddingSmall),
// Indicador de página (si hay múltiples fragmentos)
if (_qrParts.length > 1) _buildPageIndicator(),
const SizedBox(height: AppDimensions.paddingMedium),
// Botones copiar y compartir
@@ -176,10 +186,13 @@ class _ShareTokenScreenState extends State<ShareTokenScreen> {
}
Widget _buildAmountDisplay() {
final formattedAmount = UnitFormatter.formatBalance(widget.amount, widget.unit);
final unitLabel = UnitFormatter.getUnitLabel(widget.unit);
return Column(
children: [
Text(
'${widget.amount} ${widget.unit}',
'$formattedAmount $unitLabel',
style: const TextStyle(
fontFamily: 'Inter',
fontSize: 36,
@@ -204,58 +217,111 @@ class _ShareTokenScreenState extends State<ShareTokenScreen> {
}
Widget _buildQRDisplay() {
if (_urFragments.isEmpty) {
return const SizedBox(
width: 220,
height: 220,
child: Center(child: CircularProgressIndicator()),
);
}
final size = 220.0;
return Container(
width: size,
height: size,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(20),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.2),
blurRadius: 20,
offset: const Offset(0, 8),
final isAnimated = _urFragments.length > 1;
return Column(
children: [
// QR Container
Container(
width: size,
height: size,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(20),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.2),
blurRadius: 20,
offset: const Offset(0, 8),
),
],
),
],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(20),
child: _qrParts.length > 1
? PageView.builder(
controller: _pageController,
onPageChanged: (index) {
setState(() {
_currentPage = index;
});
},
itemCount: _qrParts.length,
itemBuilder: (context, index) {
return Center(
child: Padding(
padding: const EdgeInsets.all(20),
child: QrImageView(
data: _qrParts[index],
version: QrVersions.auto,
size: size - 40,
backgroundColor: Colors.white,
errorCorrectionLevel: QrErrorCorrectLevel.M,
),
),
);
},
)
: Padding(
padding: const EdgeInsets.all(20),
child: QrImageView(
data: _qrParts[0],
version: QrVersions.auto,
size: size - 40,
backgroundColor: Colors.white,
errorCorrectionLevel: QrErrorCorrectLevel.M,
child: ClipRRect(
borderRadius: BorderRadius.circular(20),
child: Padding(
padding: const EdgeInsets.all(16),
child: QrImageView(
data: _urFragments[_currentFragment],
version: QrVersions.auto,
size: size - 32,
backgroundColor: Colors.white,
errorCorrectionLevel: QrErrorCorrectLevel.L,
),
),
),
),
// Controles de animación (solo si hay múltiples fragmentos)
if (isAnimated) ...[
const SizedBox(height: 12),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// Indicador de fragmento actual
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(20),
),
child: Text(
'${_currentFragment + 1} / ${_urFragments.length}',
style: const TextStyle(
fontFamily: 'Inter',
fontSize: 14,
fontWeight: FontWeight.w600,
color: Colors.white,
),
),
),
),
const SizedBox(width: 12),
// Botón de velocidad
GestureDetector(
onTap: _cycleSpeed,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: AppColors.primaryAction.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: AppColors.primaryAction.withValues(alpha: 0.5),
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
LucideIcons.gauge,
color: AppColors.primaryAction,
size: 16,
),
const SizedBox(width: 6),
Text(
_speedLabel,
style: TextStyle(
fontFamily: 'Inter',
fontSize: 14,
fontWeight: FontWeight.w600,
color: AppColors.primaryAction,
),
),
],
),
),
),
],
),
],
],
);
}
@@ -264,6 +330,8 @@ class _ShareTokenScreenState extends State<ShareTokenScreen> {
? '${widget.token.substring(0, 25)}...${widget.token.substring(widget.token.length - 20)}'
: widget.token;
final isAnimated = _urFragments.length > 1;
return GlassCard(
padding: const EdgeInsets.all(AppDimensions.paddingSmall),
child: Column(
@@ -274,7 +342,9 @@ class _ShareTokenScreenState extends State<ShareTokenScreen> {
Icon(LucideIcons.bean, color: AppColors.primaryAction, size: 16),
const SizedBox(width: 6),
Text(
'Token Cashu (${_qrParts.length} ${_qrParts.length == 1 ? "parte" : "partes"})',
isAnimated
? 'Token Cashu (QR animado - ${_urFragments.length} fragmentos UR)'
: 'Token Cashu',
style: TextStyle(
fontFamily: 'Inter',
fontSize: 12,
@@ -298,27 +368,6 @@ class _ShareTokenScreenState extends State<ShareTokenScreen> {
);
}
Widget _buildPageIndicator() {
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: List.generate(
_qrParts.length,
(index) => AnimatedContainer(
duration: const Duration(milliseconds: 300),
margin: const EdgeInsets.symmetric(horizontal: 4),
width: index == _currentPage ? 24 : 8,
height: 8,
decoration: BoxDecoration(
color: index == _currentPage
? AppColors.primaryAction
: AppColors.textSecondary.withValues(alpha: 0.3),
borderRadius: BorderRadius.circular(4),
),
),
),
);
}
Widget _buildActionButtons(BuildContext context) {
return Row(
children: [
@@ -438,10 +487,13 @@ class _ShareTokenScreenState extends State<ShareTokenScreen> {
? '\n"${widget.memo}"'
: '';
final formattedAmount = UnitFormatter.formatBalance(widget.amount, widget.unit);
final unitLabel = UnitFormatter.getUnitLabel(widget.unit);
await SharePlus.instance.share(
ShareParams(
text: '${widget.amount} ${widget.unit}$memo\n\n${widget.token}',
subject: 'Token Cashu - ${widget.amount} ${widget.unit}',
text: '$formattedAmount $unitLabel$memo\n\n${widget.token}',
subject: 'Token Cashu - $formattedAmount $unitLabel',
),
);
}
+8 -4
View File
@@ -7,6 +7,7 @@ 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/effects/cashu_confetti.dart';
@@ -17,7 +18,7 @@ enum MintStatus { loading, unpaid, paid, issued, error }
/// Pantalla para mostrar el invoice generado y esperar el pago
class InvoiceScreen extends StatefulWidget {
final int amount;
final BigInt amount;
final String unit;
final String? description;
@@ -57,7 +58,7 @@ class _InvoiceScreenState extends State<InvoiceScreen> {
try {
final mintStream = walletProvider.mintTokens(
BigInt.from(widget.amount),
widget.amount,
widget.description,
);
@@ -116,7 +117,7 @@ class _InvoiceScreenState extends State<InvoiceScreen> {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'+${widget.amount} ${widget.unit} depositados',
'+${UnitFormatter.formatBalance(widget.amount, widget.unit)} ${UnitFormatter.getUnitLabel(widget.unit)} depositados',
),
backgroundColor: AppColors.success,
duration: const Duration(seconds: 3),
@@ -320,10 +321,13 @@ class _InvoiceScreenState extends State<InvoiceScreen> {
}
Widget _buildHeader() {
final formattedAmount = UnitFormatter.formatBalance(widget.amount, widget.unit);
final unitLabel = UnitFormatter.getUnitLabel(widget.unit);
return Column(
children: [
Text(
'Depositar ${widget.amount} ${widget.unit}',
'Depositar $formattedAmount $unitLabel',
style: const TextStyle(
fontFamily: 'Inter',
fontSize: 24,
+20 -8
View File
@@ -3,6 +3,7 @@ import 'package:provider/provider.dart';
import 'package:lucide_icons/lucide_icons.dart';
import '../../core/constants/colors.dart';
import '../../core/constants/dimensions.dart';
import '../../core/utils/formatters.dart';
import '../../widgets/common/gradient_background.dart';
import '../../widgets/common/glass_card.dart';
import '../../widgets/common/primary_button.dart';
@@ -21,11 +22,17 @@ class _MintScreenState extends State<MintScreen> {
final TextEditingController _amountController = TextEditingController();
final TextEditingController _descriptionController = TextEditingController();
final String _unit = 'sats';
late String _activeUnit;
bool _isProcessing = false;
String? _errorMessage;
@override
void initState() {
super.initState();
_activeUnit = context.read<WalletProvider>().activeUnit;
}
@override
void dispose() {
_amountController.dispose();
@@ -33,8 +40,13 @@ class _MintScreenState extends State<MintScreen> {
super.dispose();
}
int get _amount => int.tryParse(_amountController.text) ?? 0;
bool get _isValidAmount => _amount > 0;
/// Obtiene la etiqueta de la unidad para display
String get _unitLabel => UnitFormatter.getUnitLabel(_activeUnit);
/// Parsea el input del usuario a BigInt según la unidad
BigInt get _amount => UnitFormatter.parseUserInput(_amountController.text, _activeUnit);
bool get _isValidAmount => _amount > BigInt.zero;
@override
Widget build(BuildContext context) {
@@ -137,7 +149,7 @@ class _MintScreenState extends State<MintScreen> {
),
),
Text(
_unit,
_unitLabel,
style: TextStyle(
fontFamily: 'Inter',
fontSize: 18,
@@ -242,7 +254,7 @@ class _MintScreenState extends State<MintScreen> {
backgroundColor: Colors.transparent,
builder: (context) => _ConfirmationModal(
amount: _amount,
unit: _unit,
unit: _activeUnit,
description: _descriptionController.text.isNotEmpty
? _descriptionController.text
: null,
@@ -262,7 +274,7 @@ class _MintScreenState extends State<MintScreen> {
MaterialPageRoute(
builder: (context) => InvoiceScreen(
amount: _amount,
unit: _unit,
unit: _activeUnit,
description: _descriptionController.text.isNotEmpty
? _descriptionController.text
: null,
@@ -274,7 +286,7 @@ class _MintScreenState extends State<MintScreen> {
/// Modal de confirmación
class _ConfirmationModal extends StatelessWidget {
final int amount;
final BigInt amount;
final String unit;
final String? description;
final VoidCallback onConfirm;
@@ -344,7 +356,7 @@ class _ConfirmationModal extends StatelessWidget {
// Monto
Text(
'$amount $unit',
'${UnitFormatter.formatBalance(amount, unit)} ${UnitFormatter.getUnitLabel(unit)}',
style: const TextStyle(
fontFamily: 'Inter',
fontSize: 32,
+29 -24
View File
@@ -5,6 +5,7 @@ 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/primary_button.dart';
@@ -21,30 +22,34 @@ class MeltScreen extends StatefulWidget {
class _MeltScreenState extends State<MeltScreen> {
final TextEditingController _invoiceController = TextEditingController();
final String _unit = 'sats';
late String _activeUnit;
bool _isValidInvoice = false;
bool _isLoadingQuote = false;
bool _isProcessing = false;
MeltQuote? _quote;
int _invoiceAmount = 0;
int _feeReserve = 0;
int _total = 0;
int _availableBalance = 0;
BigInt _invoiceAmount = BigInt.zero;
BigInt _feeReserve = BigInt.zero;
BigInt _total = BigInt.zero;
BigInt _availableBalance = BigInt.zero;
String? _errorMessage;
@override
void initState() {
super.initState();
_activeUnit = context.read<WalletProvider>().activeUnit;
_loadBalance();
}
/// Obtiene la etiqueta de la unidad para display
String get _unitLabel => UnitFormatter.getUnitLabel(_activeUnit);
Future<void> _loadBalance() async {
final walletProvider = context.read<WalletProvider>();
final balance = await walletProvider.getTotalBalance();
final balance = await walletProvider.getBalance();
if (mounted) {
setState(() {
_availableBalance = balance.toInt();
_availableBalance = balance;
});
}
}
@@ -274,7 +279,7 @@ class _MeltScreenState extends State<MeltScreen> {
),
),
Text(
'$_invoiceAmount $_unit',
'${UnitFormatter.formatBalance(_invoiceAmount, _activeUnit)} $_unitLabel',
style: const TextStyle(
fontFamily: 'Inter',
fontSize: 18,
@@ -299,7 +304,7 @@ class _MeltScreenState extends State<MeltScreen> {
),
),
Text(
'~$_feeReserve $_unit',
'~${UnitFormatter.formatBalance(_feeReserve, _activeUnit)} $_unitLabel',
style: TextStyle(
fontFamily: 'Inter',
fontSize: 14,
@@ -328,7 +333,7 @@ class _MeltScreenState extends State<MeltScreen> {
),
),
Text(
'$_total $_unit',
'${UnitFormatter.formatBalance(_total, _activeUnit)} $_unitLabel',
style: TextStyle(
fontFamily: 'Inter',
fontSize: 20,
@@ -400,7 +405,7 @@ class _MeltScreenState extends State<MeltScreen> {
),
),
Text(
'$_availableBalance $_unit',
'${UnitFormatter.formatBalance(_availableBalance, _activeUnit)} $_unitLabel',
style: const TextStyle(
fontFamily: 'Inter',
fontSize: 14,
@@ -440,9 +445,9 @@ class _MeltScreenState extends State<MeltScreen> {
_isValidInvoice = false;
if (value.isEmpty) {
_invoiceAmount = 0;
_feeReserve = 0;
_total = 0;
_invoiceAmount = BigInt.zero;
_feeReserve = BigInt.zero;
_total = BigInt.zero;
return;
}
});
@@ -475,8 +480,8 @@ class _MeltScreenState extends State<MeltScreen> {
if (mounted) {
setState(() {
_quote = quote;
_invoiceAmount = quote.amount.toInt();
_feeReserve = quote.feeReserve.toInt();
_invoiceAmount = quote.amount;
_feeReserve = quote.feeReserve;
_total = _invoiceAmount + _feeReserve;
_isValidInvoice = true;
_isLoadingQuote = false;
@@ -507,7 +512,7 @@ class _MeltScreenState extends State<MeltScreen> {
amount: _invoiceAmount,
fee: _feeReserve,
total: _total,
unit: _unit,
unit: _activeUnit,
onConfirm: () {
Navigator.pop(context);
_payInvoice();
@@ -535,7 +540,7 @@ class _MeltScreenState extends State<MeltScreen> {
// Mostrar éxito y volver
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('-${totalPaid.toInt()} $_unit enviados'),
content: Text('-${UnitFormatter.formatBalance(totalPaid, _activeUnit)} $_unitLabel enviados'),
backgroundColor: AppColors.success,
duration: const Duration(seconds: 3),
),
@@ -567,9 +572,9 @@ class _MeltScreenState extends State<MeltScreen> {
/// Modal de confirmación
class _ConfirmationModal extends StatelessWidget {
final int amount;
final int fee;
final int total;
final BigInt amount;
final BigInt fee;
final BigInt total;
final String unit;
final VoidCallback onConfirm;
final VoidCallback onCancel;
@@ -639,7 +644,7 @@ class _ConfirmationModal extends StatelessWidget {
// Detalles
Text(
'$amount $unit',
'${UnitFormatter.formatBalance(amount, unit)} ${UnitFormatter.getUnitLabel(unit)}',
style: const TextStyle(
fontFamily: 'Inter',
fontSize: 28,
@@ -648,7 +653,7 @@ class _ConfirmationModal extends StatelessWidget {
),
),
Text(
'+ ~$fee $unit fee',
'+ ~${UnitFormatter.formatBalance(fee, unit)} ${UnitFormatter.getUnitLabel(unit)} fee',
style: TextStyle(
fontFamily: 'Inter',
fontSize: 14,
@@ -657,7 +662,7 @@ class _ConfirmationModal extends StatelessWidget {
),
const SizedBox(height: AppDimensions.paddingSmall),
Text(
'Total: $total $unit',
'Total: ${UnitFormatter.formatBalance(total, unit)} ${UnitFormatter.getUnitLabel(unit)}',
style: const TextStyle(
fontFamily: 'Inter',
fontSize: 16,
+170 -104
View File
@@ -1,9 +1,9 @@
import 'package:flutter/material.dart';
import 'package:lucide_icons/lucide_icons.dart';
import 'package:provider/provider.dart';
import 'package:cdk_flutter/cdk_flutter.dart' hide WalletProvider;
import '../../core/constants/colors.dart';
import '../../core/constants/dimensions.dart';
import '../../core/utils/formatters.dart';
import '../../providers/wallet_provider.dart';
import '../../widgets/common/gradient_background.dart';
import '../../widgets/common/glass_card.dart';
@@ -42,51 +42,38 @@ class _MintsScreenState extends State<MintsScreen> {
body: SafeArea(
child: Consumer<WalletProvider>(
builder: (context, walletProvider, child) {
return FutureBuilder<List<Mint>>(
future: walletProvider.listMints(),
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting && !snapshot.hasData) {
return const Center(
child: CircularProgressIndicator(
color: AppColors.primaryAction,
),
);
}
final mintUrls = walletProvider.mintUrls;
final mints = snapshot.data ?? [];
return Padding(
padding: const EdgeInsets.all(AppDimensions.paddingMedium),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Lista de mints
Expanded(
child: mints.isEmpty
? _buildEmptyState()
: ListView.builder(
itemCount: mints.length,
itemBuilder: (context, index) {
final mint = mints[index];
return Padding(
padding: const EdgeInsets.only(
bottom: AppDimensions.paddingMedium,
),
child: _buildMintCard(
mint,
walletProvider,
),
);
},
),
),
// Botón agregar mint
_buildAddMintButton(walletProvider),
],
return Padding(
padding: const EdgeInsets.all(AppDimensions.paddingMedium),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Lista de mints
Expanded(
child: mintUrls.isEmpty
? _buildEmptyState()
: ListView.builder(
itemCount: mintUrls.length,
itemBuilder: (context, index) {
final mintUrl = mintUrls[index];
return Padding(
padding: const EdgeInsets.only(
bottom: AppDimensions.paddingMedium,
),
child: _buildMintCard(
mintUrl,
walletProvider,
),
);
},
),
),
);
},
// Botón agregar mint
_buildAddMintButton(walletProvider),
],
),
);
},
),
@@ -129,20 +116,21 @@ class _MintsScreenState extends State<MintsScreen> {
);
}
Widget _buildMintCard(Mint mint, WalletProvider walletProvider) {
final isActive = walletProvider.activeMintUrl == mint.url;
Widget _buildMintCard(String mintUrl, WalletProvider walletProvider) {
final isActive = walletProvider.activeMintUrl == mintUrl;
final units = walletProvider.getUnitsForMint(mintUrl);
return FutureBuilder<BigInt>(
future: walletProvider.getBalanceForMint(mint.url),
return FutureBuilder<Map<String, BigInt>>(
future: walletProvider.getBalancesForMint(mintUrl),
builder: (context, balanceSnapshot) {
final balance = balanceSnapshot.data ?? BigInt.zero;
final balances = balanceSnapshot.data ?? {};
return GlassCard(
padding: const EdgeInsets.all(AppDimensions.paddingMedium),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header: URL + estado
// Header: URL + estado + refresh
Row(
children: [
// Icono estado conexión (verde si es el activo)
@@ -156,10 +144,10 @@ class _MintsScreenState extends State<MintsScreen> {
),
const SizedBox(width: 8),
// URL del mint (solo el host)
// URL del mint (display name)
Expanded(
child: Text(
_extractHost(mint.url),
UnitFormatter.getMintDisplayName(mintUrl),
style: const TextStyle(
fontFamily: 'Inter',
fontSize: 16,
@@ -170,6 +158,21 @@ class _MintsScreenState extends State<MintsScreen> {
),
),
// 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)
Container(
@@ -198,7 +201,7 @@ class _MintsScreenState extends State<MintsScreen> {
// URL completa (más pequeña)
Text(
mint.url,
mintUrl,
style: TextStyle(
fontFamily: 'Inter',
fontSize: 12,
@@ -209,19 +212,19 @@ class _MintsScreenState extends State<MintsScreen> {
const SizedBox(height: AppDimensions.paddingSmall),
// Balance
Row(
children: [
Text(
'Balance:',
style: TextStyle(
fontFamily: 'Inter',
fontSize: 14,
color: AppColors.textSecondary.withValues(alpha: 0.7),
// 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),
if (balanceSnapshot.connectionState == ConnectionState.waiting)
const SizedBox(width: 8),
SizedBox(
width: 14,
height: 14,
@@ -229,19 +232,11 @@ class _MintsScreenState extends State<MintsScreen> {
strokeWidth: 2,
color: AppColors.textSecondary,
),
)
else
Text(
'${_formatBalance(balance)} sats',
style: const TextStyle(
fontFamily: 'Inter',
fontSize: 16,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
],
),
],
)
else
_buildBalancesList(units, balances),
const SizedBox(height: AppDimensions.paddingMedium),
@@ -252,7 +247,7 @@ class _MintsScreenState extends State<MintsScreen> {
if (!isActive)
Expanded(
child: GestureDetector(
onTap: () => _setActiveMint(mint.url, walletProvider),
onTap: () => _setActiveMint(mintUrl, walletProvider),
child: Container(
padding: const EdgeInsets.symmetric(vertical: 10),
decoration: BoxDecoration(
@@ -288,7 +283,7 @@ class _MintsScreenState extends State<MintsScreen> {
// Eliminar
Expanded(
child: GestureDetector(
onTap: () => _showDeleteDialog(mint, balance, walletProvider),
onTap: () => _showDeleteDialog(mintUrl, balances, walletProvider),
child: Container(
padding: const EdgeInsets.symmetric(vertical: 10),
decoration: BoxDecoration(
@@ -327,6 +322,87 @@ class _MintsScreenState extends State<MintsScreen> {
);
}
/// Construye la lista de balances por unidad.
Widget _buildBalancesList(List<String> units, Map<String, BigInt> balances) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Balances:',
style: TextStyle(
fontFamily: 'Inter',
fontSize: 14,
color: AppColors.textSecondary.withValues(alpha: 0.7),
),
),
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),
),
),
],
),
);
}),
],
);
}
/// Refresca la información del mint (detecta nuevas unidades).
Future<void> _refreshMint(String mintUrl, WalletProvider walletProvider) async {
try {
final units = await walletProvider.refreshMint(mintUrl);
if (mounted) {
setState(() {}); // Rebuild para actualizar UI
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Unidades detectadas: ${units.join(", ")}'),
backgroundColor: AppColors.success,
duration: const Duration(seconds: 2),
),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Error: $e'),
backgroundColor: AppColors.error,
duration: const Duration(seconds: 3),
),
);
}
}
}
Widget _buildAddMintButton(WalletProvider walletProvider) {
return PrimaryButton(
text: 'Agregar mint',
@@ -335,23 +411,6 @@ class _MintsScreenState extends State<MintsScreen> {
);
}
String _extractHost(String url) {
try {
final uri = Uri.parse(url);
return uri.host;
} catch (e) {
return url;
}
}
String _formatBalance(BigInt balance) {
final value = balance.toInt();
if (value >= 1000) {
return '${(value / 1000).toStringAsFixed(value % 1000 == 0 ? 0 : 1)}k';
}
return value.toString();
}
Future<void> _setActiveMint(String mintUrl, WalletProvider walletProvider) async {
try {
await walletProvider.setActiveMint(mintUrl);
@@ -379,19 +438,26 @@ class _MintsScreenState extends State<MintsScreen> {
}
}
void _showDeleteDialog(Mint mint, BigInt balance, WalletProvider walletProvider) {
final hasBalance = balance > BigInt.zero;
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: mint.url,
mintUrl: mintUrl,
hasBalance: hasBalance,
balance: balance.toInt(),
balanceDescription: balanceStrings.isEmpty ? '' : balanceStrings.join(', '),
onConfirm: () async {
Navigator.pop(context);
await _deleteMint(mint.url, walletProvider);
await _deleteMint(mintUrl, walletProvider);
},
onCancel: () => Navigator.pop(context),
),
@@ -443,14 +509,14 @@ class _MintsScreenState extends State<MintsScreen> {
class _DeleteMintModal extends StatelessWidget {
final String mintUrl;
final bool hasBalance;
final int balance;
final String balanceDescription;
final VoidCallback onConfirm;
final VoidCallback onCancel;
const _DeleteMintModal({
required this.mintUrl,
required this.hasBalance,
required this.balance,
required this.balanceDescription,
required this.onConfirm,
required this.onCancel,
});
@@ -547,7 +613,7 @@ class _DeleteMintModal extends StatelessWidget {
Expanded(
child: Text(
hasBalance
? 'Este mint tiene $balance sats. Puedes recuperarlos después agregando el mint de nuevo.'
? 'Este mint tiene $balanceDescription. Puedes recuperarlos después agregando el mint de nuevo.'
: 'Perderás acceso a este mint',
style: TextStyle(
fontFamily: 'Inter',
+51 -18
View File
@@ -4,6 +4,7 @@ import 'package:provider/provider.dart';
import 'package:url_launcher/url_launcher.dart';
import '../../core/constants/colors.dart';
import '../../core/constants/dimensions.dart';
import '../../core/utils/formatters.dart';
import '../../providers/settings_provider.dart';
import '../../providers/wallet_provider.dart';
import '../../widgets/common/gradient_background.dart';
@@ -1227,10 +1228,9 @@ class _RecoverTokensModalState extends State<_RecoverTokensModal> {
Future<void> _loadMints() async {
final walletProvider = context.read<WalletProvider>();
final mints = await walletProvider.listMints();
if (mounted) {
setState(() {
_availableMints = mints.map((m) => m.url).toList();
_availableMints = walletProvider.mintUrls;
_isLoadingMints = false;
if (_availableMints.isNotEmpty) {
_selectedMintUrl = _availableMints.first;
@@ -1749,27 +1749,46 @@ class _RecoverTokensModalState extends State<_RecoverTokensModal> {
if (_useCurrentMnemonic) {
// Usar mnemonic actual
if (_scanAllMints) {
// Escanear todos los mints
// Escanear todos los mints (retorna Map<String, Map<String, BigInt>>)
final results = await walletProvider.restoreAllMints();
BigInt totalRecovered = BigInt.zero;
int mintsScanned = 0;
int mintsWithError = 0;
final recoveredDetails = <String>[];
for (final entry in results.entries) {
if (entry.value >= BigInt.zero) {
totalRecovered += entry.value;
mintsScanned++;
} else {
for (final mintEntry in results.entries) {
final mintUrl = mintEntry.key;
final unitBalances = mintEntry.value;
bool hasError = false;
BigInt mintTotal = BigInt.zero;
for (final unitEntry in unitBalances.entries) {
final unit = unitEntry.key;
final balance = unitEntry.value;
if (balance < BigInt.zero) {
hasError = true;
} else if (balance > BigInt.zero) {
mintTotal += balance;
final formatted = UnitFormatter.formatBalance(balance, unit);
final label = UnitFormatter.getUnitLabel(unit);
recoveredDetails.add('$formatted $label');
}
}
if (hasError) {
mintsWithError++;
} else {
mintsScanned++;
totalRecovered += mintTotal;
}
}
if (!mounted) return;
setState(() {
_isSuccess = true;
if (totalRecovered > BigInt.zero) {
_result = '¡Recuperados ${totalRecovered.toInt()} sats de $mintsScanned mint(s)!';
if (recoveredDetails.isNotEmpty) {
_result = '¡Recuperados ${recoveredDetails.join(", ")} de $mintsScanned mint(s)!';
} else {
_result = 'Escaneo completado. No se encontraron tokens nuevos.';
}
@@ -1778,7 +1797,7 @@ class _RecoverTokensModalState extends State<_RecoverTokensModal> {
}
});
} else {
// Escanear mint específico
// Escanear mint específico (retorna Map<String, BigInt>)
if (_selectedMintUrl == null) {
if (!mounted) return;
setState(() {
@@ -1788,14 +1807,25 @@ class _RecoverTokensModalState extends State<_RecoverTokensModal> {
return;
}
final recovered = await walletProvider.restoreFromMint(_selectedMintUrl!);
final mintHost = Uri.parse(_selectedMintUrl!).host;
final unitBalances = await walletProvider.restoreFromMint(_selectedMintUrl!);
final mintHost = UnitFormatter.getMintDisplayName(_selectedMintUrl!);
final recoveredDetails = <String>[];
for (final entry in unitBalances.entries) {
final unit = entry.key;
final balance = entry.value;
if (balance > BigInt.zero) {
final formatted = UnitFormatter.formatBalance(balance, unit);
final label = UnitFormatter.getUnitLabel(unit);
recoveredDetails.add('$formatted $label');
}
}
if (!mounted) return;
setState(() {
_isSuccess = true;
if (recovered > BigInt.zero) {
_result = '¡Recuperados ${recovered.toInt()} sats de $mintHost!';
if (recoveredDetails.isNotEmpty) {
_result = '¡Recuperados ${recoveredDetails.join(", ")} de $mintHost!';
} else {
_result = 'No se encontraron tokens en $mintHost.';
}
@@ -1816,8 +1846,7 @@ class _RecoverTokensModalState extends State<_RecoverTokensModal> {
}
// Obtener lista de mints actuales para escanear
final mints = await walletProvider.listMints();
final mintUrls = mints.map((m) => m.url).toList();
final mintUrls = walletProvider.mintUrls;
if (mintUrls.isEmpty) {
if (!mounted) return;
@@ -1837,7 +1866,11 @@ class _RecoverTokensModalState extends State<_RecoverTokensModal> {
setState(() {
_isSuccess = true;
if (recovered > BigInt.zero) {
_result = '¡Recuperados y transferidos ${recovered.toInt()} sats a tu wallet!';
// Usamos la unidad activa como aproximación para el formato
final activeUnit = walletProvider.activeUnit;
final formatted = UnitFormatter.formatBalance(recovered, activeUnit);
final label = UnitFormatter.getUnitLabel(activeUnit);
_result = '¡Recuperados y transferidos $formatted $label a tu wallet!';
} else {
_result = 'No se encontraron tokens asociados a ese mnemonic.';
}
+33 -1
View File
@@ -33,6 +33,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.1.0"
cbor:
dependency: "direct main"
description:
name: cbor
sha256: e60380c7329da6b415841be93884b8d4380cbd86cd4cecb2067baa221b8d88b5
url: "https://pub.dev"
source: hosted
version: "6.3.5"
cdk_flutter:
dependency: "direct main"
description:
@@ -66,6 +74,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.19.0"
convert:
dependency: transitive
description:
name: convert
sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68
url: "https://pub.dev"
source: hosted
version: "3.1.2"
cross_file:
dependency: transitive
description:
@@ -222,8 +238,16 @@ packages:
url: "https://pub.dev"
source: hosted
version: "6.3.0"
http:
hex:
dependency: transitive
description:
name: hex
sha256: "4e7cd54e4b59ba026432a6be2dd9d96e4c5205725194997193bf871703b82c4a"
url: "https://pub.dev"
source: hosted
version: "0.2.0"
http:
dependency: "direct main"
description:
name: http
sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
@@ -238,6 +262,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "4.1.2"
ieee754:
dependency: transitive
description:
name: ieee754
sha256: "7d87451c164a56c156180d34a4e93779372edd191d2c219206100b976203128c"
url: "https://pub.dev"
source: hosted
version: "1.0.3"
intl:
dependency: "direct main"
description:
+6
View File
@@ -41,6 +41,12 @@ dependencies:
# URL Launcher
url_launcher: ^6.2.0
# HTTP client for mint API calls
http: ^1.2.0
# CBOR decoder for V4 tokens
cbor: ^6.3.0
dev_dependencies:
flutter_test:
sdk: flutter