889 lines
26 KiB
Dart
889 lines
26 KiB
Dart
import 'dart:convert';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:http/http.dart' as http;
|
|
import 'package:provider/provider.dart';
|
|
import 'package:lucide_icons/lucide_icons.dart';
|
|
import 'package:elcaju/l10n/app_localizations.dart';
|
|
import '../../core/constants/colors.dart';
|
|
import '../../core/constants/dimensions.dart';
|
|
import '../../core/utils/formatters.dart';
|
|
import '../../core/utils/keyset_debug.dart';
|
|
import '../../widgets/common/gradient_background.dart';
|
|
import '../../widgets/common/glass_card.dart';
|
|
import '../../widgets/common/primary_button.dart';
|
|
import '../../widgets/common/secondary_button.dart';
|
|
import '../../widgets/common/bottom_sheet_container.dart';
|
|
import '../../widgets/common/numpad_widget.dart';
|
|
import '../../providers/wallet_provider.dart';
|
|
import '../../core/utils/nostr_utils.dart';
|
|
import '../../widgets/scanner/qr_scanner_widget.dart';
|
|
import 'share_token_screen.dart';
|
|
import 'offline_send_screen.dart';
|
|
|
|
/// Pantalla para enviar tokens Cashu
|
|
class SendScreen extends StatefulWidget {
|
|
const SendScreen({super.key});
|
|
|
|
@override
|
|
State<SendScreen> createState() => _SendScreenState();
|
|
}
|
|
|
|
class _SendScreenState extends State<SendScreen> {
|
|
final TextEditingController _memoController = TextEditingController();
|
|
final TextEditingController _pubkeyController = TextEditingController();
|
|
|
|
String _amountValue = '';
|
|
bool _isProcessing = false;
|
|
String? _errorMessage;
|
|
BigInt _availableBalance = BigInt.zero;
|
|
late String _activeUnit;
|
|
|
|
// P2PK
|
|
bool _useP2PK = false;
|
|
String? _pubkeyError;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_activeUnit = context.read<WalletProvider>().activeUnit;
|
|
_loadBalance();
|
|
}
|
|
|
|
Future<void> _loadBalance() async {
|
|
final walletProvider = context.read<WalletProvider>();
|
|
final balance = await walletProvider.getBalance();
|
|
if (mounted) {
|
|
setState(() {
|
|
_availableBalance = balance;
|
|
});
|
|
}
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_memoController.dispose();
|
|
_pubkeyController.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
/// Obtiene la etiqueta de la unidad para display
|
|
String get _unitLabel => UnitFormatter.getUnitLabel(_activeUnit);
|
|
|
|
/// Parsea los dígitos crudos a BigInt (ya son centavos para USD/EUR)
|
|
BigInt get _amount => UnitFormatter.parseRawDigits(_amountValue, _activeUnit);
|
|
|
|
bool get _isValidAmount => _amount > BigInt.zero && _amount <= _availableBalance;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return GradientBackground(
|
|
child: Scaffold(
|
|
backgroundColor: Colors.transparent,
|
|
appBar: AppBar(
|
|
backgroundColor: Colors.transparent,
|
|
elevation: 0,
|
|
leading: IconButton(
|
|
icon: const Icon(LucideIcons.arrowLeft, color: Colors.white),
|
|
onPressed: () => Navigator.pop(context),
|
|
),
|
|
title: Text(
|
|
L10n.of(context)!.sendCashu,
|
|
style: const TextStyle(
|
|
fontFamily: 'Inter',
|
|
fontWeight: FontWeight.w600,
|
|
color: Colors.white,
|
|
),
|
|
),
|
|
actions: [
|
|
// Botón para modo offline (selección manual de proofs)
|
|
IconButton(
|
|
icon: const Icon(LucideIcons.coins, color: AppColors.primaryAction),
|
|
tooltip: L10n.of(context)!.selectNotesManually,
|
|
onPressed: _goToOfflineMode,
|
|
),
|
|
],
|
|
),
|
|
body: SafeArea(
|
|
child: Column(
|
|
children: [
|
|
// Contenido scrolleable
|
|
Expanded(
|
|
child: SingleChildScrollView(
|
|
padding: const EdgeInsets.all(AppDimensions.paddingMedium),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
// Monto a enviar
|
|
_buildAmountSection(),
|
|
|
|
const SizedBox(height: AppDimensions.paddingLarge),
|
|
|
|
// Memo opcional
|
|
_buildMemoSection(),
|
|
|
|
const SizedBox(height: AppDimensions.paddingMedium),
|
|
|
|
// P2PK (bloquear a clave pública)
|
|
_buildP2PKSection(),
|
|
|
|
const SizedBox(height: AppDimensions.paddingMedium),
|
|
|
|
// Mensaje de error (si hay)
|
|
if (_errorMessage != null) _buildErrorMessage(),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
|
|
// Botón crear token (fijo abajo)
|
|
Padding(
|
|
padding: const EdgeInsets.all(AppDimensions.paddingMedium),
|
|
child: _buildCreateButton(),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildAmountSection() {
|
|
return Column(
|
|
children: [
|
|
// Display del monto
|
|
_buildAmountDisplay(),
|
|
const SizedBox(height: AppDimensions.paddingSmall),
|
|
|
|
// Balance disponible
|
|
_buildBalanceRow(),
|
|
const SizedBox(height: AppDimensions.paddingMedium),
|
|
|
|
// Teclado numérico
|
|
NumpadWidget(
|
|
value: _amountValue,
|
|
showMaxButton: true,
|
|
onMaxPressed: _setMaxAmount,
|
|
onChanged: (newValue) {
|
|
setState(() {
|
|
_errorMessage = null;
|
|
_amountValue = newValue;
|
|
});
|
|
},
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _buildAmountDisplay() {
|
|
final displayAmount = UnitFormatter.formatRawDigitsForDisplay(_amountValue, _activeUnit);
|
|
|
|
return Column(
|
|
children: [
|
|
Text(
|
|
displayAmount,
|
|
style: TextStyle(
|
|
fontFamily: 'Inter',
|
|
fontSize: 48,
|
|
fontWeight: FontWeight.bold,
|
|
color: _isValidAmount || _amountValue.isEmpty
|
|
? Colors.white
|
|
: AppColors.error,
|
|
),
|
|
),
|
|
const SizedBox(height: 4),
|
|
Text(
|
|
_unitLabel,
|
|
style: TextStyle(
|
|
fontFamily: 'Inter',
|
|
fontSize: 18,
|
|
color: AppColors.textSecondary,
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _buildBalanceRow() {
|
|
final l10n = L10n.of(context)!;
|
|
|
|
return Row(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
Text(
|
|
'${l10n.available} ',
|
|
style: TextStyle(
|
|
fontFamily: 'Inter',
|
|
fontSize: 14,
|
|
color: AppColors.textSecondary.withValues(alpha: 0.7),
|
|
),
|
|
),
|
|
Text(
|
|
'${UnitFormatter.formatBalance(_availableBalance, _activeUnit)} $_unitLabel',
|
|
style: const TextStyle(
|
|
fontFamily: 'Inter',
|
|
fontSize: 14,
|
|
fontWeight: FontWeight.w600,
|
|
color: AppColors.primaryAction,
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _buildMemoSection() {
|
|
final l10n = L10n.of(context)!;
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
l10n.memoOptional,
|
|
style: TextStyle(
|
|
fontFamily: 'Inter',
|
|
fontSize: 16,
|
|
fontWeight: FontWeight.w500,
|
|
color: AppColors.textSecondary,
|
|
),
|
|
),
|
|
const SizedBox(height: AppDimensions.paddingSmall),
|
|
|
|
GlassCard(
|
|
padding: const EdgeInsets.all(AppDimensions.paddingSmall),
|
|
child: TextField(
|
|
controller: _memoController,
|
|
maxLines: 2,
|
|
maxLength: 100,
|
|
style: const TextStyle(
|
|
fontFamily: 'Inter',
|
|
fontSize: 16,
|
|
color: Colors.white,
|
|
),
|
|
decoration: InputDecoration(
|
|
hintText: l10n.memoPlaceholder,
|
|
hintStyle: TextStyle(
|
|
fontFamily: 'Inter',
|
|
fontSize: 16,
|
|
color: Colors.white.withValues(alpha: 0.3),
|
|
),
|
|
border: InputBorder.none,
|
|
counterStyle: TextStyle(
|
|
fontFamily: 'Inter',
|
|
fontSize: 12,
|
|
color: AppColors.textSecondary.withValues(alpha: 0.5),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _buildP2PKSection() {
|
|
final l10n = L10n.of(context)!;
|
|
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
GlassCard(
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: AppDimensions.paddingMedium,
|
|
vertical: AppDimensions.paddingSmall,
|
|
),
|
|
child: Row(
|
|
children: [
|
|
Icon(
|
|
LucideIcons.lock,
|
|
color: _useP2PK ? AppColors.primaryAction : AppColors.textSecondary,
|
|
size: 20,
|
|
),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
l10n.p2pkLockToKey,
|
|
style: const TextStyle(
|
|
fontFamily: 'Inter',
|
|
fontSize: 14,
|
|
fontWeight: FontWeight.w500,
|
|
color: Colors.white,
|
|
),
|
|
),
|
|
Text(
|
|
l10n.p2pkLockDescription,
|
|
style: TextStyle(
|
|
fontFamily: 'Inter',
|
|
fontSize: 12,
|
|
color: AppColors.textSecondary.withValues(alpha: 0.7),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
Switch(
|
|
value: _useP2PK,
|
|
onChanged: (value) => setState(() => _useP2PK = value),
|
|
activeColor: AppColors.primaryAction,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
|
|
// Campo de pubkey cuando P2PK está activo
|
|
if (_useP2PK) ...[
|
|
const SizedBox(height: AppDimensions.paddingSmall),
|
|
GlassCard(
|
|
padding: const EdgeInsets.all(AppDimensions.paddingSmall),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
TextField(
|
|
controller: _pubkeyController,
|
|
onChanged: _validatePubkey,
|
|
maxLines: 1,
|
|
style: const TextStyle(
|
|
fontFamily: 'Inter',
|
|
fontSize: 14,
|
|
color: Colors.white,
|
|
),
|
|
decoration: InputDecoration(
|
|
hintText: l10n.p2pkReceiverPubkey,
|
|
hintStyle: TextStyle(
|
|
fontFamily: 'Inter',
|
|
fontSize: 14,
|
|
color: Colors.white.withValues(alpha: 0.3),
|
|
),
|
|
border: InputBorder.none,
|
|
suffixIcon: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
IconButton(
|
|
icon: const Icon(LucideIcons.clipboard, size: 20),
|
|
color: AppColors.textSecondary,
|
|
onPressed: _pastePubkey,
|
|
),
|
|
IconButton(
|
|
icon: const Icon(LucideIcons.scanLine, size: 20),
|
|
color: AppColors.textSecondary,
|
|
tooltip: l10n.scanQrCode,
|
|
onPressed: _scanPubkey,
|
|
),
|
|
],
|
|
),
|
|
errorText: _pubkeyError,
|
|
errorStyle: TextStyle(
|
|
fontFamily: 'Inter',
|
|
fontSize: 12,
|
|
color: AppColors.error,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
],
|
|
);
|
|
}
|
|
|
|
void _validatePubkey(String value) {
|
|
if (value.isEmpty) {
|
|
setState(() => _pubkeyError = null);
|
|
return;
|
|
}
|
|
|
|
final isValid = NostrUtils.isValidP2PKPubkey(value);
|
|
setState(() {
|
|
_pubkeyError = isValid ? null : L10n.of(context)!.p2pkInvalidPubkey;
|
|
});
|
|
}
|
|
|
|
Future<void> _pastePubkey() async {
|
|
final clipboardData = await Clipboard.getData(Clipboard.kTextPlain);
|
|
if (!mounted) return;
|
|
final text = clipboardData?.text?.trim();
|
|
if (text != null && text.isNotEmpty) {
|
|
_pubkeyController.text = text;
|
|
_validatePubkey(text);
|
|
}
|
|
}
|
|
|
|
Future<void> _scanPubkey() async {
|
|
final result = await Navigator.push<String>(
|
|
context,
|
|
MaterialPageRoute(
|
|
builder: (context) => Scaffold(
|
|
backgroundColor: Colors.black,
|
|
appBar: AppBar(
|
|
backgroundColor: Colors.transparent,
|
|
elevation: 0,
|
|
leading: IconButton(
|
|
icon: const Icon(LucideIcons.x, color: Colors.white),
|
|
onPressed: () => Navigator.pop(context),
|
|
),
|
|
title: Text(
|
|
L10n.of(context)!.scanQrCode,
|
|
style: const TextStyle(
|
|
fontFamily: 'Inter',
|
|
fontWeight: FontWeight.w600,
|
|
color: Colors.white,
|
|
),
|
|
),
|
|
centerTitle: true,
|
|
),
|
|
body: QrScannerWidget(
|
|
onDetect: (data) {
|
|
final trimmed = data.trim();
|
|
if (NostrUtils.isValidP2PKPubkey(trimmed)) {
|
|
Navigator.pop(context, trimmed);
|
|
} else {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text(L10n.of(context)!.p2pkInvalidPubkey),
|
|
duration: const Duration(seconds: 2),
|
|
),
|
|
);
|
|
}
|
|
},
|
|
onError: (error) {
|
|
debugPrint('[P2PK Scan] $error');
|
|
Navigator.pop(context);
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text(L10n.of(context)!.cameraPermissionDenied),
|
|
backgroundColor: AppColors.error,
|
|
),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
),
|
|
);
|
|
|
|
if (!mounted || result == null) return;
|
|
_pubkeyController.text = result;
|
|
_validatePubkey(result);
|
|
}
|
|
|
|
Widget _buildErrorMessage() {
|
|
return Container(
|
|
margin: const EdgeInsets.only(bottom: AppDimensions.paddingMedium),
|
|
padding: const EdgeInsets.all(AppDimensions.paddingMedium),
|
|
decoration: BoxDecoration(
|
|
color: AppColors.error.withValues(alpha: 0.15),
|
|
borderRadius: BorderRadius.circular(12),
|
|
border: Border.all(
|
|
color: AppColors.error.withValues(alpha: 0.3),
|
|
width: 1,
|
|
),
|
|
),
|
|
child: Row(
|
|
children: [
|
|
Icon(
|
|
LucideIcons.alertCircle,
|
|
color: AppColors.error,
|
|
size: 20,
|
|
),
|
|
const SizedBox(width: 8),
|
|
Expanded(
|
|
child: Text(
|
|
_errorMessage!,
|
|
style: TextStyle(
|
|
fontFamily: 'Inter',
|
|
fontSize: 14,
|
|
color: AppColors.error,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildCreateButton() {
|
|
final l10n = L10n.of(context)!;
|
|
// Si P2PK está habilitado, también debe haber una pubkey válida
|
|
final isP2PKValid = !_useP2PK ||
|
|
(_pubkeyController.text.isNotEmpty && _pubkeyError == null);
|
|
final canCreate = _isValidAmount && !_isProcessing && isP2PKValid;
|
|
return PrimaryButton(
|
|
text: _isProcessing ? l10n.creatingToken : l10n.createToken,
|
|
onPressed: canCreate ? _showConfirmation : null,
|
|
);
|
|
}
|
|
|
|
void _setMaxAmount() {
|
|
setState(() {
|
|
// El balance ya está en la unidad base (centavos para USD/EUR, sats para SAT)
|
|
_amountValue = _availableBalance.toString();
|
|
_errorMessage = null;
|
|
});
|
|
}
|
|
|
|
/// Navegar al modo offline para seleccionar proofs manualmente.
|
|
void _goToOfflineMode() {
|
|
final walletProvider = context.read<WalletProvider>();
|
|
final mintUrl = walletProvider.activeMintUrl;
|
|
|
|
if (mintUrl == null) {
|
|
setState(() {
|
|
_errorMessage = L10n.of(context)!.noActiveMint;
|
|
});
|
|
return;
|
|
}
|
|
|
|
Navigator.push(
|
|
context,
|
|
MaterialPageRoute(
|
|
builder: (context) => OfflineSendScreen(
|
|
mintUrl: mintUrl,
|
|
unit: _activeUnit,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
void _showConfirmation() async {
|
|
// Verificar conectividad antes de mostrar confirmación
|
|
final walletProvider = context.read<WalletProvider>();
|
|
final mintUrl = walletProvider.activeMintUrl;
|
|
|
|
if (mintUrl == null) {
|
|
setState(() {
|
|
_errorMessage = L10n.of(context)!.noActiveMint;
|
|
});
|
|
return;
|
|
}
|
|
|
|
setState(() {
|
|
_isProcessing = true;
|
|
});
|
|
|
|
final isOnline = await _checkConnectivity(mintUrl);
|
|
|
|
if (!mounted) return;
|
|
|
|
setState(() {
|
|
_isProcessing = false;
|
|
});
|
|
|
|
if (!isOnline) {
|
|
// P2PK requiere conexión al mint
|
|
if (_useP2PK) {
|
|
setState(() {
|
|
_errorMessage = L10n.of(context)!.p2pkRequiresConnection;
|
|
});
|
|
return;
|
|
}
|
|
// Offline: verificar monto mínimo con ppk del keyset local (fallback 100)
|
|
var ppk = await KeysetDebug.getInputFeePpk(mintUrl, _activeUnit);
|
|
if (ppk < 0) ppk = 100;
|
|
final minReceiveFee = ppk > 0 ? BigInt.from((ppk + 999) ~/ 1000) : BigInt.zero;
|
|
if (!mounted) return;
|
|
if (_amount <= minReceiveFee) {
|
|
setState(() {
|
|
_errorMessage = L10n.of(context)!.feeExceedsAmount;
|
|
});
|
|
return;
|
|
}
|
|
// Offline: ir directo a selección de monedas
|
|
_goToOfflineModeWithMessage();
|
|
return;
|
|
}
|
|
|
|
// Online: verificar monto mínimo con ppk real del mint
|
|
final minReceiveFee = await _getMinReceiveFee(mintUrl);
|
|
if (!mounted) return;
|
|
if (_amount <= minReceiveFee) {
|
|
setState(() {
|
|
_errorMessage = L10n.of(context)!.feeExceedsAmount;
|
|
});
|
|
return;
|
|
}
|
|
|
|
// Online: mostrar modal de confirmación normal
|
|
showModalBottomSheet(
|
|
context: context,
|
|
backgroundColor: Colors.transparent,
|
|
isScrollControlled: true,
|
|
builder: (context) => _ConfirmationModal(
|
|
amount: _amount,
|
|
unit: _activeUnit,
|
|
memo: _memoController.text.isNotEmpty ? _memoController.text : null,
|
|
onConfirm: () {
|
|
Navigator.pop(context);
|
|
_createToken();
|
|
},
|
|
onCancel: () => Navigator.pop(context),
|
|
),
|
|
);
|
|
}
|
|
|
|
/// Calcula el fee mínimo que el receptor pagará al reclamar el token.
|
|
/// Consulta /v1/keysets del mint para obtener el ppk real.
|
|
/// Fallback: ppk=100 (estándar) → fee mínimo = 1 sat.
|
|
Future<BigInt> _getMinReceiveFee(String mintUrl) async {
|
|
try {
|
|
final response = await http.get(
|
|
Uri.parse('$mintUrl/v1/keysets'),
|
|
).timeout(const Duration(seconds: 3));
|
|
if (response.statusCode == 200) {
|
|
final data = json.decode(response.body) as Map<String, dynamic>;
|
|
final keysets = data['keysets'] as List<dynamic>? ?? [];
|
|
for (final ks in keysets) {
|
|
if (ks['active'] == true && ks['unit'] == _activeUnit) {
|
|
final ppk = (ks['input_fee_ppk'] as num?)?.toInt() ?? 0;
|
|
if (ppk > 0) {
|
|
return BigInt.from((ppk + 999) ~/ 1000); // ceil(ppk / 1000)
|
|
}
|
|
return BigInt.zero;
|
|
}
|
|
}
|
|
}
|
|
} catch (_) {}
|
|
// Fallback: ppk=100 estándar
|
|
return BigInt.one;
|
|
}
|
|
|
|
/// Verifica conectividad haciendo petición HTTP real al mint.
|
|
Future<bool> _checkConnectivity(String mintUrl) async {
|
|
try {
|
|
final response = await http.get(
|
|
Uri.parse('$mintUrl/v1/info'),
|
|
).timeout(const Duration(seconds: 3));
|
|
return response.statusCode == 200;
|
|
} catch (_) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
Future<void> _createToken() async {
|
|
final l10n = L10n.of(context)!;
|
|
|
|
setState(() {
|
|
_isProcessing = true;
|
|
_errorMessage = null;
|
|
});
|
|
|
|
try {
|
|
final walletProvider = context.read<WalletProvider>();
|
|
|
|
// Crear token real con cdk-flutter
|
|
final memo = _memoController.text.isNotEmpty ? _memoController.text : null;
|
|
final amount = _amount;
|
|
|
|
String token;
|
|
|
|
// P2PK: bloquear a clave pública si está habilitado
|
|
if (_useP2PK && _pubkeyController.text.isNotEmpty) {
|
|
// Usar normalizeToCompressedHex para obtener formato SEC1 (66 chars)
|
|
final pubkeyHex = NostrUtils.normalizeToCompressedHex(_pubkeyController.text);
|
|
if (pubkeyHex == null) {
|
|
throw Exception(l10n.p2pkInvalidPubkey);
|
|
}
|
|
token = await walletProvider.sendTokensP2pk(amount, pubkeyHex, memo);
|
|
} else {
|
|
debugPrint('[SendScreen] Sending normal token, amount: $amount');
|
|
token = await walletProvider.sendTokens(amount, memo);
|
|
}
|
|
|
|
if (mounted) {
|
|
Navigator.pushReplacement(
|
|
context,
|
|
MaterialPageRoute(
|
|
builder: (context) => ShareTokenScreen(
|
|
token: token,
|
|
amount: amount,
|
|
unit: _activeUnit,
|
|
memo: memo,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
} catch (e, stackTrace) {
|
|
debugPrint('[SendScreen] ERROR creating token: $e');
|
|
debugPrint('[SendScreen] Stack trace: $stackTrace');
|
|
final errorStr = e.toString().toLowerCase();
|
|
|
|
// Detectar errores de red y redirigir a modo offline
|
|
if (_isNetworkError(errorStr)) {
|
|
if (mounted) {
|
|
setState(() {
|
|
_isProcessing = false;
|
|
});
|
|
_goToOfflineModeWithMessage();
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (mounted) {
|
|
setState(() {
|
|
if (errorStr.contains('insufficient') || errorStr.contains('not enough')) {
|
|
_errorMessage = l10n.insufficientBalance;
|
|
} else {
|
|
_errorMessage = l10n.tokenCreationError(e.toString());
|
|
}
|
|
});
|
|
}
|
|
} finally {
|
|
if (mounted) {
|
|
setState(() {
|
|
_isProcessing = false;
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Detecta si el error es de conexión/red.
|
|
bool _isNetworkError(String errorStr) {
|
|
return errorStr.contains('transport error') ||
|
|
errorStr.contains('network') ||
|
|
errorStr.contains('connection') ||
|
|
errorStr.contains('socket') ||
|
|
errorStr.contains('timeout') ||
|
|
errorStr.contains('unreachable') ||
|
|
errorStr.contains('no route') ||
|
|
errorStr.contains('error sending request');
|
|
}
|
|
|
|
/// Navegar al modo offline mostrando mensaje informativo.
|
|
void _goToOfflineModeWithMessage() {
|
|
final walletProvider = context.read<WalletProvider>();
|
|
final mintUrl = walletProvider.activeMintUrl;
|
|
|
|
if (mintUrl == null) {
|
|
setState(() {
|
|
_errorMessage = L10n.of(context)!.noActiveMint;
|
|
});
|
|
return;
|
|
}
|
|
|
|
// Mostrar snackbar informativo
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text(L10n.of(context)!.offlineModeMessage),
|
|
backgroundColor: AppColors.primaryAction,
|
|
duration: const Duration(seconds: 2),
|
|
),
|
|
);
|
|
|
|
// Navegar a modo offline
|
|
Navigator.pushReplacement(
|
|
context,
|
|
MaterialPageRoute(
|
|
builder: (context) => OfflineSendScreen(
|
|
mintUrl: mintUrl,
|
|
unit: _activeUnit,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Modal de confirmacion
|
|
class _ConfirmationModal extends StatelessWidget {
|
|
final BigInt amount;
|
|
final String unit;
|
|
final String? memo;
|
|
final VoidCallback onConfirm;
|
|
final VoidCallback onCancel;
|
|
|
|
const _ConfirmationModal({
|
|
required this.amount,
|
|
required this.unit,
|
|
this.memo,
|
|
required this.onConfirm,
|
|
required this.onCancel,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final l10n = L10n.of(context)!;
|
|
return BottomSheetContainer(
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
const BottomSheetHandle(),
|
|
|
|
// Icono
|
|
Container(
|
|
width: 64,
|
|
height: 64,
|
|
decoration: BoxDecoration(
|
|
color: AppColors.primaryAction.withValues(alpha: 0.2),
|
|
shape: BoxShape.circle,
|
|
),
|
|
child: const Icon(
|
|
LucideIcons.send,
|
|
color: AppColors.primaryAction,
|
|
size: 32,
|
|
),
|
|
),
|
|
const SizedBox(height: AppDimensions.paddingMedium),
|
|
|
|
// Titulo
|
|
Text(
|
|
l10n.confirmSend,
|
|
style: const TextStyle(
|
|
fontFamily: 'Inter',
|
|
fontSize: 20,
|
|
fontWeight: FontWeight.bold,
|
|
color: Colors.white,
|
|
),
|
|
),
|
|
const SizedBox(height: AppDimensions.paddingSmall),
|
|
|
|
// Monto
|
|
Text(
|
|
'${UnitFormatter.formatBalance(amount, unit)} ${UnitFormatter.getUnitLabel(unit)}',
|
|
style: const TextStyle(
|
|
fontFamily: 'Inter',
|
|
fontSize: 32,
|
|
fontWeight: FontWeight.bold,
|
|
color: Colors.white,
|
|
),
|
|
),
|
|
|
|
// Memo
|
|
if (memo != null && memo!.isNotEmpty) ...[
|
|
const SizedBox(height: AppDimensions.paddingSmall),
|
|
Text(
|
|
'"$memo"',
|
|
style: TextStyle(
|
|
fontFamily: 'Inter',
|
|
fontSize: 14,
|
|
fontStyle: FontStyle.italic,
|
|
color: AppColors.textSecondary.withValues(alpha: 0.7),
|
|
),
|
|
),
|
|
],
|
|
|
|
const SizedBox(height: AppDimensions.paddingLarge),
|
|
|
|
// Botones
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: SecondaryButton(
|
|
text: l10n.cancel,
|
|
onPressed: onCancel,
|
|
height: 52,
|
|
),
|
|
),
|
|
const SizedBox(width: AppDimensions.paddingMedium),
|
|
Expanded(
|
|
child: PrimaryButton(
|
|
text: l10n.confirm,
|
|
onPressed: onConfirm,
|
|
height: 52,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|