stack_wallet/lib/pages/send_view/send_view.dart
2024-01-05 13:45:42 -06:00

2327 lines
104 KiB
Dart

/*
* 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:cw_core/monero_transaction_priority.dart';
import 'package:decimal/decimal.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_native_splash/cli_commands.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:stackwallet/models/isar/models/isar_models.dart';
import 'package:stackwallet/models/paynym/paynym_account_lite.dart';
import 'package:stackwallet/models/send_view_auto_fill_data.dart';
import 'package:stackwallet/pages/address_book_views/address_book_view.dart';
import 'package:stackwallet/pages/coin_control/coin_control_view.dart';
import 'package:stackwallet/pages/send_view/confirm_transaction_view.dart';
import 'package:stackwallet/pages/send_view/sub_widgets/building_transaction_dialog.dart';
import 'package:stackwallet/pages/send_view/sub_widgets/firo_balance_selection_sheet.dart';
import 'package:stackwallet/pages/send_view/sub_widgets/transaction_fee_selection_sheet.dart';
import 'package:stackwallet/providers/providers.dart';
import 'package:stackwallet/providers/ui/fee_rate_type_state_provider.dart';
import 'package:stackwallet/providers/ui/preview_tx_button_state_provider.dart';
import 'package:stackwallet/providers/wallet/public_private_balance_state_provider.dart';
import 'package:stackwallet/route_generator.dart';
import 'package:stackwallet/services/mixins/coin_control_interface.dart';
import 'package:stackwallet/themes/coin_icon_provider.dart';
import 'package:stackwallet/themes/stack_colors.dart';
import 'package:stackwallet/utilities/address_utils.dart';
import 'package:stackwallet/utilities/amount/amount.dart';
import 'package:stackwallet/utilities/amount/amount_formatter.dart';
import 'package:stackwallet/utilities/amount/amount_input_formatter.dart';
import 'package:stackwallet/utilities/amount/amount_unit.dart';
import 'package:stackwallet/utilities/assets.dart';
import 'package:stackwallet/utilities/barcode_scanner_interface.dart';
import 'package:stackwallet/utilities/clipboard_interface.dart';
import 'package:stackwallet/utilities/constants.dart';
import 'package:stackwallet/utilities/enums/coin_enum.dart';
import 'package:stackwallet/utilities/enums/fee_rate_type_enum.dart';
import 'package:stackwallet/utilities/logger.dart';
import 'package:stackwallet/utilities/prefs.dart';
import 'package:stackwallet/utilities/text_styles.dart';
import 'package:stackwallet/utilities/util.dart';
import 'package:stackwallet/wallets/crypto_currency/crypto_currency.dart';
import 'package:stackwallet/wallets/isar/providers/wallet_info_provider.dart';
import 'package:stackwallet/wallets/models/tx_data.dart';
import 'package:stackwallet/wallets/wallet/impl/firo_wallet.dart';
import 'package:stackwallet/wallets/wallet/wallet_mixin_interfaces/paynym_interface.dart';
import 'package:stackwallet/wallets/wallet/wallet_mixin_interfaces/spark_interface.dart';
import 'package:stackwallet/widgets/animated_text.dart';
import 'package:stackwallet/widgets/background.dart';
import 'package:stackwallet/widgets/custom_buttons/app_bar_icon_button.dart';
import 'package:stackwallet/widgets/custom_buttons/blue_text_button.dart';
import 'package:stackwallet/widgets/fee_slider.dart';
import 'package:stackwallet/widgets/icon_widgets/addressbook_icon.dart';
import 'package:stackwallet/widgets/icon_widgets/clipboard_icon.dart';
import 'package:stackwallet/widgets/icon_widgets/qrcode_icon.dart';
import 'package:stackwallet/widgets/icon_widgets/x_icon.dart';
import 'package:stackwallet/widgets/rounded_white_container.dart';
import 'package:stackwallet/widgets/stack_dialog.dart';
import 'package:stackwallet/widgets/stack_text_field.dart';
import 'package:stackwallet/widgets/textfield_icon_button.dart';
import 'package:tuple/tuple.dart';
class SendView extends ConsumerStatefulWidget {
const SendView({
Key? key,
required this.walletId,
required this.coin,
this.autoFillData,
this.clipboard = const ClipboardWrapper(),
this.barcodeScanner = const BarcodeScannerWrapper(),
this.accountLite,
}) : super(key: key);
static const String routeName = "/sendView";
final String walletId;
final Coin coin;
final SendViewAutoFillData? autoFillData;
final ClipboardInterface clipboard;
final BarcodeScannerInterface barcodeScanner;
final PaynymAccountLite? accountLite;
@override
ConsumerState<SendView> createState() => _SendViewState();
}
class _SendViewState extends ConsumerState<SendView> {
late final String walletId;
late final Coin 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 _cryptoAmountChangeLock = false;
late VoidCallback onCryptoAmountChanged;
Set<UTXO> selectedUTXOs = {};
Future<void> _scanQr() async {
try {
// ref
// .read(
// shouldShowLockscreenOnResumeStateProvider
// .state)
// .state = false;
if (FocusScope.of(context).hasFocus) {
FocusScope.of(context).unfocus();
await Future<void>.delayed(const Duration(milliseconds: 75));
}
final qrResult = await scanner.scan();
// Future<void>.delayed(
// const Duration(seconds: 2),
// () => ref
// .read(
// shouldShowLockscreenOnResumeStateProvider
// .state)
// .state = true,
// );
Logging.instance.log("qrResult content: ${qrResult.rawContent}",
level: LogLevel.Info);
final results = AddressUtils.parseUri(qrResult.rawContent);
Logging.instance.log("qrResult parsed: $results", level: LogLevel.Info);
if (results.isNotEmpty && results["scheme"] == coin.uriScheme) {
// auto fill address
_address = (results["address"] ?? "").trim();
sendToController.text = _address!;
// autofill notes field
if (results["message"] != null) {
noteController.text = results["message"]!;
} else if (results["label"] != null) {
noteController.text = results["label"]!;
}
// autofill amount field
if (results["amount"] != null) {
final Amount amount = Decimal.parse(results["amount"]!).toAmount(
fractionDigits: coin.decimals,
);
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 if (ref
.read(pWallets)
.getWallet(walletId)
.cryptoCurrency
.validateAddress(qrResult.rawContent)) {
_address = qrResult.rawContent.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.decimals);
} else {
amount = baseAmount <= Amount.zero
? 0.toAmountAsRaw(fractionDigits: coin.decimals)
: (baseAmount.decimal / _price)
.toDecimal(
scaleOnInfinitePrecision: coin.decimals,
)
.toAmount(fractionDigits: coin.decimals);
}
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.decimals);
_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 != Coin.epicCash && !_baseFocus.hasFocus) {
setState(() {
_calculateFeesFuture = calculateFees(
amount == null
? 0.toAmountAsRaw(fractionDigits: coin.decimals)
: amount!,
);
});
}
});
}
}
final updateFeesTimerDuration = const Duration(milliseconds: 500);
Timer? _cryptoAmountChangedFeeUpdateTimer;
Timer? _baseAmountChangedFeeUpdateTimer;
void _baseAmountChanged() {
_baseAmountChangedFeeUpdateTimer?.cancel();
_baseAmountChangedFeeUpdateTimer = Timer(updateFeesTimerDuration, () {
if (coin != Coin.epicCash && !_cryptoFocus.hasFocus) {
setState(() {
_calculateFeesFuture = calculateFees(
ref.read(pSendAmount) == null
? 0.toAmountAsRaw(fractionDigits: coin.decimals)
: 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.decimals)
: Decimal.parse(fee).toAmount(fractionDigits: coin.decimals);
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 == CryptoCurrencyNetwork.test,
);
}
ref.read(pValidSendToAddress.notifier).state =
wallet.cryptoCurrency.validateAddress(address ?? "");
}
}
late Future<String> _calculateFeesFuture;
Map<Amount, String> cachedFees = {};
Map<Amount, String> cachedFiroLelantusFees = {};
Map<Amount, String> cachedFiroSparkFees = {};
Map<Amount, String> cachedFiroPublicFees = {};
Future<String> 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 == Coin.monero) {
MoneroTransactionPriority specialMoneroId;
switch (ref.read(feeRateTypeStateProvider.state).state) {
case FeeRateType.fast:
specialMoneroId = MoneroTransactionPriority.fast;
break;
case FeeRateType.average:
specialMoneroId = MoneroTransactionPriority.regular;
break;
case FeeRateType.slow:
specialMoneroId = MoneroTransactionPriority.slow;
break;
default:
throw ArgumentError("custom fee not available for monero");
}
fee = await wallet.estimateFeeFor(amount, specialMoneroId.raw!);
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<void> _previewTransaction() async {
// wait for keyboard to disappear
FocusScope.of(context).unfocus();
await Future<void>.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 != Coin.ethereum &&
!(wallet is CoinControlInterface && coinControlEnabled) ||
(wallet is CoinControlInterface &&
coinControlEnabled &&
selectedUTXOs.isEmpty)) {
// confirm send all
if (amount == availableBalance) {
bool? shouldSendAll;
if (mounted) {
shouldSendAll = await showDialog<bool>(
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<StackColors>()!
.getSecondaryEnabledButtonStyle(context),
child: Text(
"Cancel",
style: STextStyles.button(context).copyWith(
color: Theme.of(context)
.extension<StackColors>()!
.accentColorDark),
),
onPressed: () {
Navigator.of(context).pop(false);
},
),
rightButton: TextButton(
style: Theme.of(context)
.extension<StackColors>()!
.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<void>(
context: context,
useSafeArea: false,
barrierDismissible: false,
builder: (context) {
return BuildingTransactionDialog(
coin: wallet.info.coin,
onCancel: () {
wasCancelled = true;
Navigator.of(context).pop();
},
);
},
),
);
}
final time = Future<dynamic>.delayed(
const Duration(
milliseconds: 2500,
),
);
Future<TxData> 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 == Coin.stellar || coin == Coin.stellarTestnet
? 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) {
if (mounted) {
// pop building dialog
Navigator.of(context).pop();
unawaited(
showDialog<dynamic>(
context: context,
useSafeArea: false,
barrierDismissible: true,
builder: (context) {
return StackDialog(
title: "Transaction failed",
message: e.toString(),
rightButton: TextButton(
style: Theme.of(context)
.extension<StackColors>()!
.getSecondaryEnabledButtonStyle(context),
child: Text(
"Ok",
style: STextStyles.button(context).copyWith(
color: Theme.of(context)
.extension<StackColors>()!
.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(() {});
}
}
bool get isPaynymSend => widget.accountLite != null;
bool isCustomFee = false;
int customFeeRate = 1;
@override
void initState() {
coin = widget.coin;
ref.refresh(feeSheetSessionCacheProvider);
_currentFee = 0.toAmountAsRaw(fractionDigits: coin.decimals);
_calculateFeesFuture =
calculateFees(0.toAmountAsRaw(fractionDigits: coin.decimals));
_data = widget.autoFillData;
walletId = widget.walletId;
clipboard = widget.clipboard;
scanner = widget.barcodeScanner;
isStellar = coin == Coin.stellar || coin == Coin.stellarTestnet;
isFiro = coin == Coin.firo || coin == Coin.firoTestNet;
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.decimals,
);
cryptoAmountController.text = ref.read(pAmountFormatter(coin)).format(
amount,
withUnitName: false,
);
}
sendToController.text = _data!.contactLabel;
_address = _data!.address.trim();
_addressToggleFlag = true;
}
if (isPaynymSend) {
sendToController.text = widget.accountLite!.nymName;
noteController.text = "PayNym send";
}
// if (coin != Coin.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,
),
);
if (isFiro) {
ref.listen(publicPrivateBalanceStateProvider, (previous, next) {
if (ref.read(pSendAmount) == null) {
setState(() {
_calculateFeesFuture =
calculateFees(0.toAmountAsRaw(fractionDigits: coin.decimals));
});
} else {
setState(() {
_calculateFeesFuture = calculateFees(
ref.read(pSendAmount)!,
);
});
}
});
}
// add listener for epic cash to strip http:// and https:// prefixes if the address also ocntains an @ symbol (indicating an epicbox address)
if (coin == Coin.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 = formatAddress(_address!);
}
});
}
return Background(
child: Scaffold(
backgroundColor: Theme.of(context).extension<StackColors>()!.background,
appBar: AppBar(
leading: AppBarBackButton(
onPressed: () async {
if (FocusScope.of(context).hasFocus) {
FocusScope.of(context).unfocus();
await Future<void>.delayed(const Duration(milliseconds: 50));
}
if (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<StackColors>()!
.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 != Coin.firo &&
coin != Coin.firoTestNet)
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 == Coin.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: <TextInputFormatter>[
// 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 ==
Coin.epicCash) {
// strip http:// and https:// if content contains @
content = 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"),
controller: memoController,
readOnly: false,
autocorrect: false,
enableSuggestions: false,
focusNode: _memoFocus,
style: STextStyles.field(context),
onChanged: (_) {
setState(() {});
},
decoration: standardInputDecoration(
"Enter memo (optional)",
_memoFocus,
context,
).copyWith(
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) {
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.isTestNet)
? "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<StackColors>()!
.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<StackColors>()!
.highlight,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(
Constants.size.circularBorderRadius,
),
),
onPressed: () {
showModalBottomSheet<dynamic>(
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<StackColors>()!
.textSubtitle2,
),
],
),
),
)
],
),
const SizedBox(
height: 12,
),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
"Amount",
style: STextStyles.smallMed12(context),
textAlign: TextAlign.left,
),
if (coin != Coin.ethereum && coin != Coin.tezos)
CustomTextButton(
text: "Send all ${coin.ticker}",
onTap: () async {
if (isFiro) {
final Amount amount;
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;
}
cryptoAmountController.text = ref
.read(pAmountFormatter(coin))
.format(
amount,
withUnitName: false,
);
} else {
cryptoAmountController.text = ref
.read(pAmountFormatter(coin))
.format(
ref
.read(pWalletBalance(walletId))
.spendable,
withUnitName: false,
);
}
_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<StackColors>()!
.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.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})$')
// // RegExp(r'^\d{1,3}([,\.]\d+)?|[,\.\d]+$')
// getAmountRegex(locale, coin.decimals)
// .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<StackColors>()!
.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<StackColors>()!
.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<StackColors>()!
.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<StackColors>()!
.textSubtitle1,
),
),
CustomTextButton(
text: selectedUTXOs.isEmpty
? "Select coins"
: "Selected coins (${selectedUTXOs.length})",
onTap: () async {
if (FocusScope.of(context).hasFocus) {
FocusScope.of(context).unfocus();
await Future<void>.delayed(
const Duration(milliseconds: 100),
);
}
if (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<UTXO>) {
setState(() {
selectedUTXOs = result;
});
}
}
},
),
],
),
),
const SizedBox(
height: 12,
),
if (coin == Coin.epicCash)
Text(
"On chain Note (optional)",
style: STextStyles.smallMed12(context),
textAlign: TextAlign.left,
),
if (coin == Coin.epicCash)
const SizedBox(
height: 8,
),
if (coin == Coin.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 == Coin.epicCash)
const SizedBox(
height: 12,
),
Text(
(coin == Coin.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 != Coin.epicCash &&
coin != Coin.nano &&
coin != Coin.banano &&
coin != Coin.tezos)
Text(
"Transaction fee (estimated)",
style: STextStyles.smallMed12(context),
textAlign: TextAlign.left,
),
if (coin != Coin.epicCash &&
coin != Coin.nano &&
coin != Coin.banano &&
coin != Coin.tezos)
const SizedBox(
height: 8,
),
if (coin != Coin.epicCash &&
coin != Coin.nano &&
coin != Coin.banano &&
coin != Coin.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<StackColors>()!
.highlight,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(
Constants.size.circularBorderRadius,
),
),
onPressed: isFiro &&
ref
.watch(
publicPrivateBalanceStateProvider
.state)
.state !=
FiroType.public
? null
: () {
showModalBottomSheet<dynamic>(
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.decimals,
),
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<StackColors>()!
.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<StackColors>()!
.getPrimaryEnabledButtonStyle(context)
: Theme.of(context)
.extension<StackColors>()!
.getPrimaryDisabledButtonStyle(context),
child: Text(
"Preview",
style: STextStyles.button(context),
),
),
const SizedBox(
height: 4,
),
],
),
),
),
),
),
);
},
),
),
);
}
}
String formatAddress(String epicAddress) {
// strip http:// or https:// prefixes if the address contains an @ symbol (and is thus an epicbox address)
if ((epicAddress.startsWith("http://") ||
epicAddress.startsWith("https://")) &&
epicAddress.contains("@")) {
epicAddress = epicAddress.replaceAll("http://", "");
epicAddress = epicAddress.replaceAll("https://", "");
}
// strip mailto: prefix
if (epicAddress.startsWith("mailto:")) {
epicAddress = epicAddress.replaceAll("mailto:", "");
}
// strip / suffix if the address contains an @ symbol (and is thus an epicbox address)
if (epicAddress.endsWith("/") && epicAddress.contains("@")) {
epicAddress = epicAddress.substring(0, epicAddress.length - 1);
}
return epicAddress;
}