/* * 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 '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 '../../models/isar/models/isar_models.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 '../../route_generator.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/logger.dart'; import '../../utilities/prefs.dart'; import '../../utilities/text_styles.dart'; import '../../utilities/util.dart'; import '../../wallets/crypto_currency/crypto_currency.dart'; import '../../wallets/isar/providers/eth/current_token_wallet_provider.dart'; import '../../wallets/isar/providers/eth/token_balance_provider.dart'; import '../../wallets/isar/providers/wallet_info_provider.dart'; import '../../wallets/models/tx_data.dart'; import '../../widgets/animated_text.dart'; import '../../widgets/background.dart'; import '../../widgets/custom_buttons/app_bar_icon_button.dart'; import '../../widgets/icon_widgets/addressbook_icon.dart'; import '../../widgets/icon_widgets/clipboard_icon.dart'; import '../../widgets/icon_widgets/eth_token_icon.dart'; import '../../widgets/icon_widgets/qrcode_icon.dart'; import '../../widgets/icon_widgets/x_icon.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 '../token_view/token_view.dart'; import 'confirm_transaction_view.dart'; import 'sub_widgets/building_transaction_dialog.dart'; import 'sub_widgets/transaction_fee_selection_sheet.dart'; class TokenSendView extends ConsumerStatefulWidget { const TokenSendView({ super.key, required this.walletId, required this.coin, required this.tokenContract, this.autoFillData, this.clipboard = const ClipboardWrapper(), this.barcodeScanner = const BarcodeScannerWrapper(), }); static const String routeName = "/tokenSendView"; final String walletId; final CryptoCurrency coin; final EthContract tokenContract; final SendViewAutoFillData? autoFillData; final ClipboardInterface clipboard; final BarcodeScannerInterface barcodeScanner; @override ConsumerState createState() => _TokenSendViewState(); } class _TokenSendViewState extends ConsumerState { late final String walletId; late final CryptoCurrency coin; late final EthContract tokenContract; late final ClipboardInterface clipboard; late final BarcodeScannerInterface scanner; late TextEditingController sendToController; late TextEditingController cryptoAmountController; late TextEditingController baseAmountController; late TextEditingController noteController; late TextEditingController feeController; late final SendViewAutoFillData? _data; final _addressFocusNode = FocusNode(); final _noteFocusNode = FocusNode(); final _cryptoFocus = FocusNode(); final _baseFocus = FocusNode(); Amount? _amountToSend; Amount? _cachedAmountToSend; String? _address; bool _addressToggleFlag = false; bool _cryptoAmountChangeLock = false; late VoidCallback onCryptoAmountChanged; final updateFeesTimerDuration = const Duration(milliseconds: 500); Timer? _cryptoAmountChangedFeeUpdateTimer; Timer? _baseAmountChangedFeeUpdateTimer; late Future _calculateFeesFuture; String cachedFees = ""; void _onTokenSendViewPasteAddressFieldButtonPressed() 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")); } sendToController.text = content.trim(); _address = content.trim(); _updatePreviewButtonState(_address, _amountToSend); setState(() { _addressToggleFlag = sendToController.text.isNotEmpty; }); } } void _onTokenSendViewScanQrButtonPressed() 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, ); Logging.instance .log("qrResult parsed: $paymentData", level: LogLevel.Info); 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: tokenContract.decimals, ); cryptoAmountController.text = ref.read(pAmountFormatter(coin)).format( amount, withUnitName: false, indicatePrecisionLoss: false, ); _amountToSend = amount; } _updatePreviewButtonState(_address, _amountToSend); setState(() { _addressToggleFlag = sendToController.text.isNotEmpty; }); // now check for non standard encoded basic address } else { _address = qrResult.rawContent.split("\n").first.trim(); sendToController.text = _address ?? ""; _updatePreviewButtonState(_address, _amountToSend); 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 _onFiatAmountFieldChanged(String baseAmountString) { final baseAmount = Amount.tryParseFiatString( baseAmountString, locale: ref.read(localeServiceChangeNotifierProvider).locale, ); if (baseAmount != null) { final _price = ref .read(priceAnd24hChangeNotifierProvider) .getTokenPrice(tokenContract.address) .item1; if (_price == Decimal.zero) { _amountToSend = Amount.zero; } else { _amountToSend = baseAmount <= Amount.zero ? Amount.zero : Amount.fromDecimal( (baseAmount.decimal / _price).toDecimal( scaleOnInfinitePrecision: tokenContract.decimals, ), fractionDigits: tokenContract.decimals, ); } if (_cachedAmountToSend != null && _cachedAmountToSend == _amountToSend) { return; } _cachedAmountToSend = _amountToSend; Logging.instance.log( "it changed $_amountToSend $_cachedAmountToSend", level: LogLevel.Info, ); _cryptoAmountChangeLock = true; cryptoAmountController.text = ref.read(pAmountFormatter(coin)).format( _amountToSend!, withUnitName: false, ); _cryptoAmountChangeLock = false; } else { _amountToSend = Amount.zero; _cryptoAmountChangeLock = true; cryptoAmountController.text = ""; _cryptoAmountChangeLock = false; } // setState(() { // _calculateFeesFuture = calculateFees( // Format.decimalAmountToSatoshis( // _amountToSend!)); // }); _updatePreviewButtonState(_address, _amountToSend); } void _cryptoAmountChanged() async { if (!_cryptoAmountChangeLock) { final cryptoAmount = ref.read(pAmountFormatter(coin)).tryParse( cryptoAmountController.text, ethContract: tokenContract, ); if (cryptoAmount != null) { _amountToSend = cryptoAmount; if (_cachedAmountToSend != null && _cachedAmountToSend == _amountToSend) { return; } _cachedAmountToSend = _amountToSend; Logging.instance.log( "it changed $_amountToSend $_cachedAmountToSend", level: LogLevel.Info, ); final price = ref .read(priceAnd24hChangeNotifierProvider) .getTokenPrice(tokenContract.address) .item1; if (price > Decimal.zero) { baseAmountController.text = (_amountToSend!.decimal * price) .toAmount( fractionDigits: 2, ) .fiatString( locale: ref.read(localeServiceChangeNotifierProvider).locale, ); } } else { _amountToSend = null; baseAmountController.text = ""; } _updatePreviewButtonState(_address, _amountToSend); _cryptoAmountChangedFeeUpdateTimer?.cancel(); _cryptoAmountChangedFeeUpdateTimer = Timer(updateFeesTimerDuration, () { if (coin is! Epiccash && !_baseFocus.hasFocus) { setState(() { _calculateFeesFuture = calculateFees(); }); } }); } } void _baseAmountChanged() { _baseAmountChangedFeeUpdateTimer?.cancel(); _baseAmountChangedFeeUpdateTimer = Timer(updateFeesTimerDuration, () { if (coin is! Epiccash && !_cryptoFocus.hasFocus) { setState(() { _calculateFeesFuture = calculateFees(); }); } }); } 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 _updatePreviewButtonState(String? address, Amount? amount) { final isValidAddress = ref .read(pWallets) .getWallet(walletId) .cryptoCurrency .validateAddress(address ?? ""); ref.read(previewTokenTxButtonStateProvider.state).state = (isValidAddress && amount != null && amount > Amount.zero); } Future calculateFees() async { final wallet = ref.read(pCurrentTokenWallet)!; 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; } final Amount fee = await wallet.estimateFeeFor(Amount.zero, feeRate); cachedFees = ref.read(pAmountFormatter(coin)).format( fee, withUnitName: true, indicatePrecisionLoss: false, ); return cachedFees; } 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 tokenWallet = ref.read(pCurrentTokenWallet)!; final Amount amount = _amountToSend!; // // 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: false, onCancel: () { wasCancelled = true; Navigator.of(context).pop(); }, ); }, ), ); } final time = Future.delayed( const Duration( milliseconds: 2500, ), ); TxData txData; Future txDataFuture; txDataFuture = tokenWallet.prepareSend( txData: TxData( recipients: [ ( address: _address!, amount: amount, isChange: false, ), ], feeRateType: ref.read(feeRateTypeStateProvider), note: noteController.text, ), ); final results = await Future.wait([ txDataFuture, time, ]); txData = results.first as TxData; if (!wasCancelled && mounted) { // pop building dialog Navigator.of(context).pop(); unawaited( Navigator.of(context).push( RouteGenerator.getRoute( shouldUseMaterialRoute: RouteGenerator.useMaterialPageRoute, builder: (_) => ConfirmTransactionView( txData: txData, walletId: walletId, isTokenTx: true, onSuccess: clearSendForm, routeOnSuccessName: TokenView.routeName, ), 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 = ""; feeController.text = ""; _address = ""; _addressToggleFlag = false; if (mounted) { setState(() {}); } } @override void initState() { ref.refresh(feeSheetSessionCacheProvider); _calculateFeesFuture = calculateFees(); _data = widget.autoFillData; walletId = widget.walletId; coin = widget.coin; tokenContract = widget.tokenContract; clipboard = widget.clipboard; scanner = widget.barcodeScanner; sendToController = TextEditingController(); cryptoAmountController = TextEditingController(); baseAmountController = TextEditingController(); noteController = TextEditingController(); feeController = TextEditingController(); onCryptoAmountChanged = _cryptoAmountChanged; cryptoAmountController.addListener(onCryptoAmountChanged); baseAmountController.addListener(_baseAmountChanged); if (_data != null) { if (_data!.amount != null) { cryptoAmountController.text = _data!.amount!.toString(); } sendToController.text = _data!.contactLabel; _address = _data!.address.trim(); _addressToggleFlag = true; } super.initState(); } @override void dispose() { _cryptoAmountChangedFeeUpdateTimer?.cancel(); _baseAmountChangedFeeUpdateTimer?.cancel(); cryptoAmountController.removeListener(onCryptoAmountChanged); baseAmountController.removeListener(_baseAmountChanged); sendToController.dispose(); cryptoAmountController.dispose(); baseAmountController.dispose(); noteController.dispose(); feeController.dispose(); _noteFocusNode.dispose(); _addressFocusNode.dispose(); _cryptoFocus.dispose(); _baseFocus.dispose(); super.dispose(); } @override Widget build(BuildContext context) { debugPrint("BUILD: $runtimeType"); final String locale = ref.watch( localeServiceChangeNotifierProvider.select((value) => value.locale), ); 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 ${tokenContract.symbol}", 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: [ EthTokenIcon( contractAddress: tokenContract.address, ), 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, ), Text( "Available balance", style: STextStyles.label(context) .copyWith(fontSize: 10), ), ], ), const Spacer(), GestureDetector( onTap: () { cryptoAmountController.text = ref .watch(pAmountFormatter(coin)) .format( ref .read( pTokenBalance( ( walletId: widget.walletId, contractAddress: tokenContract.address, ), ), ) .spendable, ethContract: tokenContract, withUnitName: false, indicatePrecisionLoss: true, ); }, child: Container( color: Colors.transparent, child: Column( crossAxisAlignment: CrossAxisAlignment.end, children: [ Text( ref .watch(pAmountFormatter(coin)) .format( ref .watch( pTokenBalance( ( walletId: widget.walletId, contractAddress: tokenContract .address, ), ), ) .spendable, ethContract: tokenContract, ), style: STextStyles.titleBold12(context) .copyWith( fontSize: 10, ), textAlign: TextAlign.right, ), Text( "${(ref.watch( pTokenBalance( ( walletId: widget.walletId, contractAddress: tokenContract .address, ), ), ).spendable.decimal * ref.watch(priceAnd24hChangeNotifierProvider.select((value) => value.getTokenPrice(tokenContract.address).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, ), Text( "Send to", style: STextStyles.smallMed12(context), textAlign: TextAlign.left, ), const SizedBox( height: 8, ), ClipRRect( borderRadius: BorderRadius.circular( Constants.size.circularBorderRadius, ), child: TextField( key: const Key("tokenSendViewAddressFieldKey"), controller: sendToController, readOnly: false, autocorrect: false, enableSuggestions: false, toolbarOptions: const ToolbarOptions( copy: false, cut: false, paste: true, selectAll: false, ), onChanged: (newValue) { _address = newValue.trim(); _updatePreviewButtonState( _address, _amountToSend, ); setState(() { _addressToggleFlag = newValue.isNotEmpty; }); }, focusNode: _addressFocusNode, style: STextStyles.field(context), decoration: standardInputDecoration( "Enter ${tokenContract.symbol} 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( key: const Key( "tokenSendViewClearAddressFieldButtonKey", ), onTap: () { sendToController.text = ""; _address = ""; _updatePreviewButtonState( _address, _amountToSend, ); setState(() { _addressToggleFlag = false; }); }, child: const XIcon(), ) : TextFieldIconButton( key: const Key( "tokenSendViewPasteAddressFieldButtonKey", ), onTap: _onTokenSendViewPasteAddressFieldButtonPressed, child: sendToController .text.isEmpty ? const ClipboardIcon() : const XIcon(), ), if (sendToController.text.isEmpty) TextFieldIconButton( key: const Key( "sendViewAddressBookButtonKey", ), onTap: () { Navigator.of(context).pushNamed( AddressBookView.routeName, arguments: widget.coin, ); }, child: const AddressBookIcon(), ), if (sendToController.text.isEmpty) TextFieldIconButton( key: const Key( "sendViewScanQrButtonKey", ), onTap: _onTokenSendViewScanQrButtonPressed, child: const QrCodeIcon(), ), ], ), ), ), ), ), ), Builder( builder: (_) { final error = _updateInvalidAddressText( _address ?? "", ); 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, ), ), ), ); } }, ), const SizedBox( height: 12, ), Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( "Amount", style: STextStyles.smallMed12(context), textAlign: TextAlign.left, ), // CustomTextButton( // text: "Send all ${tokenContract.symbol}", // onTap: () async { // cryptoAmountController.text = ref // .read(tokenServiceProvider)! // .balance // .getSpendable() // .toStringAsFixed(tokenContract.decimals); // // _cryptoAmountChanged(); // }, // ), ], ), 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: tokenContract.decimals, 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})$') // .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)) .unitForContract(tokenContract), 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})$') // .hasMatch(newValue.text) // ? newValue // : oldValue), ], onChanged: _onFiatAmountFieldChanged, 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, ), ), ), ), ), ), const SizedBox( height: 12, ), Text( "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) Text( "Transaction fee (estimated)", style: STextStyles.smallMed12(context), textAlign: TextAlign.left, ), const SizedBox( height: 8, ), 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: () { showModalBottomSheet( backgroundColor: Colors.transparent, context: context, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical( top: Radius.circular(20), ), ), builder: (_) => TransactionFeeSelectionSheet( walletId: walletId, isToken: true, amount: (Decimal.tryParse( cryptoAmountController.text, ) ?? Decimal.zero) .toAmount( fractionDigits: tokenContract.decimals, ), updateChosen: (String fee) { setState(() { _calculateFeesFuture = Future(() => fee); }); }, ), ); }, child: 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) { return Text( "~${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, ), ], ), ), ), ], ), const Spacer(), const SizedBox( height: 12, ), TextButton( onPressed: ref .watch( previewTokenTxButtonStateProvider.state, ) .state ? _previewTransaction : null, style: ref .watch( previewTokenTxButtonStateProvider.state, ) .state ? Theme.of(context) .extension()! .getPrimaryEnabledButtonStyle(context) : Theme.of(context) .extension()! .getPrimaryDisabledButtonStyle(context), child: Text( "Preview", style: STextStyles.button(context), ), ), const SizedBox( height: 4, ), ], ), ), ), ), ), ); }, ), ), ); } }