/* * This file is part of Stack Wallet. * * Copyright (c) 2023 Cypher Stack * All Rights Reserved. * The code is distributed under GPLv3 license, see LICENSE file for details. * Generated by Cypher Stack on 2023-05-26 * */ import 'dart:async'; import 'dart:io'; import 'package:cs_monero/cs_monero.dart' as lib_monero; import 'package:decimal/decimal.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_svg/flutter_svg.dart'; import 'package:tuple/tuple.dart'; import '../../models/isar/models/isar_models.dart'; import '../../models/paynym/paynym_account_lite.dart'; import '../../models/send_view_auto_fill_data.dart'; import '../../providers/providers.dart'; import '../../providers/ui/fee_rate_type_state_provider.dart'; import '../../providers/ui/preview_tx_button_state_provider.dart'; import '../../providers/wallet/public_private_balance_state_provider.dart'; import '../../route_generator.dart'; import '../../themes/coin_icon_provider.dart'; import '../../themes/stack_colors.dart'; import '../../utilities/address_utils.dart'; import '../../utilities/amount/amount.dart'; import '../../utilities/amount/amount_formatter.dart'; import '../../utilities/amount/amount_input_formatter.dart'; import '../../utilities/amount/amount_unit.dart'; import '../../utilities/assets.dart'; import '../../utilities/barcode_scanner_interface.dart'; import '../../utilities/clipboard_interface.dart'; import '../../utilities/constants.dart'; import '../../utilities/enums/fee_rate_type_enum.dart'; import '../../utilities/extensions/extensions.dart'; import '../../utilities/logger.dart'; import '../../utilities/prefs.dart'; import '../../utilities/text_styles.dart'; import '../../utilities/util.dart'; import '../../wallets/crypto_currency/crypto_currency.dart'; import '../../wallets/crypto_currency/intermediate/nano_currency.dart'; import '../../wallets/isar/providers/wallet_info_provider.dart'; import '../../wallets/models/tx_data.dart'; import '../../wallets/wallet/impl/firo_wallet.dart'; import '../../wallets/wallet/wallet_mixin_interfaces/coin_control_interface.dart'; import '../../wallets/wallet/wallet_mixin_interfaces/paynym_interface.dart'; import '../../wallets/wallet/wallet_mixin_interfaces/spark_interface.dart'; import '../../widgets/animated_text.dart'; import '../../widgets/background.dart'; import '../../widgets/custom_buttons/app_bar_icon_button.dart'; import '../../widgets/custom_buttons/blue_text_button.dart'; import '../../widgets/dialogs/firo_exchange_address_dialog.dart'; import '../../widgets/fee_slider.dart'; import '../../widgets/icon_widgets/addressbook_icon.dart'; import '../../widgets/icon_widgets/clipboard_icon.dart'; import '../../widgets/icon_widgets/qrcode_icon.dart'; import '../../widgets/icon_widgets/x_icon.dart'; import '../../widgets/rounded_white_container.dart'; import '../../widgets/stack_dialog.dart'; import '../../widgets/stack_text_field.dart'; import '../../widgets/textfield_icon_button.dart'; import '../address_book_views/address_book_view.dart'; import '../coin_control/coin_control_view.dart'; import 'confirm_transaction_view.dart'; import 'sub_widgets/building_transaction_dialog.dart'; import 'sub_widgets/firo_balance_selection_sheet.dart'; import 'sub_widgets/transaction_fee_selection_sheet.dart'; class SendView extends ConsumerStatefulWidget { const SendView({ super.key, required this.walletId, required this.coin, this.autoFillData, this.clipboard = const ClipboardWrapper(), this.barcodeScanner = const BarcodeScannerWrapper(), this.accountLite, }); static const String routeName = "/sendView"; final String walletId; final CryptoCurrency coin; final SendViewAutoFillData? autoFillData; final ClipboardInterface clipboard; final BarcodeScannerInterface barcodeScanner; final PaynymAccountLite? accountLite; @override ConsumerState createState() => _SendViewState(); } class _SendViewState extends ConsumerState { late final String walletId; late final CryptoCurrency coin; late final ClipboardInterface clipboard; late final BarcodeScannerInterface scanner; late TextEditingController sendToController; late TextEditingController cryptoAmountController; late TextEditingController baseAmountController; late TextEditingController noteController; late TextEditingController onChainNoteController; late TextEditingController feeController; late TextEditingController memoController; late final SendViewAutoFillData? _data; final _addressFocusNode = FocusNode(); final _noteFocusNode = FocusNode(); final _onChainNoteFocusNode = FocusNode(); final _cryptoFocus = FocusNode(); final _baseFocus = FocusNode(); final _memoFocus = FocusNode(); late final bool isStellar; late final bool isFiro; Amount? _cachedAmountToSend; String? _address; bool _addressToggleFlag = false; bool _isFiroExWarningDisplayed = false; bool _cryptoAmountChangeLock = false; late VoidCallback onCryptoAmountChanged; Set selectedUTXOs = {}; Future _scanQr() async { try { // ref // .read( // shouldShowLockscreenOnResumeStateProvider // .state) // .state = false; if (FocusScope.of(context).hasFocus) { FocusScope.of(context).unfocus(); await Future.delayed(const Duration(milliseconds: 75)); } final qrResult = await scanner.scan(); // Future.delayed( // const Duration(seconds: 2), // () => ref // .read( // shouldShowLockscreenOnResumeStateProvider // .state) // .state = true, // ); Logging.instance.log( "qrResult content: ${qrResult.rawContent}", level: LogLevel.Info, ); final paymentData = AddressUtils.parsePaymentUri( qrResult.rawContent, logging: Logging.instance, ); if (paymentData != null && paymentData.coin?.uriScheme == coin.uriScheme) { // auto fill address _address = paymentData.address.trim(); sendToController.text = _address!; // autofill notes field if (paymentData.message != null) { noteController.text = paymentData.message!; } else if (paymentData.label != null) { noteController.text = paymentData.label!; } // autofill amount field if (paymentData.amount != null) { final Amount amount = Decimal.parse(paymentData.amount!).toAmount( fractionDigits: coin.fractionDigits, ); cryptoAmountController.text = ref.read(pAmountFormatter(coin)).format( amount, withUnitName: false, ); ref.read(pSendAmount.notifier).state = amount; } _setValidAddressProviders(_address); setState(() { _addressToggleFlag = sendToController.text.isNotEmpty; }); // now check for non standard encoded basic address } else { _address = qrResult.rawContent.split("\n").first.trim(); sendToController.text = _address ?? ""; _setValidAddressProviders(_address); setState(() { _addressToggleFlag = sendToController.text.isNotEmpty; }); } } on PlatformException catch (e, s) { // ref // .read( // shouldShowLockscreenOnResumeStateProvider // .state) // .state = true; // here we ignore the exception caused by not giving permission // to use the camera to scan a qr code Logging.instance.log( "Failed to get camera permissions while trying to scan qr code in SendView: $e\n$s", level: LogLevel.Warning, ); } } void _fiatFieldChanged(String baseAmountString) { final baseAmount = Amount.tryParseFiatString( baseAmountString, locale: ref.read(localeServiceChangeNotifierProvider).locale, ); final Amount? amount; if (baseAmount != null) { final Decimal _price = ref.read(priceAnd24hChangeNotifierProvider).getPrice(coin).item1; if (_price == Decimal.zero) { amount = 0.toAmountAsRaw(fractionDigits: coin.fractionDigits); } else { amount = baseAmount <= Amount.zero ? 0.toAmountAsRaw(fractionDigits: coin.fractionDigits) : (baseAmount.decimal / _price) .toDecimal( scaleOnInfinitePrecision: coin.fractionDigits, ) .toAmount(fractionDigits: coin.fractionDigits); } if (_cachedAmountToSend != null && _cachedAmountToSend == amount) { return; } _cachedAmountToSend = amount; Logging.instance .log("it changed $amount $_cachedAmountToSend", level: LogLevel.Info); final amountString = ref.read(pAmountFormatter(coin)).format( amount, withUnitName: false, ); _cryptoAmountChangeLock = true; cryptoAmountController.text = amountString; _cryptoAmountChangeLock = false; } else { amount = 0.toAmountAsRaw(fractionDigits: coin.fractionDigits); _cryptoAmountChangeLock = true; cryptoAmountController.text = ""; _cryptoAmountChangeLock = false; } // setState(() { // _calculateFeesFuture = calculateFees( // Format.decimalAmountToSatoshis( // _amountToSend!)); // }); ref.read(pSendAmount.notifier).state = amount; } void _cryptoAmountChanged() async { if (!_cryptoAmountChangeLock) { final cryptoAmount = ref.read(pAmountFormatter(coin)).tryParse( cryptoAmountController.text, ); final Amount? amount; if (cryptoAmount != null) { amount = cryptoAmount; if (_cachedAmountToSend != null && _cachedAmountToSend == amount) { return; } _cachedAmountToSend = amount; Logging.instance.log( "it changed $amount $_cachedAmountToSend", level: LogLevel.Info, ); final price = ref.read(priceAnd24hChangeNotifierProvider).getPrice(coin).item1; if (price > Decimal.zero) { baseAmountController.text = (amount!.decimal * price) .toAmount( fractionDigits: 2, ) .fiatString( locale: ref.read(localeServiceChangeNotifierProvider).locale, ); } } else { amount = null; baseAmountController.text = ""; } ref.read(pSendAmount.notifier).state = amount; _cryptoAmountChangedFeeUpdateTimer?.cancel(); _cryptoAmountChangedFeeUpdateTimer = Timer(updateFeesTimerDuration, () { if (coin is! Epiccash && !_baseFocus.hasFocus) { setState(() { _calculateFeesFuture = calculateFees( amount == null ? 0.toAmountAsRaw(fractionDigits: coin.fractionDigits) : amount!, ); }); } }); } } final updateFeesTimerDuration = const Duration(milliseconds: 500); Timer? _cryptoAmountChangedFeeUpdateTimer; Timer? _baseAmountChangedFeeUpdateTimer; void _baseAmountChanged() { _baseAmountChangedFeeUpdateTimer?.cancel(); _baseAmountChangedFeeUpdateTimer = Timer(updateFeesTimerDuration, () { if (coin is! Epiccash && !_cryptoFocus.hasFocus) { setState(() { _calculateFeesFuture = calculateFees( ref.read(pSendAmount) == null ? 0.toAmountAsRaw(fractionDigits: coin.fractionDigits) : ref.read(pSendAmount)!, ); }); } }); } late Amount _currentFee; void _setCurrentFee(String fee, bool shouldSetState) { fee = fee.trim(); if (fee.startsWith("~")) { fee = fee.substring(1); } if (fee.contains(" ")) { fee = fee.split(" ").first; } final value = fee.contains(",") ? Decimal.parse(fee.replaceFirst(",", ".")) .toAmount(fractionDigits: coin.fractionDigits) : Decimal.parse(fee).toAmount(fractionDigits: coin.fractionDigits); if (shouldSetState) { setState(() => _currentFee = value); } else { _currentFee = value; } } String? _updateInvalidAddressText(String address) { if (_data != null && _data!.contactLabel == address) { return null; } if (address.isNotEmpty && !ref .read(pWallets) .getWallet(walletId) .cryptoCurrency .validateAddress(address)) { return "Invalid address"; } return null; } void _setValidAddressProviders(String? address) { if (isPaynymSend) { ref.read(pValidSendToAddress.notifier).state = true; } else { final wallet = ref.read(pWallets).getWallet(walletId); if (wallet is SparkInterface) { ref.read(pValidSparkSendToAddress.notifier).state = SparkInterface.validateSparkAddress( address: address ?? "", isTestNet: wallet.cryptoCurrency.network.isTestNet, ); ref.read(pIsExchangeAddress.state).state = (coin as Firo).isExchangeAddress(address ?? ""); if (ref.read(publicPrivateBalanceStateProvider) == FiroType.spark && ref.read(pIsExchangeAddress) && !_isFiroExWarningDisplayed) { _isFiroExWarningDisplayed = true; showFiroExchangeAddressWarning( context, () => _isFiroExWarningDisplayed = false, ); } } ref.read(pValidSendToAddress.notifier).state = wallet.cryptoCurrency.validateAddress(address ?? ""); } } late Future _calculateFeesFuture; Map cachedFees = {}; Map cachedFiroLelantusFees = {}; Map cachedFiroSparkFees = {}; Map cachedFiroPublicFees = {}; Future calculateFees(Amount amount) async { if (amount <= Amount.zero) { return "0"; } if (isFiro) { switch (ref.read(publicPrivateBalanceStateProvider.state).state) { case FiroType.public: if (cachedFiroPublicFees[amount] != null) { return cachedFiroPublicFees[amount]!; } break; case FiroType.lelantus: if (cachedFiroLelantusFees[amount] != null) { return cachedFiroLelantusFees[amount]!; } break; case FiroType.spark: if (cachedFiroSparkFees[amount] != null) { return cachedFiroSparkFees[amount]!; } break; } } else if (cachedFees[amount] != null) { return cachedFees[amount]!; } final wallet = ref.read(pWallets).getWallet(walletId); final feeObject = await wallet.fees; late final int feeRate; switch (ref.read(feeRateTypeStateProvider.state).state) { case FeeRateType.fast: feeRate = feeObject.fast; break; case FeeRateType.average: feeRate = feeObject.medium; break; case FeeRateType.slow: feeRate = feeObject.slow; break; default: feeRate = -1; } Amount fee; if (coin is Monero) { lib_monero.TransactionPriority specialMoneroId; switch (ref.read(feeRateTypeStateProvider.state).state) { case FeeRateType.fast: specialMoneroId = lib_monero.TransactionPriority.high; break; case FeeRateType.average: specialMoneroId = lib_monero.TransactionPriority.medium; break; case FeeRateType.slow: specialMoneroId = lib_monero.TransactionPriority.normal; break; default: throw ArgumentError("custom fee not available for monero"); } fee = await wallet.estimateFeeFor(amount, specialMoneroId.value); cachedFees[amount] = ref.read(pAmountFormatter(coin)).format( fee, withUnitName: true, indicatePrecisionLoss: false, ); return cachedFees[amount]!; } else if (isFiro) { final firoWallet = wallet as FiroWallet; switch (ref.read(publicPrivateBalanceStateProvider.state).state) { case FiroType.public: fee = await firoWallet.estimateFeeFor(amount, feeRate); cachedFiroPublicFees[amount] = ref.read(pAmountFormatter(coin)).format( fee, withUnitName: true, indicatePrecisionLoss: false, ); return cachedFiroPublicFees[amount]!; case FiroType.lelantus: fee = await firoWallet.estimateFeeForLelantus(amount); cachedFiroLelantusFees[amount] = ref.read(pAmountFormatter(coin)).format( fee, withUnitName: true, indicatePrecisionLoss: false, ); return cachedFiroLelantusFees[amount]!; case FiroType.spark: fee = await firoWallet.estimateFeeForSpark(amount); cachedFiroSparkFees[amount] = ref.read(pAmountFormatter(coin)).format( fee, withUnitName: true, indicatePrecisionLoss: false, ); return cachedFiroSparkFees[amount]!; } } else { fee = await wallet.estimateFeeFor(amount, feeRate); cachedFees[amount] = ref.read(pAmountFormatter(coin)).format( fee, withUnitName: true, indicatePrecisionLoss: false, ); return cachedFees[amount]!; } } Future _previewTransaction() async { // wait for keyboard to disappear FocusScope.of(context).unfocus(); await Future.delayed( const Duration(milliseconds: 100), ); final wallet = ref.read(pWallets).getWallet(walletId); final Amount amount = ref.read(pSendAmount)!; final Amount availableBalance; if (isFiro) { switch (ref.read(publicPrivateBalanceStateProvider.state).state) { case FiroType.public: availableBalance = wallet.info.cachedBalance.spendable; break; case FiroType.lelantus: availableBalance = wallet.info.cachedBalanceSecondary.spendable; break; case FiroType.spark: availableBalance = wallet.info.cachedBalanceTertiary.spendable; break; } } else { availableBalance = ref.read(pWalletBalance(walletId)).spendable; } final coinControlEnabled = ref.read(prefsChangeNotifierProvider).enableCoinControl; if (coin is! Ethereum && !(wallet is CoinControlInterface && coinControlEnabled) || (wallet is CoinControlInterface && coinControlEnabled && selectedUTXOs.isEmpty)) { // confirm send all if (amount == availableBalance) { bool? shouldSendAll; if (mounted) { shouldSendAll = await showDialog( context: context, useSafeArea: false, barrierDismissible: true, builder: (context) { return StackDialog( title: "Confirm send all", message: "You are about to send your entire balance. Would you like to continue?", leftButton: TextButton( style: Theme.of(context) .extension()! .getSecondaryEnabledButtonStyle(context), child: Text( "Cancel", style: STextStyles.button(context).copyWith( color: Theme.of(context) .extension()! .accentColorDark, ), ), onPressed: () { Navigator.of(context).pop(false); }, ), rightButton: TextButton( style: Theme.of(context) .extension()! .getPrimaryEnabledButtonStyle(context), child: Text( "Yes", style: STextStyles.button(context), ), onPressed: () { Navigator.of(context).pop(true); }, ), ); }, ); } if (shouldSendAll == null || shouldSendAll == false) { // cancel preview return; } } } try { bool wasCancelled = false; if (mounted) { unawaited( showDialog( context: context, useSafeArea: false, barrierDismissible: false, builder: (context) { return BuildingTransactionDialog( coin: wallet.info.coin, isSpark: wallet is FiroWallet && ref.read(publicPrivateBalanceStateProvider.state).state == FiroType.spark, onCancel: () { wasCancelled = true; Navigator.of(context).pop(); }, ); }, ), ); } final time = Future.delayed( const Duration( milliseconds: 2500, ), ); Future txDataFuture; if (isPaynymSend) { final feeRate = ref.read(feeRateTypeStateProvider); txDataFuture = (wallet as PaynymInterface).preparePaymentCodeSend( txData: TxData( paynymAccountLite: widget.accountLite!, recipients: [ ( address: widget.accountLite!.code, amount: amount, isChange: false, ), ], satsPerVByte: isCustomFee ? customFeeRate : null, feeRateType: feeRate, utxos: (wallet is CoinControlInterface && coinControlEnabled && selectedUTXOs.isNotEmpty) ? selectedUTXOs : null, ), ); } else if (wallet is FiroWallet) { switch (ref.read(publicPrivateBalanceStateProvider.state).state) { case FiroType.public: if (ref.read(pValidSparkSendToAddress)) { txDataFuture = wallet.prepareSparkMintTransaction( txData: TxData( sparkRecipients: [ ( address: _address!, amount: amount, memo: memoController.text, isChange: false, ), ], feeRateType: ref.read(feeRateTypeStateProvider), satsPerVByte: isCustomFee ? customFeeRate : null, utxos: (wallet is CoinControlInterface && coinControlEnabled && selectedUTXOs.isNotEmpty) ? selectedUTXOs : null, ), ); } else { txDataFuture = wallet.prepareSend( txData: TxData( recipients: [ ( address: _address!, amount: amount, isChange: false, ), ], feeRateType: ref.read(feeRateTypeStateProvider), satsPerVByte: isCustomFee ? customFeeRate : null, utxos: (wallet is CoinControlInterface && coinControlEnabled && selectedUTXOs.isNotEmpty) ? selectedUTXOs : null, ), ); } break; case FiroType.lelantus: txDataFuture = wallet.prepareSendLelantus( txData: TxData( recipients: [ ( address: _address!, amount: amount, isChange: false, ), ], ), ); break; case FiroType.spark: txDataFuture = wallet.prepareSendSpark( txData: TxData( recipients: ref.read(pValidSparkSendToAddress) ? null : [ ( address: _address!, amount: amount, isChange: false, ), ], sparkRecipients: ref.read(pValidSparkSendToAddress) ? [ ( address: _address!, amount: amount, memo: memoController.text, isChange: false, ), ] : null, ), ); break; } } else { final memo = coin is Stellar ? memoController.text : null; txDataFuture = wallet.prepareSend( txData: TxData( recipients: [ ( address: _address!, amount: amount, isChange: false, ), ], memo: memo, feeRateType: ref.read(feeRateTypeStateProvider), satsPerVByte: isCustomFee ? customFeeRate : null, utxos: (wallet is CoinControlInterface && coinControlEnabled && selectedUTXOs.isNotEmpty) ? selectedUTXOs : null, ), ); } final results = await Future.wait([ txDataFuture, time, ]); TxData txData = results.first as TxData; if (!wasCancelled && mounted) { if (isPaynymSend) { txData = txData.copyWith( paynymAccountLite: widget.accountLite!, note: noteController.text.isNotEmpty ? noteController.text : "PayNym send", ); } else { txData = txData.copyWith(note: noteController.text); txData = txData.copyWith(noteOnChain: onChainNoteController.text); } // pop building dialog Navigator.of(context).pop(); unawaited( Navigator.of(context).push( RouteGenerator.getRoute( shouldUseMaterialRoute: RouteGenerator.useMaterialPageRoute, builder: (_) => ConfirmTransactionView( txData: txData, walletId: walletId, isPaynymTransaction: isPaynymSend, onSuccess: clearSendForm, ), settings: const RouteSettings( name: ConfirmTransactionView.routeName, ), ), ), ); } } catch (e, s) { Logging.instance.log("$e\n$s", level: LogLevel.Error); if (mounted) { // pop building dialog Navigator.of(context).pop(); unawaited( showDialog( context: context, useSafeArea: false, barrierDismissible: true, builder: (context) { return StackDialog( title: "Transaction failed", message: e.toString(), rightButton: TextButton( style: Theme.of(context) .extension()! .getSecondaryEnabledButtonStyle(context), child: Text( "Ok", style: STextStyles.button(context).copyWith( color: Theme.of(context) .extension()! .accentColorDark, ), ), onPressed: () { Navigator.of(context).pop(); }, ), ); }, ), ); } } } void clearSendForm() { sendToController.text = ""; cryptoAmountController.text = ""; baseAmountController.text = ""; noteController.text = ""; onChainNoteController.text = ""; feeController.text = ""; memoController.text = ""; _address = ""; _addressToggleFlag = false; if (mounted) { setState(() {}); } } String _getSendAllTitle(bool showCoinControl, Set selectedUTXOs) { if (showCoinControl && selectedUTXOs.isNotEmpty) { return "Send all selected"; } return "Send all ${coin.ticker}"; } Amount _selectedUtxosAmount(Set utxos) => Amount( rawValue: utxos.map((e) => BigInt.from(e.value)).reduce((v, e) => v += e), fractionDigits: ref.read(pWalletCoin(walletId)).fractionDigits, ); Future _sendAllTapped(bool showCoinControl) async { final Amount amount; if (showCoinControl && selectedUTXOs.isNotEmpty) { amount = _selectedUtxosAmount(selectedUTXOs); } else if (isFiro) { switch (ref.read(publicPrivateBalanceStateProvider.state).state) { case FiroType.public: amount = ref.read(pWalletBalance(walletId)).spendable; break; case FiroType.lelantus: amount = ref.read(pWalletBalanceSecondary(walletId)).spendable; break; case FiroType.spark: amount = ref.read(pWalletBalanceTertiary(walletId)).spendable; break; } } else { amount = ref.read(pWalletBalance(walletId)).spendable; } cryptoAmountController.text = ref.read(pAmountFormatter(coin)).format( amount, withUnitName: false, ); _cryptoAmountChanged(); } bool get isPaynymSend => widget.accountLite != null; bool isCustomFee = false; int customFeeRate = 1; @override void initState() { coin = widget.coin; WidgetsBinding.instance.addPostFrameCallback((_) { ref.refresh(feeSheetSessionCacheProvider); ref.refresh(pIsExchangeAddress); }); _currentFee = 0.toAmountAsRaw(fractionDigits: coin.fractionDigits); _calculateFeesFuture = calculateFees(0.toAmountAsRaw(fractionDigits: coin.fractionDigits)); _data = widget.autoFillData; walletId = widget.walletId; clipboard = widget.clipboard; scanner = widget.barcodeScanner; isStellar = coin is Stellar; isFiro = coin is Firo; sendToController = TextEditingController(); cryptoAmountController = TextEditingController(); baseAmountController = TextEditingController(); noteController = TextEditingController(); onChainNoteController = TextEditingController(); feeController = TextEditingController(); memoController = TextEditingController(); onCryptoAmountChanged = _cryptoAmountChanged; cryptoAmountController.addListener(onCryptoAmountChanged); baseAmountController.addListener(_baseAmountChanged); if (_data != null) { if (_data!.amount != null) { final amount = Amount.fromDecimal( _data!.amount!, fractionDigits: coin.fractionDigits, ); cryptoAmountController.text = ref.read(pAmountFormatter(coin)).format( amount, withUnitName: false, ); } sendToController.text = _data!.contactLabel; _address = _data!.address.trim(); _addressToggleFlag = true; WidgetsBinding.instance.addPostFrameCallback((timeStamp) { _setValidAddressProviders(_address); }); } if (isPaynymSend) { sendToController.text = widget.accountLite!.nymName; noteController.text = "PayNym send"; WidgetsBinding.instance.addPostFrameCallback( (_) => _setValidAddressProviders(sendToController.text), ); } // if (coin is! Epiccash) { // _cryptoFocus.addListener(() { // if (!_cryptoFocus.hasFocus && !_baseFocus.hasFocus) { // if (_amountToSend == null) { // setState(() { // _calculateFeesFuture = calculateFees(0); // }); // } else { // setState(() { // _calculateFeesFuture = calculateFees( // Format.decimalAmountToSatoshis(_amountToSend!, coin)); // }); // } // } // }); // _baseFocus.addListener(() { // if (!_cryptoFocus.hasFocus && !_baseFocus.hasFocus) { // if (_amountToSend == null) { // setState(() { // _calculateFeesFuture = calculateFees(0); // }); // } else { // setState(() { // _calculateFeesFuture = calculateFees( // Format.decimalAmountToSatoshis(_amountToSend!, coin)); // }); // } // } // }); // } super.initState(); } @override void dispose() { _cryptoAmountChangedFeeUpdateTimer?.cancel(); _baseAmountChangedFeeUpdateTimer?.cancel(); cryptoAmountController.removeListener(onCryptoAmountChanged); baseAmountController.removeListener(_baseAmountChanged); sendToController.dispose(); cryptoAmountController.dispose(); baseAmountController.dispose(); noteController.dispose(); onChainNoteController.dispose(); feeController.dispose(); memoController.dispose(); _noteFocusNode.dispose(); _onChainNoteFocusNode.dispose(); _addressFocusNode.dispose(); _cryptoFocus.dispose(); _baseFocus.dispose(); _memoFocus.dispose(); super.dispose(); } @override Widget build(BuildContext context) { debugPrint("BUILD: $runtimeType"); final wallet = ref.watch(pWallets).getWallet(walletId); final String locale = ref.watch( localeServiceChangeNotifierProvider.select((value) => value.locale), ); final showCoinControl = wallet is CoinControlInterface && ref.watch( prefsChangeNotifierProvider.select( (value) => value.enableCoinControl, ), ) && (coin is Firo ? ref.watch(publicPrivateBalanceStateProvider) == FiroType.public : true); if (isFiro) { final isExchangeAddress = ref.watch(pIsExchangeAddress); ref.listen(publicPrivateBalanceStateProvider, (previous, next) { selectedUTXOs = {}; if (ref.read(pSendAmount) == null) { setState(() { _calculateFeesFuture = calculateFees( 0.toAmountAsRaw(fractionDigits: coin.fractionDigits), ); }); } else { setState(() { _calculateFeesFuture = calculateFees( ref.read(pSendAmount)!, ); }); } if (previous != next && next == FiroType.spark && isExchangeAddress && !_isFiroExWarningDisplayed) { _isFiroExWarningDisplayed = true; WidgetsBinding.instance.addPostFrameCallback( (_) => showFiroExchangeAddressWarning( context, () => _isFiroExWarningDisplayed = false, ), ); } }); } // add listener for epic cash to strip http:// and https:// prefixes if the address also ocntains an @ symbol (indicating an epicbox address) if (coin is Epiccash) { sendToController.addListener(() { _address = sendToController.text.trim(); if (_address != null && _address!.isNotEmpty) { _address = _address!.trim(); if (_address!.contains("\n")) { _address = _address!.substring(0, _address!.indexOf("\n")); } sendToController.text = AddressUtils().formatAddress(_address!); } }); } return Background( child: Scaffold( backgroundColor: Theme.of(context).extension()!.background, appBar: AppBar( leading: AppBarBackButton( onPressed: () async { if (FocusScope.of(context).hasFocus) { FocusScope.of(context).unfocus(); await Future.delayed(const Duration(milliseconds: 50)); } if (context.mounted) { Navigator.of(context).pop(); } }, ), title: Text( "Send ${coin.ticker}", style: STextStyles.navBarTitle(context), ), ), body: LayoutBuilder( builder: (builderContext, constraints) { return Padding( padding: const EdgeInsets.only( left: 12, top: 12, right: 12, ), child: SingleChildScrollView( child: ConstrainedBox( constraints: BoxConstraints( // subtract top and bottom padding set in parent minHeight: constraints.maxHeight - 24, ), child: IntrinsicHeight( child: Padding( padding: const EdgeInsets.symmetric(horizontal: 4), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Container( decoration: BoxDecoration( color: Theme.of(context) .extension()! .popupBG, borderRadius: BorderRadius.circular( Constants.size.circularBorderRadius, ), ), child: Padding( padding: const EdgeInsets.all(12.0), child: Row( children: [ SvgPicture.file( File( ref.watch( coinIconProvider(coin), ), ), width: 22, height: 22, ), const SizedBox( width: 6, ), Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( ref.watch(pWalletName(walletId)), style: STextStyles.titleBold12(context) .copyWith(fontSize: 14), overflow: TextOverflow.ellipsis, maxLines: 1, ), // const SizedBox( // height: 2, // ), if (isFiro) Text( "${ref.watch(publicPrivateBalanceStateProvider.state).state.name.capitalize()} balance", style: STextStyles.label(context) .copyWith(fontSize: 10), ), if (coin is! Firo) Text( "Available balance", style: STextStyles.label(context) .copyWith(fontSize: 10), ), ], ), const Spacer(), Builder( builder: (context) { final Amount amount; if (isFiro) { switch (ref .watch( publicPrivateBalanceStateProvider .state, ) .state) { case FiroType.public: amount = ref .read(pWalletBalance(walletId)) .spendable; break; case FiroType.lelantus: amount = ref .read( pWalletBalanceSecondary( walletId, ), ) .spendable; break; case FiroType.spark: amount = ref .read( pWalletBalanceTertiary( walletId, ), ) .spendable; break; } } else { amount = ref .read(pWalletBalance(walletId)) .spendable; } return GestureDetector( onTap: () { cryptoAmountController.text = ref .read(pAmountFormatter(coin)) .format( amount, withUnitName: false, ); }, child: Container( color: Colors.transparent, child: Column( crossAxisAlignment: CrossAxisAlignment.end, children: [ Text( ref .watch( pAmountFormatter(coin), ) .format(amount), style: STextStyles.titleBold12( context, ).copyWith( fontSize: 10, ), textAlign: TextAlign.right, ), Text( "${(amount.decimal * ref.watch(priceAnd24hChangeNotifierProvider.select((value) => value.getPrice(coin).item1))).toAmount( fractionDigits: 2, ).fiatString( locale: locale, )} ${ref.watch(prefsChangeNotifierProvider.select((value) => value.currency))}", style: STextStyles.subtitle( context, ).copyWith( fontSize: 8, ), textAlign: TextAlign.right, ), ], ), ), ); }, ), ], ), ), ), const SizedBox( height: 16, ), Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( isPaynymSend ? "Send to PayNym address" : "Send to", style: STextStyles.smallMed12(context), textAlign: TextAlign.left, ), // if (coin is Monero) // CustomTextButton( // text: "Use OpenAlias", // onTap: () async { // await showModalBottomSheet( // context: context, // builder: (context) => // OpenAliasBottomSheet( // onSelected: (address) { // sendToController.text = address; // }, // ), // ); // }, // ), ], ), const SizedBox( height: 8, ), if (isPaynymSend) TextField( key: const Key("sendViewPaynymAddressFieldKey"), controller: sendToController, enabled: false, readOnly: true, style: STextStyles.fieldLabel(context), ), if (!isPaynymSend) ClipRRect( borderRadius: BorderRadius.circular( Constants.size.circularBorderRadius, ), child: TextField( key: const Key("sendViewAddressFieldKey"), controller: sendToController, readOnly: false, autocorrect: false, enableSuggestions: false, // inputFormatters: [ // FilteringTextInputFormatter.allow( // RegExp("[a-zA-Z0-9]{34}")), // ], toolbarOptions: const ToolbarOptions( copy: false, cut: false, paste: true, selectAll: false, ), onChanged: (newValue) { _address = newValue.trim(); _setValidAddressProviders(_address); setState(() { _addressToggleFlag = newValue.isNotEmpty; }); }, focusNode: _addressFocusNode, style: STextStyles.field(context), decoration: standardInputDecoration( "Enter ${coin.ticker} address", _addressFocusNode, context, ).copyWith( contentPadding: const EdgeInsets.only( left: 16, top: 6, bottom: 8, right: 5, ), suffixIcon: Padding( padding: sendToController.text.isEmpty ? const EdgeInsets.only(right: 8) : const EdgeInsets.only(right: 0), child: UnconstrainedBox( child: Row( mainAxisAlignment: MainAxisAlignment.spaceAround, children: [ _addressToggleFlag ? TextFieldIconButton( semanticsLabel: "Clear Button. Clears The Address Field Input.", key: const Key( "sendViewClearAddressFieldButtonKey", ), onTap: () { sendToController.text = ""; _address = ""; _setValidAddressProviders( _address, ); setState(() { _addressToggleFlag = false; }); }, child: const XIcon(), ) : TextFieldIconButton( semanticsLabel: "Paste Button. Pastes From Clipboard To Address Field Input.", key: const Key( "sendViewPasteAddressFieldButtonKey", ), onTap: () async { final ClipboardData? data = await clipboard.getData( Clipboard.kTextPlain, ); if (data?.text != null && data! .text!.isNotEmpty) { String content = data.text!.trim(); if (content .contains("\n")) { content = content.substring( 0, content.indexOf( "\n", ), ); } if (coin is Epiccash) { // strip http:// and https:// if content contains @ content = AddressUtils() .formatAddress( content, ); } sendToController.text = content.trim(); _address = content.trim(); _setValidAddressProviders( _address, ); setState(() { _addressToggleFlag = sendToController .text .isNotEmpty; }); } }, child: sendToController .text.isEmpty ? const ClipboardIcon() : const XIcon(), ), if (sendToController.text.isEmpty) TextFieldIconButton( semanticsLabel: "Address Book Button. Opens Address Book For Address Field.", key: const Key( "sendViewAddressBookButtonKey", ), onTap: () { Navigator.of(context).pushNamed( AddressBookView.routeName, arguments: widget.coin, ); }, child: const AddressBookIcon(), ), if (sendToController.text.isEmpty) TextFieldIconButton( semanticsLabel: "Scan QR Button. Opens Camera For Scanning QR Code.", key: const Key( "sendViewScanQrButtonKey", ), onTap: _scanQr, child: const QrCodeIcon(), ), ], ), ), ), ), ), ), const SizedBox( height: 10, ), if (isStellar || (ref.watch(pValidSparkSendToAddress) && ref.watch( publicPrivateBalanceStateProvider, ) != FiroType.lelantus)) ClipRRect( borderRadius: BorderRadius.circular( Constants.size.circularBorderRadius, ), child: TextField( key: const Key("sendViewMemoFieldKey"), maxLength: (coin is Firo) ? 31 : null, controller: memoController, readOnly: false, autocorrect: false, enableSuggestions: false, focusNode: _memoFocus, style: STextStyles.field(context), onChanged: (_) { setState(() {}); }, decoration: standardInputDecoration( "Enter memo (optional)", _memoFocus, context, ).copyWith( counterText: '', contentPadding: const EdgeInsets.only( left: 16, top: 6, bottom: 8, right: 5, ), suffixIcon: Padding( padding: memoController.text.isEmpty ? const EdgeInsets.only(right: 8) : const EdgeInsets.only(right: 0), child: UnconstrainedBox( child: Row( mainAxisAlignment: MainAxisAlignment.spaceAround, children: [ memoController.text.isNotEmpty ? TextFieldIconButton( semanticsLabel: "Clear Button. Clears The Memo Field Input.", key: const Key( "sendViewClearMemoFieldButtonKey", ), onTap: () { memoController.text = ""; setState(() {}); }, child: const XIcon(), ) : TextFieldIconButton( semanticsLabel: "Paste Button. Pastes From Clipboard To Memo Field Input.", key: const Key( "sendViewPasteMemoFieldButtonKey", ), onTap: () async { final ClipboardData? data = await clipboard.getData( Clipboard.kTextPlain, ); if (data?.text != null && data! .text!.isNotEmpty) { final String content = data.text!.trim(); memoController.text = content.trim(); setState(() {}); } }, child: const ClipboardIcon(), ), ], ), ), ), ), ), ), Builder( builder: (_) { final String? error; if (_address == null || _address!.isEmpty) { error = null; } else if (isFiro) { if (ref.watch( publicPrivateBalanceStateProvider, ) == FiroType.lelantus) { if (_data != null && _data!.contactLabel == _address) { error = SparkInterface.validateSparkAddress( address: _data!.address, isTestNet: coin.network == CryptoCurrencyNetwork.test, ) ? "Unsupported" : null; } else if (ref .watch(pValidSparkSendToAddress)) { error = "Unsupported"; } else { error = ref.watch(pValidSendToAddress) ? null : "Invalid address"; } } else { if (_data != null && _data!.contactLabel == _address) { error = null; } else if (!ref.watch(pValidSendToAddress) && !ref.watch(pValidSparkSendToAddress)) { error = "Invalid address"; } else { error = null; } } } else { if (_data != null && _data!.contactLabel == _address) { error = null; } else if (!ref.watch(pValidSendToAddress)) { error = "Invalid address"; } else { error = null; } } if (error == null || error.isEmpty) { return Container(); } else { return Align( alignment: Alignment.topLeft, child: Padding( padding: const EdgeInsets.only( left: 12.0, top: 4.0, ), child: Text( error, textAlign: TextAlign.left, style: STextStyles.label(context).copyWith( color: Theme.of(context) .extension()! .textError, ), ), ), ); } }, ), if (isFiro) const SizedBox( height: 12, ), if (isFiro) Text( "Send from", style: STextStyles.smallMed12(context), textAlign: TextAlign.left, ), if (isFiro) const SizedBox( height: 8, ), if (isFiro) Stack( children: [ TextField( autocorrect: Util.isDesktop ? false : true, enableSuggestions: Util.isDesktop ? false : true, readOnly: true, textInputAction: TextInputAction.none, ), Padding( padding: const EdgeInsets.symmetric( horizontal: 12, ), child: RawMaterialButton( splashColor: Theme.of(context) .extension()! .highlight, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular( Constants.size.circularBorderRadius, ), ), onPressed: () { showModalBottomSheet( backgroundColor: Colors.transparent, context: context, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical( top: Radius.circular(20), ), ), builder: (_) => FiroBalanceSelectionSheet( walletId: walletId, ), ); }, child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Row( children: [ Text( "${ref.watch(publicPrivateBalanceStateProvider.state).state.name.capitalize()} balance", style: STextStyles.itemSubtitle12( context, ), ), const SizedBox( width: 10, ), Builder( builder: (_) { final Amount amount; switch (ref .read( publicPrivateBalanceStateProvider .state, ) .state) { case FiroType.public: amount = ref .watch( pWalletBalance( walletId, ), ) .spendable; break; case FiroType.lelantus: amount = ref .watch( pWalletBalanceSecondary( walletId, ), ) .spendable; break; case FiroType.spark: amount = ref .watch( pWalletBalanceTertiary( walletId, ), ) .spendable; break; } return Text( ref .watch( pAmountFormatter(coin), ) .format( amount, ), style: STextStyles.itemSubtitle( context, ), ); }, ), ], ), SvgPicture.asset( Assets.svg.chevronDown, width: 8, height: 4, color: Theme.of(context) .extension()! .textSubtitle2, ), ], ), ), ), ], ), const SizedBox( height: 12, ), Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( "Amount", style: STextStyles.smallMed12(context), textAlign: TextAlign.left, ), if (coin is! Ethereum && coin is! Tezos) CustomTextButton( text: _getSendAllTitle( showCoinControl, selectedUTXOs), onTap: () => _sendAllTapped(showCoinControl), ), ], ), const SizedBox( height: 8, ), TextField( autocorrect: Util.isDesktop ? false : true, enableSuggestions: Util.isDesktop ? false : true, style: STextStyles.smallMed14(context).copyWith( color: Theme.of(context) .extension()! .textDark, ), key: const Key("amountInputFieldCryptoTextFieldKey"), controller: cryptoAmountController, focusNode: _cryptoFocus, keyboardType: Util.isDesktop ? null : const TextInputType.numberWithOptions( signed: false, decimal: true, ), textAlign: TextAlign.right, inputFormatters: [ AmountInputFormatter( decimals: coin.fractionDigits, unit: ref.watch(pAmountUnit(coin)), locale: locale, ), // regex to validate a crypto amount with 8 decimal places // TextInputFormatter.withFunction((oldValue, // newValue) => // // RegExp(r'^([0-9]*[,.]?[0-9]{0,8}|[,.][0-9]{0,8})$') // // RegExp(r'^\d{1,3}([,\.]\d+)?|[,\.\d]+$') // getAmountRegex(locale, coin.fractionDigits) // .hasMatch(newValue.text) // ? newValue // : oldValue), ], decoration: InputDecoration( contentPadding: const EdgeInsets.only( top: 12, right: 12, ), hintText: "0", hintStyle: STextStyles.fieldLabel(context).copyWith( fontSize: 14, ), prefixIcon: FittedBox( fit: BoxFit.scaleDown, child: Padding( padding: const EdgeInsets.all(12), child: Text( ref .watch(pAmountUnit(coin)) .unitForCoin(coin), style: STextStyles.smallMed14(context) .copyWith( color: Theme.of(context) .extension()! .accentColorDark, ), ), ), ), ), ), if (Prefs.instance.externalCalls) const SizedBox( height: 8, ), if (Prefs.instance.externalCalls) TextField( autocorrect: Util.isDesktop ? false : true, enableSuggestions: Util.isDesktop ? false : true, style: STextStyles.smallMed14(context).copyWith( color: Theme.of(context) .extension()! .textDark, ), key: const Key("amountInputFieldFiatTextFieldKey"), controller: baseAmountController, focusNode: _baseFocus, keyboardType: Util.isDesktop ? null : const TextInputType.numberWithOptions( signed: false, decimal: true, ), textAlign: TextAlign.right, inputFormatters: [ AmountInputFormatter( decimals: 2, locale: locale, ), // regex to validate a fiat amount with 2 decimal places // TextInputFormatter.withFunction((oldValue, // newValue) => // // RegExp(r'^([0-9]*[,.]?[0-9]{0,2}|[,.][0-9]{0,2})$') // getAmountRegex(locale, 2) // .hasMatch(newValue.text) // ? newValue // : oldValue), ], onChanged: _fiatFieldChanged, decoration: InputDecoration( contentPadding: const EdgeInsets.only( top: 12, right: 12, ), hintText: "0", hintStyle: STextStyles.fieldLabel(context).copyWith( fontSize: 14, ), prefixIcon: FittedBox( fit: BoxFit.scaleDown, child: Padding( padding: const EdgeInsets.all(12), child: Text( ref.watch( prefsChangeNotifierProvider .select((value) => value.currency), ), style: STextStyles.smallMed14(context) .copyWith( color: Theme.of(context) .extension()! .accentColorDark, ), ), ), ), ), ), if (showCoinControl) const SizedBox( height: 8, ), if (showCoinControl) RoundedWhiteContainer( child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( "Coin control", style: STextStyles.w500_14(context).copyWith( color: Theme.of(context) .extension()! .textSubtitle1, ), ), CustomTextButton( text: selectedUTXOs.isEmpty ? "Select coins" : "Selected coins (${selectedUTXOs.length})", onTap: () async { if (FocusScope.of(context).hasFocus) { FocusScope.of(context).unfocus(); await Future.delayed( const Duration(milliseconds: 100), ); } if (context.mounted) { final spendable = ref .read(pWalletBalance(walletId)) .spendable; Amount? amount; if (ref.read(pSendAmount) != null) { amount = ref.read(pSendAmount)!; if (spendable == amount) { // this is now a send all } else { amount += _currentFee; } } final result = await Navigator.of(context) .pushNamed( CoinControlView.routeName, arguments: Tuple4( walletId, CoinControlViewType.use, amount, selectedUTXOs, ), ); if (result is Set) { setState(() { selectedUTXOs = result; }); } } }, ), ], ), ), const SizedBox( height: 12, ), if (coin is Epiccash) Text( "On chain Note (optional)", style: STextStyles.smallMed12(context), textAlign: TextAlign.left, ), if (coin is Epiccash) const SizedBox( height: 8, ), if (coin is Epiccash) ClipRRect( borderRadius: BorderRadius.circular( Constants.size.circularBorderRadius, ), child: TextField( autocorrect: Util.isDesktop ? false : true, enableSuggestions: Util.isDesktop ? false : true, maxLength: 256, controller: onChainNoteController, focusNode: _onChainNoteFocusNode, style: STextStyles.field(context), onChanged: (_) => setState(() {}), decoration: standardInputDecoration( "Type something...", _onChainNoteFocusNode, context, ).copyWith( suffixIcon: onChainNoteController .text.isNotEmpty ? Padding( padding: const EdgeInsets.only(right: 0), child: UnconstrainedBox( child: Row( children: [ TextFieldIconButton( child: const XIcon(), onTap: () async { setState(() { onChainNoteController .text = ""; }); }, ), ], ), ), ) : null, ), ), ), if (coin is Epiccash) const SizedBox( height: 12, ), Text( (coin is Epiccash) ? "Local Note (optional)" : "Note (optional)", style: STextStyles.smallMed12(context), textAlign: TextAlign.left, ), const SizedBox( height: 8, ), ClipRRect( borderRadius: BorderRadius.circular( Constants.size.circularBorderRadius, ), child: TextField( autocorrect: Util.isDesktop ? false : true, enableSuggestions: Util.isDesktop ? false : true, controller: noteController, focusNode: _noteFocusNode, style: STextStyles.field(context), onChanged: (_) => setState(() {}), decoration: standardInputDecoration( "Type something...", _noteFocusNode, context, ).copyWith( suffixIcon: noteController.text.isNotEmpty ? Padding( padding: const EdgeInsets.only(right: 0), child: UnconstrainedBox( child: Row( children: [ TextFieldIconButton( child: const XIcon(), onTap: () async { setState(() { noteController.text = ""; }); }, ), ], ), ), ) : null, ), ), ), const SizedBox( height: 12, ), if (coin is! Epiccash && coin is! NanoCurrency && coin is! Tezos) Text( "Transaction fee (estimated)", style: STextStyles.smallMed12(context), textAlign: TextAlign.left, ), if (coin is! Epiccash && coin is! NanoCurrency && coin is! Tezos) const SizedBox( height: 8, ), if (coin is! Epiccash && coin is! NanoCurrency && coin is! Tezos) Stack( children: [ TextField( autocorrect: Util.isDesktop ? false : true, enableSuggestions: Util.isDesktop ? false : true, controller: feeController, readOnly: true, textInputAction: TextInputAction.none, ), Padding( padding: const EdgeInsets.symmetric( horizontal: 12, ), child: RawMaterialButton( splashColor: Theme.of(context) .extension()! .highlight, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular( Constants.size.circularBorderRadius, ), ), onPressed: isFiro && ref .watch( publicPrivateBalanceStateProvider .state, ) .state != FiroType.public ? null : () { showModalBottomSheet( backgroundColor: Colors.transparent, context: context, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical( top: Radius.circular(20), ), ), builder: (_) => TransactionFeeSelectionSheet( walletId: walletId, amount: (Decimal.tryParse( cryptoAmountController .text, ) ?? ref .watch(pSendAmount) ?.decimal ?? Decimal.zero) .toAmount( fractionDigits: coin.fractionDigits, ), updateChosen: (String fee) { if (fee == "custom") { if (!isCustomFee) { setState(() { isCustomFee = true; }); } return; } _setCurrentFee( fee, true, ); setState(() { _calculateFeesFuture = Future(() => fee); if (isCustomFee) { isCustomFee = false; } }); }, ), ); }, child: (isFiro && ref .watch( publicPrivateBalanceStateProvider .state, ) .state != FiroType.public) ? Row( children: [ FutureBuilder( future: _calculateFeesFuture, builder: (context, snapshot) { if (snapshot.connectionState == ConnectionState .done && snapshot.hasData) { _setCurrentFee( snapshot.data!, false, ); return Text( "~${snapshot.data!}", style: STextStyles .itemSubtitle( context, ), ); } else { return AnimatedText( stringsToLoopThrough: const [ "Calculating", "Calculating.", "Calculating..", "Calculating...", ], style: STextStyles .itemSubtitle( context, ), ); } }, ), ], ) : Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Row( children: [ Text( ref .watch( feeRateTypeStateProvider .state, ) .state .prettyName, style: STextStyles .itemSubtitle12( context, ), ), const SizedBox( width: 10, ), FutureBuilder( future: _calculateFeesFuture, builder: (context, snapshot) { if (snapshot.connectionState == ConnectionState .done && snapshot.hasData) { _setCurrentFee( snapshot.data!, false, ); return Text( isCustomFee ? "" : "~${snapshot.data!}", style: STextStyles .itemSubtitle( context, ), ); } else { return AnimatedText( stringsToLoopThrough: const [ "Calculating", "Calculating.", "Calculating..", "Calculating...", ], style: STextStyles .itemSubtitle( context, ), ); } }, ), ], ), SvgPicture.asset( Assets.svg.chevronDown, width: 8, height: 4, color: Theme.of(context) .extension()! .textSubtitle2, ), ], ), ), ), ], ), if (isCustomFee) Padding( padding: const EdgeInsets.only( bottom: 12, top: 16, ), child: FeeSlider( coin: coin, onSatVByteChanged: (rate) { customFeeRate = rate; }, ), ), const Spacer(), const SizedBox( height: 12, ), TextButton( onPressed: ref.watch(pPreviewTxButtonEnabled(coin)) ? _previewTransaction : null, style: ref.watch(pPreviewTxButtonEnabled(coin)) ? Theme.of(context) .extension()! .getPrimaryEnabledButtonStyle(context) : Theme.of(context) .extension()! .getPrimaryDisabledButtonStyle(context), child: Text( "Preview", style: STextStyles.button(context), ), ), const SizedBox( height: 4, ), ], ), ), ), ), ), ); }, ), ), ); } }