diff --git a/lib/pages/send_view/send_view.dart b/lib/pages/send_view/send_view.dart index 91a9e4520e..78cf9b87e8 100644 --- a/lib/pages/send_view/send_view.dart +++ b/lib/pages/send_view/send_view.dart @@ -30,6 +30,7 @@ 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 '../../providers/wallet/send_fee_providers.dart'; import '../../route_generator.dart'; import '../../services/openalias/open_alias_service.dart'; import '../../services/spark_names_service.dart'; @@ -55,21 +56,18 @@ import '../../utilities/show_loading.dart'; import '../../utilities/text_styles.dart'; import '../../utilities/util.dart'; import '../../wallets/crypto_currency/crypto_currency.dart'; -import '../../wallets/crypto_currency/intermediate/cryptonote_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/epiccash_wallet.dart'; import '../../wallets/wallet/impl/firo_wallet.dart'; import '../../wallets/wallet/impl/mimblewimblecoin_wallet.dart'; import '../../wallets/wallet/impl/salvium_wallet.dart'; -import '../../wallets/wallet/intermediate/cryptonote_wallet.dart'; import '../../wallets/wallet/wallet_mixin_interfaces/coin_control_interface.dart'; import '../../wallets/wallet/wallet_mixin_interfaces/mweb_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/calculating_future_builder.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'; @@ -118,14 +116,7 @@ class SendView extends ConsumerStatefulWidget { ConsumerState createState() => _SendViewState(); } -class _SendViewState extends ConsumerState { - static const stringsToLoopThrough = [ - "Calculating", - "Calculating.", - "Calculating..", - "Calculating...", - ]; - +class _SendViewState extends ConsumerState with SendFeeForm { late final String walletId; late final CryptoCurrency coin; late final ClipboardInterface clipboard; @@ -608,12 +599,8 @@ class _SendViewState extends ConsumerState { cryptoAmountController.text = ""; _cryptoAmountChangeLock = false; } - // setState(() { - // _calculateFeesFuture = calculateFees( - // Format.decimalAmountToSatoshis( - // _amountToSend!)); - // }); ref.read(pSendAmount.notifier).state = amount; + _syncFeeAmount(amount); } bool _xelisSendAll = false; @@ -667,46 +654,10 @@ class _SendViewState extends ConsumerState { } ref.read(pSendAmount.notifier).state = amount; - - _cryptoAmountChangedFeeUpdateTimer?.cancel(); - _cryptoAmountChangedFeeUpdateTimer = Timer(updateFeesTimerDuration, () { - if (coin is! Epiccash && - coin is! Mimblewimblecoin && - !_baseFocus.hasFocus) { - setState(() { - _calculateFeesFuture = calculateFees( - amount ?? 0.toAmountAsRaw(fractionDigits: coin.fractionDigits), - ); - }); - } - }); + _syncFeeAmount(amount); } } - final updateFeesTimerDuration = const Duration(milliseconds: 500); - - Timer? _cryptoAmountChangedFeeUpdateTimer; - Timer? _baseAmountChangedFeeUpdateTimer; - - void _baseAmountChanged() { - _baseAmountChangedFeeUpdateTimer?.cancel(); - _baseAmountChangedFeeUpdateTimer = Timer(updateFeesTimerDuration, () { - if (coin is! Epiccash && - coin is! Mimblewimblecoin && - !_cryptoFocus.hasFocus) { - setState(() { - _calculateFeesFuture = calculateFees( - ref.read(pSendAmount) == null - ? 0.toAmountAsRaw(fractionDigits: coin.fractionDigits) - : ref.read(pSendAmount)!, - ); - }); - } - }); - } - - late Amount _currentFee; - void _setValidAddressProviders(String? address) { if (_recipientIndexes != null) { ref.read(pValidSendToAddress.notifier).state = _multiRecipients! @@ -744,12 +695,6 @@ class _SendViewState extends ConsumerState { } } - late Future _calculateFeesFuture; - - final Map<(Amount, FeeRateType), Amount> cachedFees = {}; - final Map<(Amount, FeeRateType), Amount> cachedFiroSparkFees = {}; - final Map<(Amount, FeeRateType), Amount> cachedFiroPublicFees = {}; - void _setOpReturnData(String? data) { if (!mounted) { return; @@ -757,127 +702,9 @@ class _SendViewState extends ConsumerState { ref.read(pOpReturnData.notifier).state = data; } - Amount _addOpReturnFeeIfNeeded({ - required Amount fee, - required BigInt feeRate, - required FiroWallet wallet, - }) { - final opReturnData = ref.read(pOpReturnData); - if (opReturnData == null || - opReturnData.isEmpty || - ref.read(publicPrivateBalanceStateProvider) != BalanceType.public) { - return fee; - } - - final extraOutputVSize = AddressUtils.opReturnOutputVSizeFromHex( - opReturnData, - ); - final extraFee = wallet.estimateTxFee( - vSize: extraOutputVSize, - feeRatePerKB: feeRate, - ); - - return fee + - Amount( - rawValue: BigInt.from(extraFee), - fractionDigits: coin.fractionDigits, - ); - } - - Future calculateFees(Amount amount) async { + void _syncFeeAmount(Amount? amount) { // Xelis obtains the exact fee when preparing the reviewed transaction. - if (coin is Xelis) { - return Amount.zeroWith(fractionDigits: coin.fractionDigits); - } - final feeRateType = ref.read(feeRateTypeMobileStateProvider); - final cacheKey = (amount, feeRateType); - final hasOpReturnData = - isFiro && - ref.read(publicPrivateBalanceStateProvider) == BalanceType.public && - (ref.read(pOpReturnData)?.isNotEmpty ?? false); - - if (isFiro) { - switch (ref.read(publicPrivateBalanceStateProvider.state).state) { - case BalanceType.public: - if (!hasOpReturnData && cachedFiroPublicFees[cacheKey] != null) { - return cachedFiroPublicFees[cacheKey]!; - } - break; - case BalanceType.private: - if (cachedFiroSparkFees[cacheKey] != null) { - return cachedFiroSparkFees[cacheKey]!; - } - break; - } - } else if (cachedFees[cacheKey] != null) { - return cachedFees[cacheKey]!; - } - - final wallet = ref.read(pWallets).getWallet(walletId); - final feeObject = await wallet.fees; - - late final BigInt feeRate; - - switch (feeRateType) { - case FeeRateType.fast: - feeRate = feeObject.fast; - break; - case FeeRateType.average: - feeRate = feeObject.medium; - break; - case FeeRateType.slow: - feeRate = feeObject.slow; - break; - default: - feeRate = BigInt.from(-1); - } - - Amount fee; - if (coin is CryptonoteCurrency) { - final int specialMoneroId; - switch (feeRateType) { - case FeeRateType.fast: - specialMoneroId = (wallet as CryptonoteWallet).getTxPriorityHigh(); - break; - case FeeRateType.average: - specialMoneroId = (wallet as CryptonoteWallet).getTxPriorityMedium(); - break; - case FeeRateType.slow: - specialMoneroId = (wallet as CryptonoteWallet).getTxPriorityNormal(); - break; - default: - throw ArgumentError("custom fee not available for monero"); - } - - fee = await wallet.estimateFeeFor(amount, BigInt.from(specialMoneroId)); - cachedFees[cacheKey] = fee; - return fee; - } else if (isFiro) { - final firoWallet = wallet as FiroWallet; - - switch (ref.read(publicPrivateBalanceStateProvider.state).state) { - case BalanceType.public: - fee = await firoWallet.estimateFeeFor(amount, feeRate); - fee = _addOpReturnFeeIfNeeded( - fee: fee, - feeRate: feeRate, - wallet: firoWallet, - ); - if (!hasOpReturnData) { - cachedFiroPublicFees[cacheKey] = fee; - } - return fee; - - case BalanceType.private: - fee = await firoWallet.estimateFeeForSpark(amount); - cachedFiroSparkFees[cacheKey] = fee; - return fee; - } - } else { - fee = await wallet.estimateFeeFor(amount, feeRate); - cachedFees[cacheKey] = fee; - return fee; - } + if (hasFees && coin is! Xelis) followFeeAmount(amount); } Future _createSlatepack() async { @@ -1231,7 +1058,7 @@ class _SendViewState extends ConsumerState { final time = Future.delayed(const Duration(milliseconds: 2500)); Future txDataFuture; - final feeRateType = ref.read(feeRateTypeMobileStateProvider); + final feeRateType = ref.read(pFeeRateType); final satsPerVByte = feeRateType.customSatsPerVByte(customFeeRate); if (isPaynymSend) { @@ -1553,40 +1380,7 @@ class _SendViewState extends ConsumerState { late final bool hasFees; - void _onFeeSelectPressed() { - showModalBottomSheet( - backgroundColor: Colors.transparent, - context: context, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(20)), - ), - builder: (_) => TransactionFeeSelectionSheet( - walletId: walletId, - amount: - ref.read(pSendAmount) ?? - Amount.zeroWith(fractionDigits: coin.fractionDigits), - updateChosen: (feeRateType, fee) { - if (feeRateType.isCustom) { - return; - } - - setState(() { - if (fee != null) { - _currentFee = fee; - _calculateFeesFuture = Future.value(fee); - } else { - _calculateFeesFuture = calculateFees( - ref.read(pSendAmount) ?? - Amount.zeroWith(fractionDigits: coin.fractionDigits), - ); - } - customFeeRate = 1; - _ethFee.value = null; - }); - }, - ), - ); - } + void _onFeeSelectPressed() => showFeeSheet(this); @override void initState() { @@ -1597,18 +1391,16 @@ class _SendViewState extends ConsumerState { _data = widget.autoFillData; walletId = widget.walletId; + fees = SendFeeRates(ref.read, walletId: walletId, isToken: false); clipboard = widget.clipboard; WidgetsBinding.instance.addPostFrameCallback((_) { - ref.refresh(feeSheetSessionCacheProvider); ref.refresh(pIsExchangeAddress); }); - hasFees = coin is! Epiccash && coin is! NanoCurrency && coin is! Tezos; - _currentFee = 0.toAmountAsRaw(fractionDigits: coin.fractionDigits); - - _calculateFeesFuture = calculateFees( - 0.toAmountAsRaw(fractionDigits: coin.fractionDigits), - ); + hasFees = hasSendFee(coin); + if (hasFees && coin is! Xelis) { + retryFeeOnFocusLoss([_cryptoFocus, _baseFocus]); + } sendToController = TextEditingController(); cryptoAmountController = TextEditingController(); @@ -1620,7 +1412,6 @@ class _SendViewState extends ConsumerState { onCryptoAmountChanged = _cryptoAmountChanged; cryptoAmountController.addListener(onCryptoAmountChanged); - baseAmountController.addListener(_baseAmountChanged); if (_data != null) { final hasAmount = _data.amount != null; @@ -1656,48 +1447,14 @@ class _SendViewState extends ConsumerState { ); } - // 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(); _ethFee.dispose(); cryptoAmountController.removeListener(onCryptoAmountChanged); - baseAmountController.removeListener(_baseAmountChanged); sendToController.dispose(); cryptoAmountController.dispose(); @@ -1719,7 +1476,11 @@ class _SendViewState extends ConsumerState { @override Widget build(BuildContext context) { debugPrint("BUILD: $runtimeType"); - final isCustomFee = ref.watch(feeRateTypeMobileStateProvider).isCustom; + final isCustomFee = ref.watch(pFeeRateType).isCustom; + listenForCustomFeeLevel(ref, coin, () { + customFeeRate = 1; + _ethFee.value = null; + }); final String locale = ref.watch( localeServiceChangeNotifierProvider.select((value) => value.locale), ); @@ -1743,6 +1504,8 @@ class _SendViewState extends ConsumerState { final amountFormatter = ref.watch(pAmountFormatter(coin)); final balType = ref.watch(publicPrivateBalanceStateProvider); + final canPick = canPickFeeRateType(coin, balType); + final hasCustomFee = isCustomFee && canPick; // ethFee is checked in the ValueListenableBuilder around the preview // button so fee keystrokes don't rebuild this whole view. final previewEnabled = @@ -1769,18 +1532,7 @@ class _SendViewState extends ConsumerState { 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)!); - }); - } + retryFee(); if (previous != next && next == BalanceType.private && @@ -2725,7 +2477,12 @@ class _SendViewState extends ConsumerState { if (spendable == amount) { // this is now a send all } else { - amount += _currentFee; + amount += + shownFee ?? + Amount.zeroWith( + fractionDigits: + coin.fractionDigits, + ); } } @@ -2869,22 +2626,15 @@ class _SendViewState extends ConsumerState { ), ), const SizedBox(height: 12), - if (coin is! Epiccash && - coin is! Mimblewimblecoin && - coin is! NanoCurrency && - coin is! Tezos) + if (hasFees) Text( - coin is Xelis + coin is Xelis || hasCustomFee ? "Transaction fee" : "Transaction fee (estimated)", style: STextStyles.smallMed12(context), textAlign: TextAlign.left, ), - if (coin is! Epiccash && - coin is! Mimblewimblecoin && - coin is! NanoCurrency && - coin is! Tezos) - const SizedBox(height: 8), + if (hasFees) const SizedBox(height: 8), if (coin is Xelis) Padding( padding: const EdgeInsets.all(12), @@ -2893,10 +2643,7 @@ class _SendViewState extends ConsumerState { style: STextStyles.itemSubtitle(context), ), ) - else if (coin is! Epiccash && - coin is! Mimblewimblecoin && - coin is! NanoCurrency && - coin is! Tezos) + else if (hasFees) Stack( children: [ TextField( @@ -2925,149 +2672,74 @@ class _SendViewState extends ConsumerState { Constants.size.circularBorderRadius, ), ), - onPressed: - isFiro && - ref - .watch( - publicPrivateBalanceStateProvider - .state, - ) - .state != - BalanceType.public - ? null - : _onFeeSelectPressed, - child: - (isFiro && - ref + onPressed: canPick + ? _onFeeSelectPressed + : null, + child: Row( + mainAxisAlignment: + MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + if (canPick) ...[ + Text( + ref .watch( - publicPrivateBalanceStateProvider - .state, + pFeeRateType.state, ) - .state != - BalanceType.public) - ? Row( - children: [ - FutureBuilder( - future: - _calculateFeesFuture, - builder: (context, snapshot) { - if (snapshot.connectionState == - ConnectionState - .done && - snapshot.hasData) { - _currentFee = - snapshot.data!; - final formattedFee = - amountFormatter.format( - snapshot.data!, - withUnitName: - true, - indicatePrecisionLoss: - false, - ); - return Text( - "~$formattedFee", - style: - STextStyles.itemSubtitle( - context, - ), - ); - } else { - return AnimatedText( - stringsToLoopThrough: - stringsToLoopThrough, - style: - STextStyles.itemSubtitle( - context, - ), - ); - } - }, + .state + .prettyName, + style: + STextStyles.itemSubtitle12( + context, + ), ), + const SizedBox(width: 10), ], - ) - : Row( - mainAxisAlignment: - MainAxisAlignment - .spaceBetween, - children: [ - Row( - children: [ - Text( - ref - .watch( - feeRateTypeMobileStateProvider - .state, - ) - .state - .prettyName, - style: - STextStyles.itemSubtitle12( - context, - ), - ), - const SizedBox(width: 10), - FutureBuilder( - future: - _calculateFeesFuture, - builder: (context, snapshot) { - if (snapshot.connectionState == - ConnectionState - .done && - snapshot - .hasData) { - _currentFee = - snapshot.data!; - final formattedFee = - amountFormatter.format( - snapshot - .data!, - withUnitName: - true, - indicatePrecisionLoss: - false, - ); - return Text( - isCustomFee - ? "" - : "~$formattedFee", - style: - STextStyles.itemSubtitle( - context, - ), - ); - } else { - return AnimatedText( - stringsToLoopThrough: - stringsToLoopThrough, - style: - STextStyles.itemSubtitle( - context, - ), - ); - } - }, + CalculatingFutureBuilder( + future: watchFee(), + style: + STextStyles.itemSubtitle( + context, ), - ], - ), - SvgPicture.asset( - Assets.svg.chevronDown, - width: 8, - height: 4, - color: Theme.of(context) - .extension< - StackColors - >()! - .textSubtitle2, - ), - ], + builder: (fee) { + final formattedFee = + amountFormatter.format( + fee, + withUnitName: true, + indicatePrecisionLoss: + false, + ); + return Text( + hasCustomFee + ? "" + : "~$formattedFee", + style: + STextStyles.itemSubtitle( + context, + ), + ); + }, + ), + ], + ), + if (canPick) + SvgPicture.asset( + Assets.svg.chevronDown, + width: 8, + height: 4, + color: Theme.of(context) + .extension()! + .textSubtitle2, ), + ], + ), ), ), ), ], ), - if (isCustomFee && !isEth) + if (hasCustomFee && !isEth) Padding( padding: const EdgeInsets.only( bottom: 12, diff --git a/lib/pages/send_view/sol_token_send_view.dart b/lib/pages/send_view/sol_token_send_view.dart index c4ea873044..9187a391db 100644 --- a/lib/pages/send_view/sol_token_send_view.dart +++ b/lib/pages/send_view/sol_token_send_view.dart @@ -21,6 +21,7 @@ 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/send_fee_providers.dart'; import '../../route_generator.dart'; import '../../themes/stack_colors.dart'; import '../../utilities/address_utils.dart'; @@ -43,8 +44,8 @@ import '../../wallets/isar/providers/solana/current_sol_token_wallet_provider.da import '../../wallets/isar/providers/solana/sol_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/calculating_future_builder.dart'; import '../../widgets/custom_buttons/app_bar_icon_button.dart'; import '../../widgets/icon_widgets/clipboard_icon.dart'; import '../../widgets/icon_widgets/qrcode_icon.dart'; @@ -93,7 +94,8 @@ class SolTokenSendView extends ConsumerStatefulWidget { ConsumerState createState() => _SolTokenSendViewState(); } -class _SolTokenSendViewState extends ConsumerState { +class _SolTokenSendViewState extends ConsumerState + with SendFeeForm { late final String walletId; late final String tokenMint; late final ClipboardInterface clipboard; @@ -122,12 +124,6 @@ class _SolTokenSendViewState extends ConsumerState { bool _cryptoAmountChangeLock = false; late VoidCallback onCryptoAmountChanged; - final updateFeesTimerDuration = const Duration(milliseconds: 500); - - Timer? _cryptoAmountChangedFeeUpdateTimer; - Timer? _baseAmountChangedFeeUpdateTimer; - late Future _calculateFeesFuture; - void _onTokenSendViewPasteAddressFieldButtonPressed() async { final ClipboardData? data = await clipboard.getData(Clipboard.kTextPlain); if (data?.text != null && data!.text!.isNotEmpty) { @@ -294,6 +290,7 @@ class _SolTokenSendViewState extends ConsumerState { _cryptoAmountChangeLock = false; } _updatePreviewButtonState(_address, _amountToSend); + followFeeAmount(_amountToSend); } // Controller assignments used for display must not edit the stored amount. @@ -347,29 +344,10 @@ class _SolTokenSendViewState extends ConsumerState { } _updatePreviewButtonState(_address, _amountToSend); - - _cryptoAmountChangedFeeUpdateTimer?.cancel(); - _cryptoAmountChangedFeeUpdateTimer = Timer(updateFeesTimerDuration, () { - if (mounted) { - setState(() { - _calculateFeesFuture = calculateFees(); - }); - } - }); + followFeeAmount(_amountToSend); } } - void _baseAmountChanged() { - _baseAmountChangedFeeUpdateTimer?.cancel(); - _baseAmountChangedFeeUpdateTimer = Timer(updateFeesTimerDuration, () { - if (mounted && !_cryptoFocus.hasFocus) { - setState(() { - _calculateFeesFuture = calculateFees(); - }); - } - }); - } - String? _updateInvalidAddressText(String address) { if (_data != null && _data.contactLabel == address) { return null; @@ -391,48 +369,6 @@ class _SolTokenSendViewState extends ConsumerState { (isValidAddress && amount != null && amount > Amount.zero); } - Future calculateFees() async { - final solana = Solana(CryptoCurrencyNetwork.main); - final minimumFee = Amount( - rawValue: BigInt.from(5000), - fractionDigits: solana.fractionDigits, - ); - try { - final wallet = ref.read(pCurrentSolanaTokenWallet); - if (wallet == null) { - return minimumFee; - } - - final feeObject = await wallet.fees; - - late final BigInt feeRate; - - switch (ref.read(feeRateTypeMobileStateProvider.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 = BigInt.from(-1); - } - - final Amount fee = await wallet.estimateFeeFor(Amount.zero, feeRate); - return fee; - } catch (e, s) { - Logging.instance.w( - "Failed to calculate Solana token fees: ", - error: e, - stackTrace: s, - ); - return minimumFee; - } - } - Future _previewTransaction() async { // wait for keyboard to disappear FocusScope.of(context).unfocus(); @@ -520,7 +456,7 @@ class _SolTokenSendViewState extends ConsumerState { ), ], memo: memoController.text.isEmpty ? null : memoController.text, - feeRateType: ref.read(feeRateTypeMobileStateProvider), + feeRateType: ref.read(pFeeRateType), note: noteController.text, ), ); @@ -606,15 +542,14 @@ class _SolTokenSendViewState extends ConsumerState { @override void initState() { - ref.refresh(feeSheetSessionCacheProvider); - WidgetsBinding.instance.addPostFrameCallback((_) { - ref.read(feeRateTypeMobileStateProvider.state).state = FeeRateType.slow; + ref.read(pFeeRateType.state).state = FeeRateType.slow; }); - _calculateFeesFuture = calculateFees(); _data = widget.autoFillData; walletId = widget.walletId; + fees = SendFeeRates(ref.read, walletId: walletId, isToken: true); + retryFeeOnFocusLoss([_cryptoFocus, _baseFocus]); tokenMint = widget.tokenMint; clipboard = widget.clipboard; @@ -627,7 +562,6 @@ class _SolTokenSendViewState extends ConsumerState { onCryptoAmountChanged = _cryptoAmountChanged; cryptoAmountController.addListener(onCryptoAmountChanged); - baseAmountController.addListener(_baseAmountChanged); if (_data != null) { if (_data.amount != null) { @@ -651,11 +585,7 @@ class _SolTokenSendViewState extends ConsumerState { @override void dispose() { - _cryptoAmountChangedFeeUpdateTimer?.cancel(); - _baseAmountChangedFeeUpdateTimer?.cancel(); - cryptoAmountController.removeListener(onCryptoAmountChanged); - baseAmountController.removeListener(_baseAmountChanged); sendToController.dispose(); memoController.dispose(); @@ -1284,36 +1214,7 @@ class _SolTokenSendViewState extends ConsumerState { 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: - _amountToSend ?? - Amount.zeroWith( - fractionDigits: tokenWallet - .tokenDecimals, - ), - updateChosen: (_, fee) { - setState(() { - _calculateFeesFuture = - fee == null - ? calculateFees() - : Future.value(fee); - }); - }, - ), - ); - }, + onPressed: () => showFeeSheet(this), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, @@ -1322,10 +1223,7 @@ class _SolTokenSendViewState extends ConsumerState { children: [ Text( ref - .watch( - feeRateTypeMobileStateProvider - .state, - ) + .watch(pFeeRateType.state) .state .prettyName, style: STextStyles.itemSubtitle12( @@ -1333,41 +1231,32 @@ class _SolTokenSendViewState extends ConsumerState { ), ), const SizedBox(width: 10), - FutureBuilder( - future: _calculateFeesFuture, - builder: (context, snapshot) { - if (snapshot.connectionState == - ConnectionState.done && - snapshot.hasData) { - final formattedFee = - amountFormatter.format( - snapshot.data!, - withUnitName: true, - indicatePrecisionLoss: - false, - ); - return Text( - "~$formattedFee", - style: - STextStyles.itemSubtitle( - context, - ), - ); - } else { - return AnimatedText( - stringsToLoopThrough: - const [ - "Calculating", - "Calculating.", - "Calculating..", - "Calculating...", - ], - style: - STextStyles.itemSubtitle( - context, - ), - ); - } + CalculatingFutureBuilder( + future: watchFee(), + style: STextStyles.itemSubtitle( + context, + ), + fallback: Amount( + rawValue: BigInt.from(5000), + fractionDigits: tokenWallet + .cryptoCurrency + .fractionDigits, + ), + builder: (fee) { + final formattedFee = + amountFormatter.format( + fee, + withUnitName: true, + indicatePrecisionLoss: + false, + ); + return Text( + "~$formattedFee", + style: + STextStyles.itemSubtitle( + context, + ), + ); }, ), ], diff --git a/lib/pages/send_view/sub_widgets/transaction_fee_selection_sheet.dart b/lib/pages/send_view/sub_widgets/transaction_fee_selection_sheet.dart index 71fa032f70..f5cab999e6 100644 --- a/lib/pages/send_view/sub_widgets/transaction_fee_selection_sheet.dart +++ b/lib/pages/send_view/sub_widgets/transaction_fee_selection_sheet.dart @@ -11,54 +11,37 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import '../../../models/paymint/fee_object_model.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 '../../../providers/wallet/send_fee_providers.dart'; import '../../../themes/stack_colors.dart'; -import '../../../utilities/address_utils.dart'; import '../../../utilities/amount/amount.dart'; import '../../../utilities/amount/amount_formatter.dart'; import '../../../utilities/constants.dart'; import '../../../utilities/enums/fee_rate_type_enum.dart'; import '../../../utilities/text_styles.dart'; import '../../../wallets/crypto_currency/crypto_currency.dart'; -import '../../../wallets/crypto_currency/intermediate/cryptonote_currency.dart'; -import '../../../wallets/isar/providers/eth/current_token_wallet_provider.dart'; -import '../../../wallets/isar/providers/wallet_info_provider.dart'; -import '../../../wallets/wallet/impl/firo_wallet.dart'; -import '../../../wallets/wallet/intermediate/cryptonote_wallet.dart'; -import '../../../wallets/wallet/wallet.dart'; -import '../../../wallets/wallet/wallet_mixin_interfaces/electrumx_interface.dart'; -import '../../../widgets/animated_text.dart'; - -final feeSheetSessionCacheProvider = - ChangeNotifierProvider((ref) { - return FeeSheetSessionCache(); - }); - -class FeeSheetSessionCache extends ChangeNotifier { - final Map fast = {}; - final Map average = {}; - final Map slow = {}; - - void notify() => notifyListeners(); -} +import '../../../widgets/calculating_future_builder.dart'; + +Future showFeeSheet(SendFeeForm form) => form.chooseFee( + (fees, amount) => showModalBottomSheet( + backgroundColor: Colors.transparent, + context: form.context, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + builder: (_) => TransactionFeeSelectionSheet(fees: fees, amount: amount), + ), +); class TransactionFeeSelectionSheet extends ConsumerStatefulWidget { const TransactionFeeSelectionSheet({ super.key, - required this.walletId, + required this.fees, required this.amount, - required this.updateChosen, - this.isToken = false, }); - final String walletId; - final Amount amount; - final void Function(FeeRateType feeRateType, Amount? fee) updateChosen; - final bool isToken; + final SendFeeRates fees; + final Amount? amount; @override ConsumerState createState() => @@ -67,249 +50,90 @@ class TransactionFeeSelectionSheet extends ConsumerStatefulWidget { class _TransactionFeeSelectionSheetState extends ConsumerState { - late final String walletId; - late final Amount amount; - - FeeObject? feeObject; - - final stringsToLoopThrough = [ - "Calculating", - "Calculating.", - "Calculating..", - "Calculating...", - ]; - - void _selectFeeRate(FeeRateType feeRateType) { - ref.read(feeRateTypeMobileStateProvider.state).state = feeRateType; - widget.updateChosen( - feeRateType, - feeRateType.isCustom ? null : getAmount(feeRateType), - ); - - Navigator.of(context).pop(); - } - - Amount _addFiroOpReturnFee({ - required Amount fee, - required BigInt feeRate, - required FiroWallet wallet, - required CryptoCurrency coin, - }) { - final opReturnData = ref.read(pOpReturnData); - if (opReturnData == null || - opReturnData.isEmpty || - ref.read(publicPrivateBalanceStateProvider) != BalanceType.public) { - return fee; + Widget _feeRateRow(FeeRateType feeRateType) { + final coin = widget.fees.coin; + final style = STextStyles.itemSubtitle(context); + final fee = feeRateType.isCustom + ? null + : widget.fees.estimate(ref.watch, feeRateType, amount: widget.amount); + void select() { + ref.read(pFeeRateType.state).state = feeRateType; + + Navigator.of(context).pop(); } - final extraOutputVSize = AddressUtils.opReturnOutputVSizeFromHex( - opReturnData, - ); - final extraFee = wallet.estimateTxFee( - vSize: extraOutputVSize, - feeRatePerKB: feeRate, + return GestureDetector( + onTap: select, + child: Container( + color: Colors.transparent, + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Column( + children: [ + SizedBox( + width: 20, + height: 20, + child: Radio( + activeColor: Theme.of(context) + .extension()! + .radioButtonIconEnabled, + value: feeRateType, + groupValue: ref.watch(pFeeRateType.state).state, + onChanged: (_) => select(), + ), + ), + ], + ), + const SizedBox(width: 12), + Flexible( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text( + feeRateType.prettyName, + style: STextStyles.titleBold12(context), + textAlign: TextAlign.left, + ), + const SizedBox(width: 2), + if (fee != null) + CalculatingFutureBuilder( + future: fee, + style: style, + builder: (fee) => Text( + "(~${ref.watch(pAmountFormatter(coin)).format(fee, indicatePrecisionLoss: false)})", + style: style, + textAlign: TextAlign.left, + ), + ), + ], + ), + const SizedBox(height: 2), + if (fee != null && coin is! Ethereum) + CalculatingFutureBuilder( + future: widget.fees.rates, + style: style, + builder: (rates) => Text( + sendFeeTime(coin, rates, feeRateType), + style: style, + textAlign: TextAlign.left, + ), + ), + ], + ), + ), + ], + ), + ), ); - - return fee + - Amount( - rawValue: BigInt.from(extraFee), - fractionDigits: coin.fractionDigits, - ); - } - - Future feeFor({ - required Amount amount, - required FeeRateType feeRateType, - required BigInt feeRate, - required CryptoCurrency coin, - }) async { - if (!widget.isToken && - coin is Firo && - ref.read(publicPrivateBalanceStateProvider) == BalanceType.public && - (ref.read(pOpReturnData)?.isNotEmpty ?? false)) { - final wallet = ref.read(pWallets).getWallet(walletId) as FiroWallet; - final fee = await wallet.estimateFeeFor(amount, feeRate); - return _addFiroOpReturnFee( - fee: fee, - feeRate: feeRate, - wallet: wallet, - coin: coin, - ); - } - - switch (feeRateType) { - case FeeRateType.fast: - if (ref.read(feeSheetSessionCacheProvider).fast[amount] == null) { - if (widget.isToken == false) { - final wallet = ref.read(pWallets).getWallet(walletId); - - if (coin is CryptonoteCurrency) { - final fee = await wallet.estimateFeeFor( - amount, - BigInt.from((wallet as CryptonoteWallet).getTxPriorityHigh()), - ); - ref.read(feeSheetSessionCacheProvider).fast[amount] = fee; - } else if (coin is Firo) { - final Amount fee; - switch (ref.read(publicPrivateBalanceStateProvider.state).state) { - case BalanceType.private: - fee = await (wallet as FiroWallet).estimateFeeForSpark( - amount, - ); - case BalanceType.public: - fee = await (wallet as FiroWallet).estimateFeeFor( - amount, - feeRate, - ); - } - - ref.read(feeSheetSessionCacheProvider).fast[amount] = fee; - } else { - ref.read(feeSheetSessionCacheProvider).fast[amount] = await wallet - .estimateFeeFor(amount, feeRate); - } - } else { - final Wallet wallet; - if (coin is Ethereum) { - wallet = ref.read(pCurrentTokenWallet)!; - } else { - wallet = ref.read(pWallets).getWallet(walletId); - } - final fee = await wallet.estimateFeeFor(amount, feeRate); - ref.read(feeSheetSessionCacheProvider).fast[amount] = fee; - } - } - return ref.read(feeSheetSessionCacheProvider).fast[amount]!; - - case FeeRateType.average: - if (ref.read(feeSheetSessionCacheProvider).average[amount] == null) { - if (widget.isToken == false) { - final wallet = ref.read(pWallets).getWallet(walletId); - if (coin is CryptonoteCurrency) { - final fee = await wallet.estimateFeeFor( - amount, - BigInt.from((wallet as CryptonoteWallet).getTxPriorityMedium()), - ); - ref.read(feeSheetSessionCacheProvider).average[amount] = fee; - } else if (coin is Firo) { - final Amount fee; - switch (ref.read(publicPrivateBalanceStateProvider.state).state) { - case BalanceType.private: - fee = await (wallet as FiroWallet).estimateFeeForSpark( - amount, - ); - case BalanceType.public: - fee = await (wallet as FiroWallet).estimateFeeFor( - amount, - feeRate, - ); - } - ref.read(feeSheetSessionCacheProvider).average[amount] = fee; - } else { - ref.read(feeSheetSessionCacheProvider).average[amount] = - await wallet.estimateFeeFor(amount, feeRate); - } - } else { - final Wallet wallet; - if (coin is Ethereum) { - wallet = ref.read(pCurrentTokenWallet)!; - } else { - wallet = ref.read(pWallets).getWallet(walletId); - } - final fee = await wallet.estimateFeeFor(amount, feeRate); - ref.read(feeSheetSessionCacheProvider).average[amount] = fee; - } - } - return ref.read(feeSheetSessionCacheProvider).average[amount]!; - - case FeeRateType.slow: - if (ref.read(feeSheetSessionCacheProvider).slow[amount] == null) { - if (widget.isToken == false) { - final wallet = ref.read(pWallets).getWallet(walletId); - if (coin is CryptonoteCurrency) { - final fee = await wallet.estimateFeeFor( - amount, - BigInt.from((wallet as CryptonoteWallet).getTxPriorityNormal()), - ); - ref.read(feeSheetSessionCacheProvider).slow[amount] = fee; - } else if (coin is Firo) { - final Amount fee; - switch (ref.read(publicPrivateBalanceStateProvider.state).state) { - case BalanceType.private: - fee = await (wallet as FiroWallet).estimateFeeForSpark( - amount, - ); - case BalanceType.public: - fee = await (wallet as FiroWallet).estimateFeeFor( - amount, - feeRate, - ); - } - ref.read(feeSheetSessionCacheProvider).slow[amount] = fee; - } else { - ref.read(feeSheetSessionCacheProvider).slow[amount] = await wallet - .estimateFeeFor(amount, feeRate); - } - } else { - final Wallet wallet; - if (coin is Ethereum) { - wallet = ref.read(pCurrentTokenWallet)!; - } else { - wallet = ref.read(pWallets).getWallet(walletId); - } - final fee = await wallet.estimateFeeFor(amount, feeRate); - ref.read(feeSheetSessionCacheProvider).slow[amount] = fee; - } - } - return ref.read(feeSheetSessionCacheProvider).slow[amount]!; - - default: - return Amount.zero; - } - } - - String estimatedTimeToBeIncludedInNextBlock( - int targetBlockTime, - int estimatedNumberOfBlocks, - ) { - final int time = targetBlockTime * estimatedNumberOfBlocks; - - final int hours = (time / 3600).floor(); - if (hours > 1) { - return "~$hours hours"; - } else if (hours == 1) { - return "~$hours hour"; - } - - // less than an hour - - final string = (time / 60).toStringAsFixed(1); - - if (string == "1.0") { - return "~1 minute"; - } else { - if (string.endsWith(".0")) { - return "~${(time / 60).floor()} minutes"; - } - return "~$string minutes"; - } - } - - @override - void initState() { - walletId = widget.walletId; - amount = widget.amount; - super.initState(); } @override Widget build(BuildContext context) { debugPrint("BUILD: $runtimeType"); - final wallet = ref.watch(pWallets).getWallet(walletId); - - final coin = ref.watch(pWalletCoin(walletId)); - return Container( decoration: BoxDecoration( color: Theme.of(context).extension()!.popupBG, @@ -342,448 +166,20 @@ class _TransactionFeeSelectionSheetState ), ), const SizedBox(height: 36), - FutureBuilder( - future: widget.isToken - ? (coin is Ethereum - ? ref.read(pCurrentTokenWallet)!.fees - : wallet.fees) - : wallet.fees, - builder: (context, AsyncSnapshot snapshot) { - if (snapshot.connectionState == ConnectionState.done && - snapshot.hasData) { - feeObject = snapshot.data!; - } - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - "Fee rate", - style: STextStyles.pageTitleH2(context), - textAlign: TextAlign.left, - ), - const SizedBox(height: 16), - GestureDetector( - onTap: () => _selectFeeRate(FeeRateType.fast), - child: Container( - color: Colors.transparent, - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Column( - mainAxisAlignment: MainAxisAlignment.start, - children: [ - SizedBox( - width: 20, - height: 20, - child: Radio( - activeColor: Theme.of(context) - .extension()! - .radioButtonIconEnabled, - value: FeeRateType.fast, - groupValue: ref - .watch( - feeRateTypeMobileStateProvider - .state, - ) - .state, - onChanged: (_) => - _selectFeeRate(FeeRateType.fast), - ), - ), - ], - ), - const SizedBox(width: 12), - Flexible( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Text( - FeeRateType.fast.prettyName, - style: STextStyles.titleBold12( - context, - ), - textAlign: TextAlign.left, - ), - const SizedBox(width: 2), - if (feeObject == null) - AnimatedText( - stringsToLoopThrough: - stringsToLoopThrough, - style: STextStyles.itemSubtitle( - context, - ), - ), - if (feeObject != null) - FutureBuilder( - future: feeFor( - coin: coin, - feeRateType: FeeRateType.fast, - feeRate: feeObject!.fast, - amount: amount, - ), - builder: (_, AsyncSnapshot snapshot) { - if (snapshot.connectionState == - ConnectionState.done && - snapshot.hasData) { - return Text( - "(~${ref.watch(pAmountFormatter(coin)).format(snapshot.data!, indicatePrecisionLoss: false)})", - style: - STextStyles.itemSubtitle( - context, - ), - textAlign: TextAlign.left, - ); - } else { - return AnimatedText( - stringsToLoopThrough: - stringsToLoopThrough, - style: - STextStyles.itemSubtitle( - context, - ), - ); - } - }, - ), - ], - ), - const SizedBox(height: 2), - if (feeObject == null && coin is! Ethereum) - AnimatedText( - stringsToLoopThrough: - stringsToLoopThrough, - style: STextStyles.itemSubtitle( - context, - ), - ), - if (feeObject != null && coin is! Ethereum) - Text( - estimatedTimeToBeIncludedInNextBlock( - coin.targetBlockTimeSeconds, - feeObject!.numberOfBlocksFast, - ), - style: STextStyles.itemSubtitle( - context, - ), - textAlign: TextAlign.left, - ), - ], - ), - ), - ], - ), - ), - ), - const SizedBox(height: 16), - GestureDetector( - onTap: () => _selectFeeRate(FeeRateType.average), - child: Container( - color: Colors.transparent, - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Column( - children: [ - SizedBox( - width: 20, - height: 20, - child: Radio( - activeColor: Theme.of(context) - .extension()! - .radioButtonIconEnabled, - value: FeeRateType.average, - groupValue: ref - .watch( - feeRateTypeMobileStateProvider - .state, - ) - .state, - onChanged: (_) => - _selectFeeRate(FeeRateType.average), - ), - ), - ], - ), - const SizedBox(width: 12), - Flexible( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Text( - FeeRateType.average.prettyName, - style: STextStyles.titleBold12( - context, - ), - textAlign: TextAlign.left, - ), - const SizedBox(width: 2), - if (feeObject == null) - AnimatedText( - stringsToLoopThrough: - stringsToLoopThrough, - style: STextStyles.itemSubtitle( - context, - ), - ), - if (feeObject != null) - FutureBuilder( - future: feeFor( - coin: coin, - feeRateType: FeeRateType.average, - feeRate: feeObject!.medium, - amount: amount, - ), - builder: (_, AsyncSnapshot snapshot) { - if (snapshot.connectionState == - ConnectionState.done && - snapshot.hasData) { - return Text( - "(~${ref.watch(pAmountFormatter(coin)).format(snapshot.data!, indicatePrecisionLoss: false)})", - style: - STextStyles.itemSubtitle( - context, - ), - textAlign: TextAlign.left, - ); - } else { - return AnimatedText( - stringsToLoopThrough: - stringsToLoopThrough, - style: - STextStyles.itemSubtitle( - context, - ), - ); - } - }, - ), - ], - ), - const SizedBox(height: 2), - if (feeObject == null && coin is! Ethereum) - AnimatedText( - stringsToLoopThrough: - stringsToLoopThrough, - style: STextStyles.itemSubtitle( - context, - ), - ), - if (feeObject != null && coin is! Ethereum) - Text( - estimatedTimeToBeIncludedInNextBlock( - coin.targetBlockTimeSeconds, - feeObject!.numberOfBlocksAverage, - ), - style: STextStyles.itemSubtitle( - context, - ), - textAlign: TextAlign.left, - ), - ], - ), - ), - ], - ), - ), - ), - const SizedBox(height: 16), - GestureDetector( - onTap: () => _selectFeeRate(FeeRateType.slow), - child: Container( - color: Colors.transparent, - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Column( - children: [ - SizedBox( - width: 20, - height: 20, - child: Radio( - activeColor: Theme.of(context) - .extension()! - .radioButtonIconEnabled, - value: FeeRateType.slow, - groupValue: ref - .watch( - feeRateTypeMobileStateProvider - .state, - ) - .state, - onChanged: (_) => - _selectFeeRate(FeeRateType.slow), - ), - ), - ], - ), - const SizedBox(width: 12), - Flexible( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Text( - FeeRateType.slow.prettyName, - style: STextStyles.titleBold12( - context, - ), - textAlign: TextAlign.left, - ), - const SizedBox(width: 2), - if (feeObject == null) - AnimatedText( - stringsToLoopThrough: - stringsToLoopThrough, - style: STextStyles.itemSubtitle( - context, - ), - ), - if (feeObject != null) - FutureBuilder( - future: feeFor( - coin: coin, - feeRateType: FeeRateType.slow, - feeRate: feeObject!.slow, - amount: amount, - ), - builder: (_, AsyncSnapshot snapshot) { - if (snapshot.connectionState == - ConnectionState.done && - snapshot.hasData) { - return Text( - "(~${ref.watch(pAmountFormatter(coin)).format(snapshot.data!, indicatePrecisionLoss: false)})", - style: - STextStyles.itemSubtitle( - context, - ), - textAlign: TextAlign.left, - ); - } else { - return AnimatedText( - stringsToLoopThrough: - stringsToLoopThrough, - style: - STextStyles.itemSubtitle( - context, - ), - ); - } - }, - ), - ], - ), - const SizedBox(height: 2), - if (feeObject == null && coin is! Ethereum) - AnimatedText( - stringsToLoopThrough: - stringsToLoopThrough, - style: STextStyles.itemSubtitle( - context, - ), - ), - if (feeObject != null && coin is! Ethereum) - Text( - estimatedTimeToBeIncludedInNextBlock( - coin.targetBlockTimeSeconds, - feeObject!.numberOfBlocksSlow, - ), - style: STextStyles.itemSubtitle( - context, - ), - textAlign: TextAlign.left, - ), - ], - ), - ), - ], - ), - ), - ), - const SizedBox(height: 24), - if (wallet is ElectrumXInterface || coin is Ethereum) - GestureDetector( - onTap: () => _selectFeeRate(FeeRateType.custom), - child: Container( - color: Colors.transparent, - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Column( - children: [ - SizedBox( - width: 20, - height: 20, - child: Radio( - activeColor: Theme.of(context) - .extension()! - .radioButtonIconEnabled, - value: FeeRateType.custom, - groupValue: ref - .watch( - feeRateTypeMobileStateProvider - .state, - ) - .state, - onChanged: (_) => - _selectFeeRate(FeeRateType.custom), - ), - ), - ], - ), - const SizedBox(width: 12), - Flexible( - child: Column( - crossAxisAlignment: - CrossAxisAlignment.start, - children: [ - Row( - children: [ - Text( - FeeRateType.custom.prettyName, - style: STextStyles.titleBold12( - context, - ), - textAlign: TextAlign.left, - ), - ], - ), - const SizedBox(height: 2), - ], - ), - ), - ], - ), - ), - ), - if (wallet is ElectrumXInterface || coin is Ethereum) - const SizedBox(height: 24), - ], - ); - }, + Text( + "Fee rate", + style: STextStyles.pageTitleH2(context), + textAlign: TextAlign.left, ), + for (final feeRateType in sendFeeRateTypes(widget.fees.coin)) ...[ + SizedBox(height: feeRateType.isCustom ? 24 : 16), + _feeRateRow(feeRateType), + ], + const SizedBox(height: 24), ], ), ), ), ); } - - Amount? getAmount(FeeRateType feeRateType) { - switch (feeRateType) { - case FeeRateType.fast: - return ref.read(feeSheetSessionCacheProvider).fast[amount]; - case FeeRateType.average: - return ref.read(feeSheetSessionCacheProvider).average[amount]; - case FeeRateType.slow: - return ref.read(feeSheetSessionCacheProvider).slow[amount]; - case FeeRateType.custom: - return null; - } - } } diff --git a/lib/pages/send_view/token_send_view.dart b/lib/pages/send_view/token_send_view.dart index 382dd2ca3f..99e7082c39 100644 --- a/lib/pages/send_view/token_send_view.dart +++ b/lib/pages/send_view/token_send_view.dart @@ -21,6 +21,7 @@ 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/send_fee_providers.dart'; import '../../route_generator.dart'; import '../../themes/stack_colors.dart'; import '../../utilities/address_utils.dart'; @@ -45,8 +46,8 @@ 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/calculating_future_builder.dart'; import '../../widgets/custom_buttons/app_bar_icon_button.dart'; import '../../widgets/eth_fee_form.dart'; import '../../widgets/icon_widgets/addressbook_icon.dart'; @@ -85,7 +86,8 @@ class TokenSendView extends ConsumerStatefulWidget { ConsumerState createState() => _TokenSendViewState(); } -class _TokenSendViewState extends ConsumerState { +class _TokenSendViewState extends ConsumerState + with SendFeeForm { late final String walletId; late final CryptoCurrency coin; late final EthContract tokenContract; @@ -113,12 +115,6 @@ class _TokenSendViewState extends ConsumerState { bool _cryptoAmountChangeLock = false; late VoidCallback onCryptoAmountChanged; - final updateFeesTimerDuration = const Duration(milliseconds: 500); - - Timer? _cryptoAmountChangedFeeUpdateTimer; - Timer? _baseAmountChangedFeeUpdateTimer; - late Future _calculateFeesFuture; - final _ethFee = ValueNotifier(null); void _onTokenSendViewPasteAddressFieldButtonPressed() async { @@ -287,12 +283,8 @@ class _TokenSendViewState extends ConsumerState { cryptoAmountController.text = ""; _cryptoAmountChangeLock = false; } - // setState(() { - // _calculateFeesFuture = calculateFees( - // Format.decimalAmountToSatoshis( - // _amountToSend!)); - // }); _updatePreviewButtonState(_address, _amountToSend); + followFeeAmount(_amountToSend); } // Controller assignments used for display must not edit the stored amount. @@ -344,29 +336,10 @@ class _TokenSendViewState extends ConsumerState { } _updatePreviewButtonState(_address, _amountToSend); - - _cryptoAmountChangedFeeUpdateTimer?.cancel(); - _cryptoAmountChangedFeeUpdateTimer = Timer(updateFeesTimerDuration, () { - if (mounted && coin is! Epiccash && !_baseFocus.hasFocus) { - setState(() { - _calculateFeesFuture = calculateFees(); - }); - } - }); + followFeeAmount(_amountToSend); } } - void _baseAmountChanged() { - _baseAmountChangedFeeUpdateTimer?.cancel(); - _baseAmountChangedFeeUpdateTimer = Timer(updateFeesTimerDuration, () { - if (mounted && coin is! Epiccash && !_cryptoFocus.hasFocus) { - setState(() { - _calculateFeesFuture = calculateFees(); - }); - } - }); - } - String? _updateInvalidAddressText(String address) { if (_data != null && _data.contactLabel == address) { return null; @@ -392,30 +365,6 @@ class _TokenSendViewState extends ConsumerState { (isValidAddress && amount != null && amount > Amount.zero); } - Future calculateFees() async { - final wallet = ref.read(pCurrentTokenWallet)!; - final feeObject = await wallet.fees; - - late final BigInt feeRate; - - switch (ref.read(feeRateTypeMobileStateProvider.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 = BigInt.from(-1); - } - - final Amount fee = await wallet.estimateFeeFor(Amount.zero, feeRate); - return fee; - } - Future _previewTransaction() async { // wait for keyboard to disappear FocusScope.of(context).unfocus(); @@ -516,7 +465,7 @@ class _TokenSendViewState extends ConsumerState { )!, ), ], - feeRateType: ref.read(feeRateTypeMobileStateProvider), + feeRateType: ref.read(pFeeRateType), note: noteController.text, ethEIP1559Fee: _ethFee.value, ), @@ -602,11 +551,10 @@ class _TokenSendViewState extends ConsumerState { @override void initState() { - ref.refresh(feeSheetSessionCacheProvider); - - _calculateFeesFuture = calculateFees(); _data = widget.autoFillData; walletId = widget.walletId; + fees = SendFeeRates(ref.read, walletId: walletId, isToken: true); + retryFeeOnFocusLoss([_cryptoFocus, _baseFocus]); coin = widget.coin; tokenContract = widget.tokenContract; clipboard = widget.clipboard; @@ -619,7 +567,6 @@ class _TokenSendViewState extends ConsumerState { onCryptoAmountChanged = _cryptoAmountChanged; cryptoAmountController.addListener(onCryptoAmountChanged); - baseAmountController.addListener(_baseAmountChanged); if (_data != null) { if (_data.amount != null) { @@ -639,12 +586,9 @@ class _TokenSendViewState extends ConsumerState { @override void dispose() { - _cryptoAmountChangedFeeUpdateTimer?.cancel(); - _baseAmountChangedFeeUpdateTimer?.cancel(); _ethFee.dispose(); cryptoAmountController.removeListener(onCryptoAmountChanged); - baseAmountController.removeListener(_baseAmountChanged); sendToController.dispose(); cryptoAmountController.dispose(); @@ -662,7 +606,8 @@ class _TokenSendViewState extends ConsumerState { @override Widget build(BuildContext context) { debugPrint("BUILD: $runtimeType"); - final isCustomFee = ref.watch(feeRateTypeMobileStateProvider).isCustom; + final isCustomFee = ref.watch(pFeeRateType).isCustom; + listenForCustomFeeLevel(ref, coin, () => _ethFee.value = null); final String locale = ref.watch( localeServiceChangeNotifierProvider.select((value) => value.locale), ); @@ -1219,44 +1164,7 @@ class _TokenSendViewState extends ConsumerState { 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: - _amountToSend ?? - Amount.zeroWith( - fractionDigits: - tokenContract.decimals, - ), - updateChosen: (feeRateType, fee) { - if (feeRateType.isCustom) { - return; - } - - setState(() { - if (fee != null) { - _calculateFeesFuture = - Future.value(fee); - } else { - _calculateFeesFuture = - calculateFees(); - } - _ethFee.value = null; - }); - }, - ), - ); - }, + onPressed: () => showFeeSheet(this), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, @@ -1265,10 +1173,7 @@ class _TokenSendViewState extends ConsumerState { children: [ Text( ref - .watch( - feeRateTypeMobileStateProvider - .state, - ) + .watch(pFeeRateType.state) .state .prettyName, style: STextStyles.itemSubtitle12( @@ -1276,46 +1181,31 @@ class _TokenSendViewState extends ConsumerState { ), ), const SizedBox(width: 10), - FutureBuilder( - future: _calculateFeesFuture, - builder: (context, snapshot) { - if (snapshot.connectionState == - ConnectionState.done && - snapshot.hasData) { - final formattedFee = ref - .watch( - pAmountFormatter(coin), - ) - .format( - snapshot.data!, - withUnitName: true, - indicatePrecisionLoss: - false, - ); - return Text( - isCustomFee - ? "" - : "~$formattedFee", - style: - STextStyles.itemSubtitle( - context, - ), - ); - } else { - return AnimatedText( - stringsToLoopThrough: - const [ - "Calculating", - "Calculating.", - "Calculating..", - "Calculating...", - ], - style: - STextStyles.itemSubtitle( - context, - ), - ); - } + CalculatingFutureBuilder( + future: watchFee(), + style: STextStyles.itemSubtitle( + context, + ), + builder: (fee) { + final formattedFee = ref + .watch( + pAmountFormatter(coin), + ) + .format( + fee, + withUnitName: true, + indicatePrecisionLoss: + false, + ); + return Text( + isCustomFee + ? "" + : "~$formattedFee", + style: + STextStyles.itemSubtitle( + context, + ), + ); }, ), ], diff --git a/lib/pages_desktop_specific/my_stack_view/wallet_view/desktop_sol_token_view.dart b/lib/pages_desktop_specific/my_stack_view/wallet_view/desktop_sol_token_view.dart index c8a5a66422..8947d64786 100644 --- a/lib/pages_desktop_specific/my_stack_view/wallet_view/desktop_sol_token_view.dart +++ b/lib/pages_desktop_specific/my_stack_view/wallet_view/desktop_sol_token_view.dart @@ -12,7 +12,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_svg/svg.dart'; -import '../../../pages/send_view/sub_widgets/transaction_fee_selection_sheet.dart'; import '../../../pages/token_view/solana_token_contract_details_view.dart'; import '../../../pages/token_view/sub_widgets/token_transaction_list_widget_sol.dart'; import '../../../services/event_bus/events/global/wallet_sync_status_changed_event.dart'; @@ -88,12 +87,11 @@ class _DesktopTokenViewState extends ConsumerState { Assets.svg.arrowLeft, width: 18, height: 18, - color: Theme.of( - context, - ).extension()!.topNavIconPrimary, + color: Theme.of(context) + .extension()! + .topNavIconPrimary, ), onPressed: () { - ref.refresh(feeSheetSessionCacheProvider); Navigator.of(context).pop(); }, ), @@ -210,9 +208,9 @@ class _DesktopTokenViewState extends ConsumerState { child: Text( "My wallet", style: STextStyles.desktopTextExtraSmall(context).copyWith( - color: Theme.of( - context, - ).extension()!.textFieldActiveSearchIconLeft, + color: Theme.of(context) + .extension()! + .textFieldActiveSearchIconLeft, ), ), ), diff --git a/lib/pages_desktop_specific/my_stack_view/wallet_view/desktop_token_view.dart b/lib/pages_desktop_specific/my_stack_view/wallet_view/desktop_token_view.dart index f112ffe7e1..36036436e0 100644 --- a/lib/pages_desktop_specific/my_stack_view/wallet_view/desktop_token_view.dart +++ b/lib/pages_desktop_specific/my_stack_view/wallet_view/desktop_token_view.dart @@ -13,7 +13,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_svg/svg.dart'; -import '../../../pages/send_view/sub_widgets/transaction_fee_selection_sheet.dart'; import '../../../pages/token_view/sub_widgets/token_transaction_list_widget.dart'; import '../../../pages/token_view/token_contract_details_view.dart'; import '../../../pages/wallet_view/transaction_views/tx_v2/all_transactions_v2_view.dart'; @@ -88,12 +87,11 @@ class _DesktopTokenViewState extends ConsumerState { Assets.svg.arrowLeft, width: 18, height: 18, - color: Theme.of( - context, - ).extension()!.topNavIconPrimary, + color: Theme.of(context) + .extension()! + .topNavIconPrimary, ), onPressed: () { - ref.refresh(feeSheetSessionCacheProvider); Navigator.of(context).pop(); }, ), @@ -220,9 +218,9 @@ class _DesktopTokenViewState extends ConsumerState { child: Text( "My wallet", style: STextStyles.desktopTextExtraSmall(context).copyWith( - color: Theme.of( - context, - ).extension()!.textFieldActiveSearchIconLeft, + color: Theme.of(context) + .extension()! + .textFieldActiveSearchIconLeft, ), ), ), diff --git a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_send.dart b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_send.dart index 77a78bfff4..ebbd81514b 100644 --- a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_send.dart +++ b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_send.dart @@ -32,12 +32,11 @@ import '../../../../pages/send_view/sub_widgets/building_transaction_dialog.dart import '../../../../pages/send_view/sub_widgets/epic_slatepack_dialog.dart'; import '../../../../pages/send_view/sub_widgets/multi_recipient.dart'; import '../../../../pages/send_view/sub_widgets/mwc_slatepack_dialog.dart'; -import '../../../../pages/send_view/sub_widgets/transaction_fee_selection_sheet.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/desktop_fee_providers.dart'; import '../../../../providers/wallet/public_private_balance_state_provider.dart'; +import '../../../../providers/wallet/send_fee_providers.dart'; import '../../../../services/openalias/open_alias_service.dart'; import '../../../../services/spark_names_service.dart'; import '../../../../themes/stack_colors.dart'; @@ -59,7 +58,6 @@ import '../../../../utilities/show_loading.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/epiccash_wallet.dart'; @@ -683,7 +681,7 @@ class _DesktopSendState extends ConsumerState { TxData txData; Future txDataFuture; - final feeRateType = ref.read(feeRateTypeDesktopStateProvider); + final feeRateType = ref.read(pFeeRateType); final satsPerVByte = feeRateType.customSatsPerVByte(customFeeRate); if (isPaynymSend) { @@ -983,9 +981,10 @@ class _DesktopSendState extends ConsumerState { setState(() {}); } + final _feeAmount = SendFeeAmount(); + void _syncFeeAmount(Amount? amount) { - ref.read(sendAmountProvider.notifier).state = - amount ?? Amount.zeroWith(fractionDigits: coin.fractionDigits); + if (mounted) _feeAmount.sync(amount); } void _setOpReturnData(String? data) { @@ -1049,6 +1048,7 @@ class _DesktopSendState extends ConsumerState { } ref.read(pSendAmount.notifier).state = amount; + _feeAmount.typed(amount); } } @@ -1284,7 +1284,6 @@ class _DesktopSendState extends ConsumerState { ? "" : ref.read(pAmountFormatter(coin)).formatEditable(total); ref.read(pSendAmount.notifier).state = total; - _syncFeeAmount(total); _setValidAddressProviders(_address); } @@ -1499,6 +1498,7 @@ class _DesktopSendState extends ConsumerState { // _amountToSend!)); // }); ref.read(pSendAmount.notifier).state = amount; + _feeAmount.typed(amount); } String _getSendAllTitle(bool showCoinControl, Set selectedUTXOs) { @@ -1555,7 +1555,6 @@ class _DesktopSendState extends ConsumerState { @override void initState() { WidgetsBinding.instance.addPostFrameCallback((_) { - ref.refresh(feeSheetSessionCacheProvider); ref.refresh(pIsExchangeAddress); ref.read(pValidSendToAddress.state).state = false; ref.read(pValidSparkSendToAddress.state).state = false; @@ -1613,23 +1612,13 @@ class _DesktopSendState extends ConsumerState { _cryptoFocus.addListener(() { if (!_cryptoFocus.hasFocus && !_baseFocus.hasFocus) { - if (ref.read(pSendAmount) == null) { - if (ref.read(sendAmountProvider) != Amount.zero && mounted) { - ref.read(sendAmountProvider.state).state = Amount.zero; - } - } else { - ref.read(sendAmountProvider.state).state = ref.read(pSendAmount)!; - } + _syncFeeAmount(ref.read(pSendAmount)); } }); _baseFocus.addListener(() { if (!_cryptoFocus.hasFocus && !_baseFocus.hasFocus) { - if (ref.read(pSendAmount) == null) { - ref.refresh(sendAmountProvider); - } else { - ref.read(sendAmountProvider.state).state = ref.read(pSendAmount)!; - } + _syncFeeAmount(ref.read(pSendAmount)); } }); @@ -1640,6 +1629,7 @@ class _DesktopSendState extends ConsumerState { void dispose() { cryptoAmountController.removeListener(onCryptoAmountChanged); _ethFee.dispose(); + _feeAmount.dispose(); sendToController.dispose(); cryptoAmountController.dispose(); @@ -1678,7 +1668,11 @@ class _DesktopSendState extends ConsumerState { _xelisSendAll = xelisSendAll; } }); - final isCustomFee = ref.watch(feeRateTypeDesktopStateProvider).isCustom; + final isCustomFee = ref.watch(pFeeRateType).isCustom; + listenForCustomFeeLevel(ref, coin, () { + customFeeRate = 1; + _ethFee.value = null; + }); // ethFee is checked in the ValueListenableBuilder around the preview // button so fee keystrokes don't rebuild this whole view. final previewEnabled = ref.watch(pPreviewTxButtonEnabled(coin)); @@ -2504,18 +2498,12 @@ class _DesktopSendState extends ConsumerState { ), ), if (!isPaynymSend) const SizedBox(height: 20), - if (coin is! NanoCurrency && - coin is! Epiccash && - coin is! Tezos && - coin is! Mimblewimblecoin) + if (hasSendFee(coin)) DesktopSendFeeForm( walletId: walletId, isToken: false, + amount: _feeAmount, onCustomFeeSliderChanged: (value) => customFeeRate = value, - onCustomFeeOptionChanged: () { - customFeeRate = 1; - _ethFee.value = null; - }, onCustomEip1559FeeOptionChanged: (value) { _ethFee.value = value; }, diff --git a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_send_fee_form.dart b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_send_fee_form.dart index d001f776cf..ee4d19b95f 100644 --- a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_send_fee_form.dart +++ b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_send_fee_form.dart @@ -1,126 +1,122 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import '../../../../models/paymint/fee_object_model.dart'; -import '../../../../pages/send_view/sub_widgets/transaction_fee_selection_sheet.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/desktop_fee_providers.dart'; import '../../../../providers/wallet/public_private_balance_state_provider.dart'; +import '../../../../providers/wallet/send_fee_providers.dart'; import '../../../../themes/stack_colors.dart'; -import '../../../../utilities/address_utils.dart'; import '../../../../utilities/amount/amount.dart'; import '../../../../utilities/enums/fee_rate_type_enum.dart'; import '../../../../utilities/eth_commons.dart'; import '../../../../utilities/text_styles.dart'; import '../../../../wallets/crypto_currency/crypto_currency.dart'; -import '../../../../wallets/crypto_currency/interfaces/electrumx_currency_interface.dart'; -import '../../../../wallets/crypto_currency/intermediate/cryptonote_currency.dart'; -import '../../../../wallets/isar/providers/eth/current_token_wallet_provider.dart'; import '../../../../wallets/isar/providers/wallet_info_provider.dart'; -import '../../../../wallets/wallet/impl/firo_wallet.dart'; -import '../../../../wallets/wallet/intermediate/cryptonote_wallet.dart'; -import '../../../../widgets/animated_text.dart'; import '../../../../widgets/conditional_parent.dart'; import '../../../../widgets/custom_buttons/blue_text_button.dart'; import '../../../../widgets/desktop/desktop_fee_dialog.dart'; import '../../../../widgets/eth_fee_form.dart'; import '../../../../widgets/fee_slider.dart'; +class SendFeeAmount extends ChangeNotifier { + Amount? _value; + bool _isTyped = false; + + Amount? get value => _value; + + /// Whether the fee follows [value] once typing pauses. + bool get isTyped => _isTyped; + + void typed(Amount? amount) => _set(amount, isTyped: true); + + /// Each sync notifies, so a fee that failed is retried on the same amount + /// too. + void sync(Amount? amount) => _set(amount, isTyped: false); + + void _set(Amount? amount, {required bool isTyped}) { + _value = amount; + _isTyped = isTyped; + notifyListeners(); + } +} + class DesktopSendFeeForm extends ConsumerStatefulWidget { const DesktopSendFeeForm({ super.key, required this.walletId, required this.isToken, + required this.amount, required this.onCustomFeeSliderChanged, - required this.onCustomFeeOptionChanged, this.onCustomEip1559FeeOptionChanged, }); final String walletId; final bool isToken; + final SendFeeAmount amount; final void Function(int) onCustomFeeSliderChanged; - final VoidCallback onCustomFeeOptionChanged; final void Function(EthEIP1559Fee?)? onCustomEip1559FeeOptionChanged; @override ConsumerState createState() => _DesktopSendFeeFormState(); } -class _DesktopSendFeeFormState extends ConsumerState { - final stringsToLoopThrough = [ - "Calculating", - "Calculating.", - "Calculating..", - "Calculating...", - ]; - +class _DesktopSendFeeFormState extends ConsumerState + with SendFeeForm { late final CryptoCurrency cryptoCurrency; - Future? _fees; bool get isEth => cryptoCurrency is Ethereum; - (FeeRateType, String?, String?)? feeSelectionResult; - - Amount _addFiroOpReturnFee({ - required Amount fee, - required BigInt feeRate, - required FiroWallet wallet, - }) { - final opReturnData = ref.read(pOpReturnData); - if (opReturnData == null || - opReturnData.isEmpty || - ref.read(publicPrivateBalanceStateProvider) != BalanceType.public) { - return fee; - } - - final extraOutputVSize = AddressUtils.opReturnOutputVSizeFromHex( - opReturnData, - ); - final extraFee = wallet.estimateTxFee( - vSize: extraOutputVSize, - feeRatePerKB: feeRate, - ); - - return fee + - Amount( - rawValue: BigInt.from(extraFee), - fractionDigits: cryptoCurrency.fractionDigits, - ); - } - @override void initState() { super.initState(); cryptoCurrency = ref.read(pWalletCoin(widget.walletId)); + fees = SendFeeRates( + ref.read, + walletId: widget.walletId, + isToken: widget.isToken, + ); + feeAmount = widget.amount.value; + widget.amount.addListener(_amountChanged); - // The fee rate type provider is global and never disposed. Reset it here - // so a stale custom selection from another wallet/send form can't cause a - // send using an unset custom fee rate. + // A send form opened over another one shares its fee rate type, and a + // Custom level picked in the other form has no custom fee rate in this one. WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) { - ref.read(feeRateTypeDesktopStateProvider.state).state = - FeeRateType.average; + ref.read(pFeeRateType.state).state = FeeRateType.average; } }); } + void _amountChanged() => widget.amount.isTyped + ? followFeeAmount(widget.amount.value) + : setFeeAmount(widget.amount.value); + + void _editFee() => chooseFee( + (fees, amount) => showDialog( + context: context, + builder: (_) => DesktopFeeDialog(fees: fees, amount: amount), + ), + ); + + @override + void dispose() { + widget.amount.removeListener(_amountChanged); + super.dispose(); + } + @override Widget build(BuildContext context) { - // Keep the send amount alive for the fee estimates. - ref.listen(sendAmountProvider, (_, __) {}); - final isCustomFee = ref.watch(feeRateTypeDesktopStateProvider).isCustom; + ref.listen(publicPrivateBalanceStateProvider, (_, __) => retryFee()); + final feeRateType = ref.watch(pFeeRateType); + final isCustomFee = feeRateType.isCustom; final locale = ref.watch( localeServiceChangeNotifierProvider.select((value) => value.locale), ); - final canEditFees = - isEth || - cryptoCurrency is Solana || - (cryptoCurrency is ElectrumXCurrencyInterface && - !(((cryptoCurrency is Firo) && - (ref.watch(publicPrivateBalanceStateProvider.state).state == - BalanceType.private)))); + final canPick = canPickFeeRateType( + cryptoCurrency, + ref.watch(publicPrivateBalanceStateProvider.state).state, + ); + final canEditFees = canPick && cryptoCurrency is! Xelis; return Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -131,34 +127,13 @@ class _DesktopSendFeeFormState extends ConsumerState { mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ child, - CustomTextButton( - text: "Edit", - onTap: () async { - final wasCustomFee = isCustomFee; - feeSelectionResult = - await showDialog<(FeeRateType, String?, String?)?>( - context: context, - builder: (_) => DesktopFeeDialog( - walletId: widget.walletId, - isToken: widget.isToken, - ), - ); - - if (feeSelectionResult != null) { - final selectedIsCustomFee = feeSelectionResult!.$1.isCustom; - if (wasCustomFee != selectedIsCustomFee) { - widget.onCustomFeeOptionChanged.call(); - } - } - - setState(() {}); - }, - ), + CustomTextButton(text: "Edit", onTap: _editFee), ], ), child: Text( - "Transaction fee" - "${isCustomFee ? "" : " (${isEth ? "max" : "estimated"})"}", + isCustomFee && canPick || cryptoCurrency is Xelis + ? "Transaction fee" + : "Transaction fee (${isEth ? "max" : "estimated"})", style: STextStyles.desktopTextExtraSmall(context).copyWith( color: Theme.of(context) .extension()! @@ -173,188 +148,15 @@ class _DesktopSendFeeFormState extends ConsumerState { padding: EdgeInsets.all(10), child: Text('Calculated when reviewing'), ) - else if (!isCustomFee) + else if (!isCustomFee || !canPick) Padding( padding: const EdgeInsets.all(10), - child: (feeSelectionResult?.$2 == null) - ? FutureBuilder( - future: _fees ??= ref - .read(pWallets) - .getWallet(widget.walletId) - .fees, - builder: (context, snapshot) { - if (snapshot.connectionState == ConnectionState.done && - snapshot.hasError) { - _fees = null; - } - if (snapshot.connectionState == ConnectionState.done && - snapshot.hasData) { - return DesktopFeeItem( - feeObject: snapshot.data, - feeRateType: FeeRateType.average, - walletId: widget.walletId, - isButton: false, - feeFor: - ({ - required Amount amount, - required FeeRateType feeRateType, - required BigInt feeRate, - required CryptoCurrency coin, - }) async { - if (!widget.isToken && - coin is Firo && - ref.read( - publicPrivateBalanceStateProvider, - ) == - BalanceType.public && - (ref.read(pOpReturnData)?.isNotEmpty ?? - false)) { - final wallet = - ref - .read(pWallets) - .getWallet(widget.walletId) - as FiroWallet; - final fee = await wallet.estimateFeeFor( - amount, - feeRate, - ); - return _addFiroOpReturnFee( - fee: fee, - feeRate: feeRate, - wallet: wallet, - ); - } - - if (ref - .read( - widget.isToken - ? tokenFeeSessionCacheProvider - : feeSheetSessionCacheProvider, - ) - .average[amount] == - null) { - if (widget.isToken == false) { - final wallet = ref - .read(pWallets) - .getWallet(widget.walletId); - - if (coin is CryptonoteCurrency) { - final fee = await wallet.estimateFeeFor( - amount, - BigInt.from( - (wallet as CryptonoteWallet) - .getTxPriorityMedium(), - ), - ); - ref - .read( - feeSheetSessionCacheProvider, - ) - .average[amount] = - fee; - } else if ((coin is Firo) && - ref - .read( - publicPrivateBalanceStateProvider - .state, - ) - .state != - BalanceType.public) { - final firoWallet = wallet as FiroWallet; - - if (ref - .read( - publicPrivateBalanceStateProvider - .state, - ) - .state == - BalanceType.private) { - ref - .read(feeSheetSessionCacheProvider) - .average[amount] = await firoWallet - .estimateFeeForSpark(amount); - } - } else { - ref - .read(feeSheetSessionCacheProvider) - .average[amount] = await wallet - .estimateFeeFor(amount, feeRate); - } - } else { - // Token fee estimation (works for ERC20 and SOL tokens). - try { - final tokenWallet = ref.read( - pCurrentTokenWallet, - )!; - final fee = await tokenWallet - .estimateFeeFor(amount, feeRate); - ref - .read( - tokenFeeSessionCacheProvider, - ) - .average[amount] = - fee; - } catch (_) { - // Token wallet not available. - debugPrint( - "Token fee estimation not available", - ); - } - } - } - return ref - .read( - widget.isToken - ? tokenFeeSessionCacheProvider - : feeSheetSessionCacheProvider, - ) - .average[amount]!; - }, - isSelected: true, - ); - } else { - return Row( - children: [ - AnimatedText( - stringsToLoopThrough: stringsToLoopThrough, - style: - STextStyles.desktopTextExtraExtraSmall( - context, - ).copyWith( - color: Theme.of(context) - .extension()! - .textFieldActiveText, - ), - ), - ], - ); - } - }, - ) - : Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - feeSelectionResult?.$2 ?? "", - style: STextStyles.desktopTextExtraExtraSmall(context) - .copyWith( - color: Theme.of(context) - .extension()! - .textFieldActiveText, - ), - textAlign: TextAlign.left, - ), - Text( - feeSelectionResult?.$3 ?? "", - style: STextStyles.desktopTextExtraExtraSmall(context) - .copyWith( - color: Theme.of(context) - .extension()! - .textFieldActiveSearchIconRight, - ), - ), - ], - ), + child: DesktopFeeItem( + fees: fees, + feeRateType: canPick ? feeRateType : FeeRateType.average, + amount: feeAmount, + isButton: false, + ), ), if (isCustomFee && isEth) EthFeeForm( @@ -365,7 +167,7 @@ class _DesktopSendFeeFormState extends ConsumerState { stateChanged: (value) => widget.onCustomEip1559FeeOptionChanged?.call(value), ), - if (isCustomFee && !isEth) + if (isCustomFee && canPick && !isEth) Padding( padding: const EdgeInsets.only(bottom: 12, top: 16), child: FeeSlider( diff --git a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_sol_token_send.dart b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_sol_token_send.dart index c73e79aafa..e78455e978 100644 --- a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_sol_token_send.dart +++ b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_sol_token_send.dart @@ -643,7 +643,6 @@ class _DesktopSolTokenSendState extends ConsumerState { @override void initState() { WidgetsBinding.instance.addPostFrameCallback((_) { - // ref.refresh(tokenFeeSessionCacheProvider); // Ethereum-specific ref.read(previewTokenTxButtonStateProvider.state).state = false; }); diff --git a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_token_send.dart b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_token_send.dart index 9a7e8e971e..2dc934ae45 100644 --- a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_token_send.dart +++ b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_token_send.dart @@ -23,7 +23,7 @@ import '../../../../pages/send_view/sub_widgets/building_transaction_dialog.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/desktop_fee_providers.dart'; +import '../../../../providers/wallet/send_fee_providers.dart'; import '../../../../themes/stack_colors.dart'; import '../../../../utilities/address_utils.dart'; import '../../../../utilities/amount/amount.dart'; @@ -256,7 +256,7 @@ class _DesktopTokenSendState extends ConsumerState { )!, ), ], - feeRateType: ref.read(feeRateTypeDesktopStateProvider), + feeRateType: ref.read(pFeeRateType), nonce: nonce, ethEIP1559Fee: _ethFee.value, ), @@ -368,10 +368,10 @@ class _DesktopTokenSendState extends ConsumerState { } } + final _feeAmount = SendFeeAmount(); + void _syncFeeAmount(Amount? amount) { - final tokenDecimals = ref.read(pCurrentTokenWallet)!.tokenContract.decimals; - ref.read(sendAmountProvider.notifier).state = - amount ?? Amount.zeroWith(fractionDigits: tokenDecimals); + if (mounted) _feeAmount.sync(amount); } // Controller assignments used for display must not edit the stored amount. @@ -427,6 +427,7 @@ class _DesktopTokenSendState extends ConsumerState { } _updatePreviewButtonState(_address, _amountToSend); + _feeAmount.typed(_amountToSend); } } @@ -607,6 +608,7 @@ class _DesktopTokenSendState extends ConsumerState { } _updatePreviewButtonState(_address, _amountToSend); + _feeAmount.typed(_amountToSend); } Future sendAllTapped() async { @@ -626,7 +628,6 @@ class _DesktopTokenSendState extends ConsumerState { @override void initState() { WidgetsBinding.instance.addPostFrameCallback((_) { - ref.refresh(tokenFeeSessionCacheProvider); ref.read(previewTokenTxButtonStateProvider.state).state = false; }); @@ -662,25 +663,13 @@ class _DesktopTokenSendState extends ConsumerState { _cryptoFocus.addListener(() { if (!_cryptoFocus.hasFocus && !_baseFocus.hasFocus) { - WidgetsBinding.instance.addPostFrameCallback((_) { - if (_amountToSend == null) { - ref.refresh(sendAmountProvider); - } else { - ref.read(sendAmountProvider.state).state = _amountToSend!; - } - }); + _syncFeeAmount(_amountToSend); } }); _baseFocus.addListener(() { if (!_cryptoFocus.hasFocus && !_baseFocus.hasFocus) { - WidgetsBinding.instance.addPostFrameCallback((_) { - if (_amountToSend == null) { - ref.refresh(sendAmountProvider); - } else { - ref.read(sendAmountProvider.state).state = _amountToSend!; - } - }); + _syncFeeAmount(_amountToSend); } }); @@ -691,6 +680,7 @@ class _DesktopTokenSendState extends ConsumerState { void dispose() { cryptoAmountController.removeListener(onCryptoAmountChanged); _ethFee.dispose(); + _feeAmount.dispose(); sendToController.dispose(); cryptoAmountController.dispose(); @@ -722,7 +712,8 @@ class _DesktopTokenSendState extends ConsumerState { }); } }); - final isCustomFee = ref.watch(feeRateTypeDesktopStateProvider).isCustom; + final isCustomFee = ref.watch(pFeeRateType).isCustom; + listenForCustomFeeLevel(ref, coin, () => _ethFee.value = null); // ethFee is checked in the ValueListenableBuilder around the preview // button so fee keystrokes don't rebuild this whole view. final previewEnabled = ref @@ -1105,10 +1096,8 @@ class _DesktopTokenSendState extends ConsumerState { DesktopSendFeeForm( walletId: walletId, isToken: true, + amount: _feeAmount, onCustomFeeSliderChanged: (value) => {}, - onCustomFeeOptionChanged: () { - _ethFee.value = null; - }, onCustomEip1559FeeOptionChanged: (value) { _ethFee.value = value; }, diff --git a/lib/providers/ui/fee_rate_type_state_provider.dart b/lib/providers/ui/fee_rate_type_state_provider.dart index 8b309a2e59..aa7152f297 100644 --- a/lib/providers/ui/fee_rate_type_state_provider.dart +++ b/lib/providers/ui/fee_rate_type_state_provider.dart @@ -12,10 +12,6 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../utilities/enums/fee_rate_type_enum.dart'; -final feeRateTypeMobileStateProvider = StateProvider.autoDispose( - (_) => FeeRateType.average, -); - -final feeRateTypeDesktopStateProvider = StateProvider( +final pFeeRateType = StateProvider.autoDispose( (_) => FeeRateType.average, ); diff --git a/lib/providers/wallet/desktop_fee_providers.dart b/lib/providers/wallet/desktop_fee_providers.dart deleted file mode 100644 index d1723c78c7..0000000000 --- a/lib/providers/wallet/desktop_fee_providers.dart +++ /dev/null @@ -1,23 +0,0 @@ -/* - * 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 'package:flutter_riverpod/flutter_riverpod.dart'; - -import '../../pages/send_view/sub_widgets/transaction_fee_selection_sheet.dart'; -import '../../utilities/amount/amount.dart'; - -final tokenFeeSessionCacheProvider = - ChangeNotifierProvider((ref) { - return FeeSheetSessionCache(); - }); - -final sendAmountProvider = StateProvider.autoDispose( - (_) => Amount.zero, -); diff --git a/lib/providers/wallet/send_fee_providers.dart b/lib/providers/wallet/send_fee_providers.dart new file mode 100644 index 0000000000..aa60861b2f --- /dev/null +++ b/lib/providers/wallet/send_fee_providers.dart @@ -0,0 +1,330 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../models/paymint/fee_object_model.dart'; +import '../../services/event_bus/events/global/wallet_sync_status_changed_event.dart'; +import '../../services/event_bus/global_event_bus.dart'; +import '../../utilities/address_utils.dart'; +import '../../utilities/amount/amount.dart'; +import '../../utilities/enums/fee_rate_type_enum.dart'; +import '../../utilities/logger.dart'; +import '../../wallets/crypto_currency/crypto_currency.dart'; +import '../../wallets/crypto_currency/interfaces/electrumx_currency_interface.dart'; +import '../../wallets/crypto_currency/intermediate/cryptonote_currency.dart'; +import '../../wallets/crypto_currency/intermediate/nano_currency.dart'; +import '../../wallets/isar/providers/eth/current_token_wallet_provider.dart'; +import '../../wallets/isar/providers/wallet_info_provider.dart'; +import '../../wallets/wallet/impl/firo_wallet.dart'; +import '../../wallets/wallet/wallet.dart'; +import '../../wallets/wallet/wallet_mixin_interfaces/sync_dependent_fee_estimate_interface.dart'; +import '../global/wallets_provider.dart'; +import '../ui/fee_rate_type_state_provider.dart'; +import '../ui/preview_tx_button_state_provider.dart'; +import 'public_private_balance_state_provider.dart'; + +typedef _FeeKey = ({ + FeeRateType feeRateType, + Amount? amount, + bool isSpark, + String? opReturnData, + Object spendable, +}); + +class _Request { + _Request(this.future) { + future.then((answer) { + value = answer; + // A FutureBuilder given the request later builds its answer at once. + future = SynchronousFuture(answer); + }, onError: (Object _) {}); + } + + Future future; + T? value; +} + +class SendFeeRates { + SendFeeRates(this._read, {required this._walletId, required this._isToken}) + : _feesByRate = {}; + + SendFeeRates._refreshed(SendFeeRates previous) + : _read = previous._read, + _walletId = previous._walletId, + _isToken = previous._isToken, + _feesByRate = previous._feesByRate; + + final Reader _read; + final String _walletId; + final bool _isToken; + + /// Fees estimated at each fee rate, so fresh rates at the same rate reuse + /// them, and Spark fees. + final Map _feesByRate; + final _estimates = <_FeeKey, _Request>{}; + _Request? _rates; + + Wallet get _wallet => _read(pWallets).getWallet(_walletId); + + Wallet get _feeWallet => + _isToken && coin is Ethereum ? _read(pCurrentTokenWallet)! : _wallet; + + late final CryptoCurrency coin = _wallet.cryptoCurrency; + + Future get rates => + (_rates ??= _Request(_logged(() => _feeWallet.fees))).future; + + SendFeeRates refreshed() => SendFeeRates._refreshed(this); + + /// The rates and fees that have not arrived are asked again. + void _retry() { + if (_rates?.value == null) _rates = null; + _estimates.removeWhere((_, estimate) => estimate.value == null); + } + + /// One request for everything the fee depends on, which [read] reads. + Future estimate( + Reader read, + FeeRateType feeRateType, { + Amount? amount, + }) { + final key = _key(read, feeRateType, amount); + if (_wallet case SyncDependentFeeEstimateInterface(canEstimateFee: false) + when !_estimates.containsKey(key)) { + // The wallet's next sync status rebuilds the widget [read] belongs to, + // which asks again. + read(_pWalletSyncStatusChange(_walletId)); + return Completer().future; + } + return _estimates.putIfAbsent(key, () => _Request(_estimate(key))).future; + } + + _FeeKey _key(Reader read, FeeRateType feeRateType, Amount? amount) { + final isSpark = _isSparkFee(coin, read(publicPrivateBalanceStateProvider)); + final balance = isSpark ? pWalletBalanceTertiary : pWalletBalance; + final spendable = read(balance(_walletId)).spendable; + return ( + feeRateType: feeRateType, + amount: + _isFeeAmountIndependent(coin) || + amount == null || + amount.raw == BigInt.zero + ? null + : amount, + isSpark: isSpark, + opReturnData: coin is Firo && !isSpark ? read(pOpReturnData) : null, + // The fee depends on the coins the wallet can spend, or only on having + // some when it ignores the amount. + spendable: _isFeeAmountIndependent(coin) + ? spendable.raw > BigInt.zero + : spendable, + ); + } + + Future _estimate(_FeeKey key) async { + // The form may be closed once the rates arrive. + final (wallet, feeWallet) = (_wallet, _feeWallet); + final zero = Amount.zeroWith(fractionDigits: coin.fractionDigits); + final amount = key.amount ?? zero; + if (key.isSpark) { + return _feesByRate[(key.amount, key.spendable)] ??= await _logged( + () => (wallet as FiroWallet).estimateFeeForSpark(amount), + ); + } + if (key.feeRateType.isCustom) { + if (coin is CryptonoteCurrency) { + throw ArgumentError("custom fee not available for monero"); + } + // The send forms hide the estimated fee at the Custom level. + return zero; + } + + final rates = await this.rates; + final feeRate = switch (key.feeRateType) { + FeeRateType.fast => rates.fast, + FeeRateType.slow => rates.slow, + _ => rates.medium, + }; + final byRate = (feeRate, key.amount, key.opReturnData, key.spendable); + final known = _feesByRate[byRate]; + if (known != null) return known; + + final fee = await _logged(() => feeWallet.estimateFeeFor(amount, feeRate)); + final opReturnData = key.opReturnData ?? ""; + final opReturnFee = wallet is FiroWallet && opReturnData.isNotEmpty + ? wallet.estimateTxFee( + vSize: AddressUtils.opReturnOutputVSizeFromHex(opReturnData), + feeRatePerKB: feeRate, + ) + : 0; + return _feesByRate[byRate] = + fee + opReturnFee.toAmountAsRaw(fractionDigits: fee.fractionDigits); + } + + Future _logged(Future Function() request) async { + try { + return await request(); + } catch (error, stackTrace) { + Logging.instance.w( + "Failed to calculate ${coin.prettyName}${_isToken ? " token" : ""} " + "fees: ", + error: error, + stackTrace: stackTrace, + ); + rethrow; + } + } +} + +final _pWalletSyncStatusChange = StreamProvider.autoDispose + .family( + (ref, walletId) => GlobalEventBus.instance + .on() + .where((event) => event.walletId == walletId), + ); + +bool _isSparkFee(CryptoCurrency coin, BalanceType balanceType) => + coin is Firo && balanceType == BalanceType.private; + +bool _isFeeAmountIndependent(CryptoCurrency coin) => + coin is Ethereum || coin is Solana || coin is Stellar || coin is Cardano; + +/// Stellar and Cardano give the same fee at every rate. +bool canPickFeeRateType(CryptoCurrency coin, BalanceType balanceType) => + !_isSparkFee(coin, balanceType) && coin is! Stellar && coin is! Cardano; + +bool canUseCustomFee(CryptoCurrency coin) => + coin is ElectrumXCurrencyInterface || coin is Ethereum; + +Iterable sendFeeRateTypes(CryptoCurrency coin) => + FeeRateType.values.where((e) => !e.isCustom || canUseCustomFee(coin)); + +/// The custom fee inputs start over each time the Custom level shows, and any +/// send form can change the level. They also start over once the balance sent +/// from hides them. +void listenForCustomFeeLevel( + WidgetRef ref, + CryptoCurrency coin, + VoidCallback onToggled, +) { + ref.listen(pFeeRateType, (previous, next) { + if (previous?.isCustom != next.isCustom) onToggled(); + }); + ref.listen(publicPrivateBalanceStateProvider, (_, next) { + if (!canPickFeeRateType(coin, next)) onToggled(); + }); +} + +bool hasSendFee(CryptoCurrency coin) => + coin is! Epiccash && + coin is! Mimblewimblecoin && + coin is! NanoCurrency && + coin is! Tezos; + +String estimatedTimeToBeIncludedInNextBlock( + int targetBlockTime, + int estimatedNumberOfBlocks, +) { + final time = targetBlockTime * estimatedNumberOfBlocks; + final hours = (time / 3600).floor(); + if (hours > 0) return "~$hours hour${hours > 1 ? "s" : ""}"; + final minutes = (time / 60).toStringAsFixed(1); + if (minutes == "1.0") return "~1 minute"; + return "~${minutes.endsWith(".0") ? (time / 60).floor() : minutes} minutes"; +} + +String sendFeeTime( + CryptoCurrency coin, + FeeObject rates, + FeeRateType feeRateType, +) => estimatedTimeToBeIncludedInNextBlock( + coin.targetBlockTimeSeconds, + switch (feeRateType) { + FeeRateType.fast => rates.numberOfBlocksFast, + FeeRateType.slow => rates.numberOfBlocksSlow, + _ => rates.numberOfBlocksAverage, + }, +); + +/// Amount edits and balance switches ask again for a fee that failed or +/// hangs. +mixin SendFeeForm on ConsumerState { + late SendFeeRates fees; + Amount? feeAmount; + Amount? _typedFeeAmount; + Timer? _typing; + Object? _feeChoice; + + bool get _isTyping => _typing?.isActive ?? false; + + void followFeeAmount(Amount? amount) { + _typedFeeAmount = amount; + _typing?.cancel(); + _typing = Timer( + const Duration(milliseconds: 500), + () => setFeeAmount(amount), + ); + } + + void setFeeAmount(Amount? amount) { + _typing?.cancel(); + setState(() { + fees._retry(); + feeAmount = amount; + }); + } + + void retryFee() => setFeeAmount(_isTyping ? _typedFeeAmount : feeAmount); + + /// Leaving the amount [fields] applies the amount being typed at once. + void retryFeeOnFocusLoss(List fields) { + for (final field in fields) { + field.addListener(() { + if (mounted && !fields.any((field) => field.hasFocus)) retryFee(); + }); + } + } + + Future watchFee() => + fees.estimate(ref.watch, ref.watch(pFeeRateType), amount: feeAmount); + + /// Null until the fee of [watchFee] arrives. + Amount? get shownFee => fees + ._estimates[fees._key(ref.read, ref.read(pFeeRateType), feeAmount)] + ?.value; + + /// The form takes over the choice's fresh rates once the chosen fee arrives + /// at them, so a refresh that fails keeps the fee shown. + Future chooseFee( + Future Function(SendFeeRates fees, Amount? amount) show, + ) async { + if (_isTyping) setFeeAmount(_typedFeeAmount); + final choice = _feeChoice = Object(); + final amount = feeAmount; + final refreshed = fees.refreshed(); + await show(refreshed, amount); + final feeRateType = ref.read(pFeeRateType); + final chosen = await refreshed + .estimate(ref.read, feeRateType, amount: amount) + .then((fee) => fee, onError: (Object _) => null); + // The latest choice wins. + if (!mounted || !identical(_feeChoice, choice) || chosen == null) return; + // The Custom level has its fee before the rates arrive. + if (refreshed._rates?.value == null) return; + // The fee may follow another amount by now, and the same fee gains + // nothing. + if (fees._key(ref.read, feeRateType, amount) == + fees._key(ref.read, feeRateType, feeAmount) && + shownFee != chosen) { + setState(() => fees = refreshed); + } + } + + @override + void dispose() { + _typing?.cancel(); + super.dispose(); + } +} diff --git a/lib/wallets/wallet/impl/monero_wallet.dart b/lib/wallets/wallet/impl/monero_wallet.dart index c394169371..da9876dfe5 100644 --- a/lib/wallets/wallet/impl/monero_wallet.dart +++ b/lib/wallets/wallet/impl/monero_wallet.dart @@ -8,17 +8,23 @@ import '../../../wl_gen/interfaces/cs_salvium_interface.dart' show WrappedWallet; import '../../crypto_currency/crypto_currency.dart'; import '../intermediate/lib_monero_wallet.dart'; +import '../wallet_mixin_interfaces/sync_dependent_fee_estimate_interface.dart'; -class MoneroWallet extends LibMoneroWallet { +class MoneroWallet extends LibMoneroWallet + implements SyncDependentFeeEstimateInterface { MoneroWallet(CryptoCurrencyNetwork network) : super(Monero(network), lib_monero_compat.WalletType.monero); @override bool get supportsMultiRecipient => true; + @override + bool get canEstimateFee => + wallet != null && syncStatus is lib_monero_compat.SyncedSyncStatus; + @override Future estimateFeeFor(Amount amount, BigInt feeRate) async { - if (wallet == null || syncStatus is! lib_monero_compat.SyncedSyncStatus) { + if (!canEstimateFee) { return Amount.zeroWith(fractionDigits: cryptoCurrency.fractionDigits); } diff --git a/lib/wallets/wallet/impl/wownero_wallet.dart b/lib/wallets/wallet/impl/wownero_wallet.dart index 86f018381e..cda65b0239 100644 --- a/lib/wallets/wallet/impl/wownero_wallet.dart +++ b/lib/wallets/wallet/impl/wownero_wallet.dart @@ -11,14 +11,20 @@ import '../../../wl_gen/interfaces/cs_wownero_interface.dart'; import '../../crypto_currency/crypto_currency.dart'; import '../../models/tx_data.dart'; import '../intermediate/lib_wownero_wallet.dart'; +import '../wallet_mixin_interfaces/sync_dependent_fee_estimate_interface.dart'; -class WowneroWallet extends LibWowneroWallet { +class WowneroWallet extends LibWowneroWallet + implements SyncDependentFeeEstimateInterface { WowneroWallet(CryptoCurrencyNetwork network) : super(Wownero(network), lib_monero_compat.WalletType.wownero); + @override + bool get canEstimateFee => + wallet != null && syncStatus is lib_monero_compat.SyncedSyncStatus; + @override Future estimateFeeFor(Amount amount, BigInt feeRate) async { - if (wallet == null || syncStatus is! lib_monero_compat.SyncedSyncStatus) { + if (!canEstimateFee) { return Amount.zeroWith(fractionDigits: cryptoCurrency.fractionDigits); } @@ -51,8 +57,7 @@ class WowneroWallet extends LibWowneroWallet { recipients: [ // This address is only used for getting an approximate fee, never for sending TxRecipient( - address: - "WW3iVcnoAY6K9zNdU4qmdvZELefx6xZz4PMpTwUifRkvMQckyadhSPYMVPJhBdYE8P9c27fg9RPmVaWNFx1cDaj61HnetqBiy", + address: "WW3iVcnoAY6K9zNdU4qmdvZELefx6xZz4PMpTwUifRkvMQckyadhSPYMVPJhBdYE8P9c27fg9RPmVaWNFx1cDaj61HnetqBiy", amount: amount, isChange: false, addressType: AddressType.cryptonote, diff --git a/lib/wallets/wallet/intermediate/cryptonote_wallet.dart b/lib/wallets/wallet/intermediate/cryptonote_wallet.dart index 62a08ce3a1..3cdd749d4c 100644 --- a/lib/wallets/wallet/intermediate/cryptonote_wallet.dart +++ b/lib/wallets/wallet/intermediate/cryptonote_wallet.dart @@ -59,7 +59,5 @@ abstract class CryptonoteWallet Future internalCommitTx(CsPendingTransaction tx); // tx prio forwarding - int getTxPriorityHigh(); - int getTxPriorityMedium(); int getTxPriorityNormal(); } diff --git a/lib/wallets/wallet/intermediate/lib_monero_wallet.dart b/lib/wallets/wallet/intermediate/lib_monero_wallet.dart index 144fa6613d..cdc807f510 100644 --- a/lib/wallets/wallet/intermediate/lib_monero_wallet.dart +++ b/lib/wallets/wallet/intermediate/lib_monero_wallet.dart @@ -1485,12 +1485,6 @@ abstract class LibMoneroWallet ) : csMonero.getRefreshFromBlockHeight(wallet!); - @override - int getTxPriorityHigh() => csMonero.getTxPriorityHigh(); - - @override - int getTxPriorityMedium() => csMonero.getTxPriorityMedium(); - @override int getTxPriorityNormal() => csMonero.getTxPriorityNormal(); diff --git a/lib/wallets/wallet/intermediate/lib_salvium_wallet.dart b/lib/wallets/wallet/intermediate/lib_salvium_wallet.dart index 3a07570e7b..169e9cd218 100644 --- a/lib/wallets/wallet/intermediate/lib_salvium_wallet.dart +++ b/lib/wallets/wallet/intermediate/lib_salvium_wallet.dart @@ -1414,12 +1414,6 @@ abstract class LibSalviumWallet ) : csSalvium.getRefreshFromBlockHeight(wallet!); - @override - int getTxPriorityHigh() => csSalvium.getTxPriorityHigh(); - - @override - int getTxPriorityMedium() => csSalvium.getTxPriorityMedium(); - @override int getTxPriorityNormal() => csSalvium.getTxPriorityNormal(); diff --git a/lib/wallets/wallet/intermediate/lib_wownero_wallet.dart b/lib/wallets/wallet/intermediate/lib_wownero_wallet.dart index 90993d2487..acf788e603 100644 --- a/lib/wallets/wallet/intermediate/lib_wownero_wallet.dart +++ b/lib/wallets/wallet/intermediate/lib_wownero_wallet.dart @@ -1426,12 +1426,6 @@ abstract class LibWowneroWallet ) : csWownero.getRefreshFromBlockHeight(wallet!); - @override - int getTxPriorityHigh() => csWownero.getTxPriorityHigh(); - - @override - int getTxPriorityMedium() => csWownero.getTxPriorityMedium(); - @override int getTxPriorityNormal() => csWownero.getTxPriorityNormal(); diff --git a/lib/wallets/wallet/wallet_mixin_interfaces/sync_dependent_fee_estimate_interface.dart b/lib/wallets/wallet/wallet_mixin_interfaces/sync_dependent_fee_estimate_interface.dart new file mode 100644 index 0000000000..6f7b74de7a --- /dev/null +++ b/lib/wallets/wallet/wallet_mixin_interfaces/sync_dependent_fee_estimate_interface.dart @@ -0,0 +1,4 @@ +mixin SyncDependentFeeEstimateInterface { + /// The wallet estimates a zero fee while this is false. + bool get canEstimateFee; +} diff --git a/lib/widgets/calculating_future_builder.dart b/lib/widgets/calculating_future_builder.dart new file mode 100644 index 0000000000..578eb5b6a2 --- /dev/null +++ b/lib/widgets/calculating_future_builder.dart @@ -0,0 +1,42 @@ +import 'package:flutter/widgets.dart'; + +import 'animated_text.dart'; + +/// Shows "Calculating" until [future] answers. +class CalculatingFutureBuilder extends StatelessWidget { + const CalculatingFutureBuilder({ + super.key, + required this.future, + required this.style, + required this.builder, + this.fallback, + }); + + final Future future; + final TextStyle style; + final Widget Function(T answer) builder; + + /// Built with when [future] fails. + final T? fallback; + + @override + Widget build(BuildContext context) => FutureBuilder( + future: future, + builder: (_, snapshot) { + final answer = snapshot.connectionState == ConnectionState.done + ? snapshot.data ?? fallback + : null; + return answer == null + ? AnimatedText( + stringsToLoopThrough: const [ + "Calculating", + "Calculating.", + "Calculating..", + "Calculating...", + ], + style: style, + ) + : builder(answer); + }, + ); +} diff --git a/lib/widgets/custom_buttons/blue_text_button.dart b/lib/widgets/custom_buttons/blue_text_button.dart index 8304498bee..5bb7c2106c 100644 --- a/lib/widgets/custom_buttons/blue_text_button.dart +++ b/lib/widgets/custom_buttons/blue_text_button.dart @@ -111,9 +111,8 @@ class _CustomTextButtonState extends State<_CustomTextButton> text: widget.text, style: widget.textSize == null ? STextStyles.link2(context).copyWith(color: color) - : STextStyles.link2( - context, - ).copyWith(color: color, fontSize: widget.textSize), + : STextStyles.link2(context) + .copyWith(color: color, fontSize: widget.textSize), recognizer: widget.enabled ? (TapGestureRecognizer() ..onTap = () { @@ -148,14 +147,16 @@ class CustomTextButton extends StatelessWidget { @override Widget build(BuildContext context) { return _CustomTextButton( - key: UniqueKey(), + // The state starts from these, and is kept through other rebuilds so + // that a click in progress lasts. + key: ValueKey((enabled, Theme.of(context).extension()!)), text: text, - enabledColor: Theme.of( - context, - ).extension()!.customTextButtonEnabledText, - disabledColor: Theme.of( - context, - ).extension()!.customTextButtonDisabledText, + enabledColor: Theme.of(context) + .extension()! + .customTextButtonEnabledText, + disabledColor: Theme.of(context) + .extension()! + .customTextButtonDisabledText, enabled: enabled, textSize: textSize, overflow: overflow, diff --git a/lib/widgets/desktop/desktop_fee_dialog.dart b/lib/widgets/desktop/desktop_fee_dialog.dart index 3ec5124bd7..ae8685d7dc 100644 --- a/lib/widgets/desktop/desktop_fee_dialog.dart +++ b/lib/widgets/desktop/desktop_fee_dialog.dart @@ -1,263 +1,53 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import '../../models/models.dart'; -import '../../pages/send_view/sub_widgets/transaction_fee_selection_sheet.dart'; -import '../../providers/global/wallets_provider.dart'; import '../../providers/ui/fee_rate_type_state_provider.dart'; -import '../../providers/wallet/desktop_fee_providers.dart'; -import '../../providers/wallet/public_private_balance_state_provider.dart'; +import '../../providers/wallet/send_fee_providers.dart'; import '../../themes/stack_colors.dart'; import '../../utilities/amount/amount.dart'; import '../../utilities/amount/amount_formatter.dart'; import '../../utilities/enums/fee_rate_type_enum.dart'; import '../../utilities/text_styles.dart'; import '../../wallets/crypto_currency/crypto_currency.dart'; -import '../../wallets/isar/providers/eth/current_token_wallet_provider.dart'; -import '../../wallets/wallet/impl/firo_wallet.dart'; -import '../../wallets/wallet/intermediate/cryptonote_wallet.dart'; -import '../animated_text.dart'; +import '../calculating_future_builder.dart'; import '../conditional_parent.dart'; import 'desktop_dialog.dart'; import 'desktop_dialog_close_button.dart'; -class DesktopFeeDialog extends ConsumerStatefulWidget { - const DesktopFeeDialog({ - super.key, - required this.walletId, - this.isToken = false, - }); +class DesktopFeeDialog extends StatelessWidget { + const DesktopFeeDialog({super.key, required this.fees, required this.amount}); - final String walletId; - final bool isToken; - - @override - ConsumerState createState() => _DesktopFeeDialogState(); -} - -class _DesktopFeeDialogState extends ConsumerState { - late final String walletId; - - FeeObject? feeObject; - FeeRateType feeRateType = FeeRateType.average; - - Future feeFor({ - required Amount amount, - required FeeRateType feeRateType, - required BigInt feeRate, - required CryptoCurrency coin, - }) async { - switch (feeRateType) { - case FeeRateType.fast: - if (ref - .read( - widget.isToken - ? tokenFeeSessionCacheProvider - : feeSheetSessionCacheProvider, - ) - .fast[amount] == - null) { - if (widget.isToken == false) { - final wallet = ref.read(pWallets).getWallet(walletId); - - if (wallet is CryptonoteWallet) { - final fee = await wallet.estimateFeeFor( - amount, - BigInt.from(wallet.getTxPriorityHigh()), - ); - ref.read(feeSheetSessionCacheProvider).fast[amount] = fee; - } else if (coin is Firo) { - final Amount fee; - switch (ref.read(publicPrivateBalanceStateProvider.state).state) { - case BalanceType.private: - fee = await (wallet as FiroWallet).estimateFeeForSpark( - amount, - ); - case BalanceType.public: - fee = await (wallet as FiroWallet).estimateFeeFor( - amount, - feeRate, - ); - } - ref.read(feeSheetSessionCacheProvider).fast[amount] = fee; - } else { - ref.read(feeSheetSessionCacheProvider).fast[amount] = await wallet - .estimateFeeFor(amount, feeRate); - } - } else { - final tokenWallet = ref.read(pCurrentTokenWallet)!; - final fee = await tokenWallet.estimateFeeFor(amount, feeRate); - ref.read(tokenFeeSessionCacheProvider).fast[amount] = fee; - } - } - return ref - .read( - widget.isToken - ? tokenFeeSessionCacheProvider - : feeSheetSessionCacheProvider, - ) - .fast[amount]!; - - case FeeRateType.average: - if (ref - .read( - widget.isToken - ? tokenFeeSessionCacheProvider - : feeSheetSessionCacheProvider, - ) - .average[amount] == - null) { - if (widget.isToken == false) { - final wallet = ref.read(pWallets).getWallet(walletId); - - if (wallet is CryptonoteWallet) { - final fee = await wallet.estimateFeeFor( - amount, - BigInt.from(wallet.getTxPriorityMedium()), - ); - ref.read(feeSheetSessionCacheProvider).average[amount] = fee; - } else if (coin is Firo) { - final Amount fee; - switch (ref.read(publicPrivateBalanceStateProvider.state).state) { - case BalanceType.private: - fee = await (wallet as FiroWallet).estimateFeeForSpark( - amount, - ); - case BalanceType.public: - fee = await (wallet as FiroWallet).estimateFeeFor( - amount, - feeRate, - ); - } - ref.read(feeSheetSessionCacheProvider).average[amount] = fee; - } else { - ref.read(feeSheetSessionCacheProvider).average[amount] = - await wallet.estimateFeeFor(amount, feeRate); - } - } else { - final tokenWallet = ref.read(pCurrentTokenWallet)!; - final fee = await tokenWallet.estimateFeeFor(amount, feeRate); - ref.read(tokenFeeSessionCacheProvider).average[amount] = fee; - } - } - return ref - .read( - widget.isToken - ? tokenFeeSessionCacheProvider - : feeSheetSessionCacheProvider, - ) - .average[amount]!; - - case FeeRateType.slow: - if (ref - .read( - widget.isToken - ? tokenFeeSessionCacheProvider - : feeSheetSessionCacheProvider, - ) - .slow[amount] == - null) { - if (widget.isToken == false) { - final wallet = ref.read(pWallets).getWallet(walletId); - - if (wallet is CryptonoteWallet) { - final fee = await wallet.estimateFeeFor( - amount, - BigInt.from(wallet.getTxPriorityNormal()), - ); - ref.read(feeSheetSessionCacheProvider).slow[amount] = fee; - } else if (coin is Firo) { - final Amount fee; - switch (ref.read(publicPrivateBalanceStateProvider.state).state) { - case BalanceType.private: - fee = await (wallet as FiroWallet).estimateFeeForSpark( - amount, - ); - case BalanceType.public: - fee = await (wallet as FiroWallet).estimateFeeFor( - amount, - feeRate, - ); - } - ref.read(feeSheetSessionCacheProvider).slow[amount] = fee; - } else { - ref.read(feeSheetSessionCacheProvider).slow[amount] = await wallet - .estimateFeeFor(amount, feeRate); - } - } else { - final tokenWallet = ref.read(pCurrentTokenWallet)!; - final fee = await tokenWallet.estimateFeeFor(amount, feeRate); - ref.read(tokenFeeSessionCacheProvider).slow[amount] = fee; - } - } - return ref - .read( - widget.isToken - ? tokenFeeSessionCacheProvider - : feeSheetSessionCacheProvider, - ) - .slow[amount]!; - default: - return Amount.zero; - } - } - - @override - void initState() { - walletId = widget.walletId; - super.initState(); - } + final SendFeeRates fees; + final Amount? amount; @override Widget build(BuildContext context) { return DesktopDialog( maxWidth: 450, maxHeight: double.infinity, - child: FutureBuilder( - future: ref.watch( - pWallets.select((value) => value.getWallet(walletId).fees), - ), - builder: (context, snapshot) { - if (snapshot.connectionState == ConnectionState.done && - snapshot.hasData) { - feeObject = snapshot.data!; - } - - return Column( + child: Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Padding( - padding: const EdgeInsets.only(left: 32), - child: Text( - "Choose fee", - style: STextStyles.desktopH3(context), - ), - ), - const DesktopDialogCloseButton(), - ], - ), - ...FeeRateType.values.map( - (e) => Padding( - padding: const EdgeInsets.only( - left: 32, - right: 32, - bottom: 16, - ), - child: DesktopFeeItem( - feeObject: feeObject, - feeRateType: e, - walletId: walletId, - feeFor: feeFor, - isSelected: false, - ), + Padding( + padding: const EdgeInsets.only(left: 32), + child: Text( + "Choose fee", + style: STextStyles.desktopH3(context), ), ), - const SizedBox(height: 16), + const DesktopDialogCloseButton(), ], - ); - }, + ), + ...sendFeeRateTypes(fees.coin).map( + (e) => Padding( + padding: const EdgeInsets.only(left: 32, right: 32, bottom: 16), + child: DesktopFeeItem(fees: fees, feeRateType: e, amount: amount), + ), + ), + const SizedBox(height: 16), + ], ), ); } @@ -266,25 +56,15 @@ class _DesktopFeeDialogState extends ConsumerState { class DesktopFeeItem extends ConsumerStatefulWidget { const DesktopFeeItem({ super.key, - required this.feeObject, + required this.fees, required this.feeRateType, - required this.walletId, - required this.feeFor, - required this.isSelected, + required this.amount, this.isButton = true, }); - final FeeObject? feeObject; + final SendFeeRates fees; final FeeRateType feeRateType; - final String walletId; - final Future Function({ - required Amount amount, - required FeeRateType feeRateType, - required BigInt feeRate, - required CryptoCurrency coin, - }) - feeFor; - final bool isSelected; + final Amount? amount; final bool isButton; @override @@ -292,164 +72,66 @@ class DesktopFeeItem extends ConsumerStatefulWidget { } class _DesktopFeeItemState extends ConsumerState { - String? feeString; - String? timeString; - - static const stringsToLoopThrough = [ - "Calculating", - "Calculating.", - "Calculating..", - "Calculating...", - ]; - - String estimatedTimeToBeIncludedInNextBlock( - int targetBlockTime, - int estimatedNumberOfBlocks, - ) { - final int time = targetBlockTime * estimatedNumberOfBlocks; - - final int hours = (time / 3600).floor(); - if (hours > 1) { - return "~$hours hours"; - } else if (hours == 1) { - return "~$hours hour"; - } - - // less than an hour - - final string = (time / 60).toStringAsFixed(1); - - if (string == "1.0") { - return "~1 minute"; - } else { - if (string.endsWith(".0")) { - return "~${(time / 60).floor()} minutes"; - } - return "~$string minutes"; - } - } - @override Widget build(BuildContext context) { debugPrint("BUILD: $runtimeType : ${widget.feeRateType}"); + final (fees, feeRateType) = (widget.fees, widget.feeRateType); + final colors = Theme.of(context).extension()!; + final style = STextStyles.desktopTextExtraExtraSmall(context) + .copyWith(color: colors.textFieldActiveText); + final coin = fees.coin; + final fee = feeRateType.isCustom + ? null + : fees.estimate(ref.watch, feeRateType, amount: widget.amount); return ConditionalParent( condition: widget.isButton, builder: (child) => MaterialButton( materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, onPressed: () { - ref.read(feeRateTypeDesktopStateProvider.state).state = - widget.feeRateType; - Navigator.of( - context, - ).pop((widget.feeRateType, feeString, timeString)); + ref.read(pFeeRateType.state).state = feeRateType; + Navigator.of(context).pop(); }, child: child, ), - child: Builder( - builder: (_) { - if (widget.feeRateType == FeeRateType.custom) { - return Row( + child: fee == null + ? Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( - widget.feeRateType.prettyName, - style: STextStyles.desktopTextExtraExtraSmall(context) - .copyWith( - color: Theme.of( - context, - ).extension()!.textFieldActiveText, - ), + feeRateType.prettyName, + style: style, textAlign: TextAlign.left, ), ], - ); - } - - final wallet = ref.watch( - pWallets.select((value) => value.getWallet(widget.walletId)), - ); - - if (widget.feeObject == null) { - return AnimatedText( - stringsToLoopThrough: stringsToLoopThrough, - style: STextStyles.desktopTextExtraExtraSmall(context).copyWith( - color: Theme.of( - context, - ).extension()!.textFieldActiveText, - ), - ); - } else { - return FutureBuilder( - future: widget.feeFor( - coin: wallet.info.coin, - feeRateType: widget.feeRateType, - feeRate: widget.feeRateType == FeeRateType.fast - ? widget.feeObject!.fast - : widget.feeRateType == FeeRateType.slow - ? widget.feeObject!.slow - : widget.feeObject!.medium, - amount: ref.watch(sendAmountProvider.state).state, - ), - builder: (_, AsyncSnapshot snapshot) { - if (snapshot.connectionState == ConnectionState.done && - snapshot.hasData) { - feeString = - "${widget.feeRateType.prettyName} " - "(~${ref.watch(pAmountFormatter(wallet.info.coin)).format(snapshot.data!, indicatePrecisionLoss: false)})"; - - timeString = wallet.info.coin is Ethereum - ? "" - : estimatedTimeToBeIncludedInNextBlock( - wallet.info.coin.targetBlockTimeSeconds, - widget.feeRateType == FeeRateType.fast - ? widget.feeObject!.numberOfBlocksFast - : widget.feeRateType == FeeRateType.slow - ? widget.feeObject!.numberOfBlocksSlow - : widget.feeObject!.numberOfBlocksAverage, - ); - - return Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - feeString!, - style: STextStyles.desktopTextExtraExtraSmall(context) - .copyWith( - color: Theme.of( - context, - ).extension()!.textFieldActiveText, - ), - textAlign: TextAlign.left, + ) + : FutureBuilder( + future: fees.rates, + builder: (_, rates) => CalculatingFutureBuilder( + future: fee, + style: style, + builder: (fee) => Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + "${feeRateType.prettyName} " + "(~${ref.watch(pAmountFormatter(coin)).format(fee, indicatePrecisionLoss: false)})", + style: style, + textAlign: TextAlign.left, + ), + Text( + // A Spark fee shows while the fee rates load or fail. + coin is Ethereum || !rates.hasData + ? "" + : sendFeeTime(coin, rates.data!, feeRateType), + style: style.copyWith( + color: colors.textFieldActiveSearchIconRight, ), - if (widget.feeObject != null) - Text( - timeString!, - style: STextStyles.desktopTextExtraExtraSmall(context) - .copyWith( - color: Theme.of(context) - .extension()! - .textFieldActiveSearchIconRight, - ), - ), - ], - ); - } else { - return AnimatedText( - stringsToLoopThrough: stringsToLoopThrough, - style: STextStyles.desktopTextExtraExtraSmall(context) - .copyWith( - color: Theme.of( - context, - ).extension()!.textFieldActiveText, - ), - ); - } - }, - ); - } - }, - ), + ), + ], + ), + ), + ), ); } } diff --git a/test/pages/send_view/fiat_clear_unit_change_test.dart b/test/pages/send_view/fiat_clear_unit_change_test.dart index 9958498190..f7f06b8ad3 100644 --- a/test/pages/send_view/fiat_clear_unit_change_test.dart +++ b/test/pages/send_view/fiat_clear_unit_change_test.dart @@ -130,6 +130,7 @@ void main() { pWalletInfo(walletId).overrideWithValue(info), pWalletCoin(walletId).overrideWithValue(coin), pWalletName(walletId).overrideWithValue(info.name), + pWalletBalance(walletId).overrideWithValue(info.cachedBalance), prefsChangeNotifierProvider.overrideWithValue(prefs), localeServiceChangeNotifierProvider.overrideWithValue(_Locale()), priceAnd24hChangeNotifierProvider.overrideWithValue(_Price()), diff --git a/test/pages/send_view/frost_ms/recipient_test.dart b/test/pages/send_view/frost_ms/recipient_test.dart index 791235f22c..a8bc1387be 100644 --- a/test/pages/send_view/frost_ms/recipient_test.dart +++ b/test/pages/send_view/frost_ms/recipient_test.dart @@ -17,18 +17,10 @@ import 'package:stackwallet/utilities/amount/amount_unit.dart'; import 'package:stackwallet/utilities/barcode_scanner_interface.dart'; import 'package:stackwallet/utilities/clipboard_interface.dart'; import 'package:stackwallet/utilities/util.dart'; -import 'package:stackwallet/wallets/crypto_currency/crypto_currency.dart'; import 'package:stackwallet/widgets/desktop/qr_code_scanner_dialog.dart'; import '../../../sample_data/theme_json.dart'; - -/// Monero that validates addresses without the native library. -class _TestMonero extends Monero { - _TestMonero() : super(CryptoCurrencyNetwork.main); - - @override - bool validateAddress(String address) => address.startsWith("address"); -} +import '../support/fake_wallets.dart' show TestMonero; class _TestLocaleService extends LocaleService { String _locale = "en_US"; @@ -57,7 +49,7 @@ class _FakeScanner implements BarcodeScannerInterface { } void main() { - final coin = _TestMonero(); + final coin = TestMonero(); const RecipientId id = (walletId: "wallet", index: 1); Amount xmr(String value) => Amount.tryParseCanonicalAmount( diff --git a/test/pages/send_view/open_alias_preview_test.dart b/test/pages/send_view/open_alias_preview_test.dart index f9accfd009..775e3a23e0 100644 --- a/test/pages/send_view/open_alias_preview_test.dart +++ b/test/pages/send_view/open_alias_preview_test.dart @@ -113,12 +113,6 @@ class _Wallet extends Fake implements CryptonoteWallet { slow: BigInt.one, ); @override - int getTxPriorityHigh() => 3; - @override - int getTxPriorityMedium() => 2; - @override - int getTxPriorityNormal() => 1; - @override Future estimateFeeFor(Amount value, BigInt feeRate) async => amount(10); @override diff --git a/test/pages/send_view/send_views_test.dart b/test/pages/send_view/send_views_test.dart new file mode 100644 index 0000000000..b21c93a0e7 --- /dev/null +++ b/test/pages/send_view/send_views_test.dart @@ -0,0 +1,920 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:stackwallet/models/balance.dart'; +import 'package:stackwallet/pages/send_view/frost_ms/recipient.dart'; +import 'package:stackwallet/pages/send_view/send_view.dart'; +import 'package:stackwallet/pages/send_view/sub_widgets/transaction_fee_selection_sheet.dart'; +import 'package:stackwallet/providers/ui/fee_rate_type_state_provider.dart'; +import 'package:stackwallet/providers/wallet/public_private_balance_state_provider.dart'; +import 'package:stackwallet/services/event_bus/events/global/wallet_sync_status_changed_event.dart'; +import 'package:stackwallet/services/event_bus/global_event_bus.dart'; +import 'package:stackwallet/utilities/amount/amount.dart'; +import 'package:stackwallet/utilities/enums/fee_rate_type_enum.dart'; +import 'package:stackwallet/utilities/stack_file_system.dart'; +import 'package:stackwallet/wallets/isar/providers/wallet_info_provider.dart'; +import 'package:stackwallet/wallets/wallet/wallet.dart'; +import 'package:stackwallet/widgets/custom_buttons/blue_text_button.dart'; +import 'package:stackwallet/widgets/desktop/desktop_fee_dialog.dart'; +import 'package:stackwallet/widgets/eth_fee_form.dart'; +import 'package:stackwallet/widgets/fee_slider.dart'; +import 'package:tuple/tuple.dart'; + +import '../../support/isar_test_utils.dart'; +import 'support/fake_wallets.dart'; +import 'support/send_view_cases.dart'; +import 'support/send_view_harness.dart'; + +void main() { + setUpAll(() async { + final dir = await Directory.systemTemp.createTemp("send_views_"); + StackFileSystem.setDesktopOverrideDir(dir.path); + addTearDown(() => dir.delete(recursive: true)); + await initializeTestIsar(); + await loadButtonFont(); + }); + + Future settle(WidgetTester tester) async { + await tester.pump(const Duration(seconds: 1)); + await tester.pumpAndSettle(); + } + + Future enterAmount( + WidgetTester tester, + SendViewCase view, + String amount, + ) async { + await enterField(tester, view.cryptoAmountField, amount); + // Leaving the field applies the amount at once. + FocusManager.instance.primaryFocus?.unfocus(); + await settle(tester); + } + + Future receive( + WidgetTester tester, + Wallet wallet, + Balance balance, + ) async { + (wallet.info as TestWalletInfo).balance = balance; + ProviderScope.containerOf(tester.element(find.byType(Scaffold).first)) + .refresh(pWalletBalance(wallet.walletId)); + await settle(tester); + } + + Future setSyncStatus( + WidgetTester tester, + FakeSyncDependentFeeWallet wallet, + WalletSyncStatus status, + ) async { + wallet.isSynced = status == WalletSyncStatus.synced; + GlobalEventBus.instance.fire( + WalletSyncStatusChangedEvent( + status, + wallet.walletId, + wallet.cryptoCurrency, + ), + ); + await settle(tester); + } + + Finder inFeeChoice(Finder finder) => find.descendant( + of: find.byWidgetPredicate( + (widget) => + widget is TransactionFeeSelectionSheet || widget is DesktopFeeDialog, + ), + matching: finder, + ); + + Future failHeldFeeRates(WidgetTester tester, FakeWallet fees) async { + final held = fees.holdFeeRateFetches!; + fees.holdFeeRateFetches = null; + held.completeError(Exception("node down")); + await settle(tester); + } + + Future pumpCoinControl(WidgetTester tester) async { + final routes = RecordedRoutes(); + await mobileCoin.pump( + tester, + balance: testBalance("1", 8), + prefs: HarnessPrefs(enableCoinControl: true), + onGenerateRoute: routes.generate, + ); + await enterField(tester, mobileCoin.addressField, btcAddress); + return routes; + } + + final ethFeeViews = [ + (mobileCoin, "0.000042"), + (desktopCoin, ""), + (mobileEthToken, "0.00013"), + (desktopEthToken, ""), + ]; + + group("the fee shown", () { + for (final view in coinCases) { + testWithoutAnimation("covers the OP_RETURN output of a public Firo send " + "($view)", (tester) async { + await view.pump( + tester, + wallet: FakeFiroWallet(), + overrides: [ + publicPrivateBalanceStateProvider.overrideWithProvider( + StateProvider((_) => BalanceType.public), + ), + ], + ); + await enterField( + tester, + view.addressField, + "firo:$firoExchangeAddress?amount=0.1&op_return=68656c6c6f", + ); + await settle(tester); + expect(find.textContaining("0.00001320"), findsOneWidget); + + await view.openFeeChoice(tester, "0.00001320"); + + // Each rate's estimate plus its fee for the 16-vbyte OP_RETURN output. + expect(find.textContaining("0.00001980"), findsOneWidget); + expect(find.textContaining("0.00001320"), findsWidgets); + expect(find.textContaining("0.00000660"), findsOneWidget); + }); + + testWithoutAnimation("follows the balance sent from ($view Firo)", ( + tester, + ) async { + await view.pump(tester, wallet: FakeFiroWallet()); + await enterAmount(tester, view, "0.1"); + expect(find.textContaining("0.00002000"), findsOneWidget); + + await sendFrom(tester, "Public balance"); + await settle(tester); + + expect(find.textContaining("0.00001000"), findsOneWidget); + expect(find.textContaining("0.00002000"), findsNothing); + }); + + testWithoutAnimation("is calculated when reviewing ($view Xelis)", ( + tester, + ) async { + final wallet = FakeXelisWallet(); + await view.pump(tester, wallet: wallet); + await enterAmount(tester, view, "0.12345678"); + + await tester.tap(find.text("Calculated when reviewing")); + await tester.pumpAndSettle(); + + expect(find.text("Transaction fee"), findsOneWidget); + expect(find.text("Calculated when reviewing"), findsOneWidget); + expect(find.byType(TransactionFeeSelectionSheet), findsNothing); + expect(find.text("Edit", findRichText: true), findsNothing); + expect(wallet.feeRateFetches, 0); + expect(wallet.feeEstimates, 0); + }); + + testWithoutAnimation( + "is absent for a coin without fees ($view Epic Cash)", + (tester) async { + final wallet = FakeEpiccashWallet(); + await view.pump(tester, wallet: wallet); + await enterField(tester, view.cryptoAmountField, "1"); + await settle(tester); + + expect(find.textContaining("Transaction fee"), findsNothing); + expect(wallet.feeRateFetches, 0); + }, + ); + + testWithoutAnimation("is labelled as an estimate until Custom is picked " + "($view)", (tester) async { + await view.pump(tester); + expect(find.text("Transaction fee (estimated)"), findsOneWidget); + + await view.openFeeChoice(tester, "0.00002000"); + await pick(tester, "Custom"); + + expect(find.text("Transaction fee"), findsOneWidget); + }); + + testWithoutAnimation("is estimated for the whole balance with Send all " + "($view)", (tester) async { + final wallet = FakeBitcoinWallet(); + await view.pump(tester, wallet: wallet, balance: testBalance("1", 8)); + + await tester.tap(find.text("Send all BTC", findRichText: true)); + FocusManager.instance.primaryFocus?.unfocus(); + await settle(tester); + + expect( + wallet.feeEstimateAmounts.last, + Amount.tryParseCanonicalAmount("1", fractionDigits: 8), + ); + }); + + testWithoutAnimation( + "is estimated for the amount at the picked level ($view)", + (tester) async { + final wallet = FakeBitcoinWallet(); + await view.pump(tester, wallet: wallet); + await view.openFeeChoice(tester, "0.00002000"); + await pick(tester, "Fast"); + + await enterAmount(tester, view, "0.2"); + + expect( + wallet.feeEstimateAmounts.last, + Amount.tryParseCanonicalAmount("0.2", fractionDigits: 8), + ); + expect(find.textContaining("0.00003000"), findsOneWidget); + }, + ); + + testWithoutAnimation( + "follows the funds the wallet can spend ($view Solana)", + (tester) async { + final wallet = FakeSolanaWallet(); + await view.pump(tester, wallet: wallet); + await enterAmount(tester, view, "1"); + + await receive(tester, wallet, testBalance("1", 9)); + + expect(find.textContaining("0.000015000"), findsOneWidget); + }, + ); + + for (final (coin, newWallet, fee, balance) + in <(String, FakeSyncDependentFeeWallet Function(), String, Balance)>[ + ("Monero", FakeMoneroWallet.new, "0.00002", testBalance("10", 12)), + ("Wownero", FakeWowneroWallet.new, "0.0002", testBalance("10", 11)), + ]) { + testWithoutAnimation( + "follows the wallet once it has synchronized ($view $coin)", + (tester) async { + final wallet = newWallet()..isSynced = false; + await view.pump(tester, wallet: wallet, balance: balance); + await enterAmount(tester, view, "1"); + expect(find.textContaining("Calculating"), findsOneWidget); + + await setSyncStatus(tester, wallet, WalletSyncStatus.synced); + + expect(find.textContaining(fee), findsOneWidget); + }, + ); + } + + testWithoutAnimation( + "stays while the synchronized wallet refreshes ($view Monero)", + (tester) async { + final wallet = FakeMoneroWallet(); + await view.pump( + tester, + wallet: wallet, + balance: testBalance("10", 12), + ); + await enterAmount(tester, view, "1"); + + await setSyncStatus(tester, wallet, WalletSyncStatus.syncing); + await enterAmount(tester, view, "1"); + + expect(find.textContaining("0.00002"), findsOneWidget); + }, + ); + + testWithoutAnimation( + "is estimated for the recipients' total ($view Monero)", + (tester) async { + final wallet = FakeMoneroWallet(); + await view.pump(tester, wallet: wallet); + await enterField( + tester, + view.addressField, + "monero:address0;address1?tx_amount=1;2", + ); + await settle(tester); + + await tester.enterText( + find.descendant( + of: find.byType(Recipient).first, + matching: find.byKey(view.cryptoAmountField), + ), + "5", + ); + await settle(tester); + + expect( + wallet.feeEstimateAmounts.last, + Amount.tryParseCanonicalAmount("7", fractionDigits: 12), + ); + }, + ); + } + + testWithoutAnimation("is the slow rate's fee (mobile Solana token)", ( + tester, + ) async { + await mobileSolToken.pump(tester, balance: testBalance("1", 9)); + await enterField(tester, mobileSolToken.cryptoAmountField, "1"); + await settle(tester); + + expect(find.text("Slow"), findsOneWidget); + expect(find.text("~0.000010000 SOL"), findsOneWidget); + }); + }); + + group("the fee choice", () { + for (final view in coinCases) { + testWithoutAnimation("shows each level's confirmation time ($view)", ( + tester, + ) async { + await view.pump(tester); + + await view.openFeeChoice(tester, "0.00002000"); + + expect(inFeeChoice(find.text("~10 minutes")), findsOneWidget); + expect(inFeeChoice(find.text("~30 minutes")), findsOneWidget); + expect(inFeeChoice(find.text("~1 hour")), findsOneWidget); + }); + + testWithoutAnimation( + "shows the fees once the wallet has synchronized ($view Monero)", + (tester) async { + final wallet = FakeMoneroWallet()..isSynced = false; + await view.pump( + tester, + wallet: wallet, + balance: testBalance("10", 12), + ); + await enterAmount(tester, view, "1"); + await view.openFeeChoice(tester, "Calculating"); + + await setSyncStatus(tester, wallet, WalletSyncStatus.synced); + + expect(inFeeChoice(find.textContaining("0.00002")), findsOneWidget); + }, + ); + + testWithoutAnimation("leaves the form at the picked level and its fee " + "($view)", (tester) async { + final wallet = FakeBitcoinWallet(); + await view.pump(tester, wallet: wallet); + await enterAmount(tester, view, "0.1"); + await view.openFeeChoice(tester, "0.00002000"); + final estimates = wallet.feeEstimates; + + await pick(tester, "Fast"); + + expect(find.textContaining("Fast"), findsOneWidget); + expect(find.textContaining("0.00003000"), findsOneWidget); + expect(wallet.feeEstimates, estimates); + }); + + testWithoutAnimation("offers no custom rate for Solana ($view)", ( + tester, + ) async { + await view.pump( + tester, + wallet: FakeSolanaWallet(), + balance: testBalance("1", 9), + ); + await enterAmount(tester, view, "1"); + + await view.openFeeChoice(tester, "0.000015000"); + + expect(inFeeChoice(find.textContaining("0.000020000")), findsOneWidget); + expect(find.text("Custom"), findsNothing); + }); + + testWithoutAnimation( + "offers the edit fees for cryptonote coins ($view)", + (tester) async { + await view.pump(tester, wallet: FakeMoneroWallet()); + await enterAmount(tester, view, "1"); + + await view.openFeeChoice(tester, "0.00002"); + + expect(inFeeChoice(find.textContaining("0.00003")), findsOneWidget); + }, + ); + + testWithoutAnimation("is absent for Spark ($view Firo)", (tester) async { + await view.pump(tester, wallet: FakeFiroWallet()); + await enterAmount(tester, view, "0.1"); + + await tester.tap(find.textContaining("0.00002000").last); + await tester.pumpAndSettle(); + + expect(find.byType(TransactionFeeSelectionSheet), findsNothing); + expect(find.text("Edit", findRichText: true), findsNothing); + }); + } + + for (final (view, coin, wallet, fee, balance) + in <(SendViewCase, String, Wallet Function(), String, Balance)>[ + ( + mobileCoin, + "Stellar", + FakeStellarWallet.new, + "0.00001", + testBalance("10", 7), + ), + ( + mobileCoin, + "Cardano", + FakeCardanoWallet.new, + "0.17", + testBalance("10", 6), + ), + ( + desktopCoin, + "Cardano", + FakeCardanoWallet.new, + "0.17", + testBalance("10", 6), + ), + ]) { + testWithoutAnimation("is absent for $coin ($view)", (tester) async { + await view.pump(tester, wallet: wallet(), balance: balance); + await enterAmount(tester, view, "1"); + + await tester.tap(find.textContaining(fee).last); + await tester.pumpAndSettle(); + + expect(find.byType(TransactionFeeSelectionSheet), findsNothing); + expect(find.text("Edit", findRichText: true), findsNothing); + }); + } + + for (final (view, fee) in ethFeeViews) { + testWithoutAnimation("offers a custom Ethereum fee ($view)", ( + tester, + ) async { + await view.pump(tester, wallet: FakeEthereumWallet()); + await enterAmount(tester, view, "1"); + await view.openFeeChoice(tester, fee); + + await pick(tester, "Custom"); + + expect(find.byType(EthFeeForm), findsOneWidget); + }); + } + + testWithoutAnimation( + "offers no custom rate once sent from the private balance " + "(mobile Firo)", + (tester) async { + await mobileCoin.pump(tester, wallet: FakeFiroWallet()); + await sendFrom(tester, "Public balance"); + await settle(tester); + await mobileCoin.openFeeChoice(tester, "0.00001000"); + await pick(tester, "Custom"); + + await sendFrom(tester, "Private balance"); + + expect(find.byType(FeeSlider), findsNothing); + }, + ); + + testWithoutAnimation("opens from an Edit click that ends the amount typing " + "(desktop)", (tester) async { + await desktopCoin.pump(tester); + await enterField(tester, desktopCoin.cryptoAmountField, "0.01"); + final edit = find.ancestor( + of: find.text("Edit", findRichText: true), + matching: find.byType(CustomTextButton), + ); + + FocusManager.instance.primaryFocus?.unfocus(); + final click = await tester.startGesture( + tester.getRect(edit).centerLeft + const Offset(4, 0), + ); + await tester.pump(); + await click.up(); + await tester.pumpAndSettle(); + + expect(find.byType(DesktopFeeDialog), findsOneWidget); + }); + }); + + group("the fee sent", () { + for (final view in coinCases) { + testWithoutAnimation("is the custom rate picked ($view)", (tester) async { + final wallet = FakeBitcoinWallet(); + await view.pump(tester, wallet: wallet, balance: testBalance("1", 8)); + await enterField(tester, view.addressField, btcAddress); + await enterField(tester, view.cryptoAmountField, "0.1"); + await settle(tester); + await view.openFeeChoice(tester, "0.00002000"); + final rate = await setCustomRate(tester); + await view.preview(tester); + + expect(rate, greaterThan(1)); + expect(wallet.prepared!.feeRateType, FeeRateType.custom); + expect(wallet.prepared!.satsPerVByte, rate); + }); + + testWithoutAnimation( + "starts the custom rate over once a level was picked " + "($view)", + (tester) async { + final wallet = FakeBitcoinWallet(); + await view.pump(tester, wallet: wallet, balance: testBalance("1", 8)); + await enterField(tester, view.addressField, btcAddress); + await enterAmount(tester, view, "0.1"); + await view.openFeeChoice(tester, "0.00002000"); + await setCustomRate(tester); + await view.openFeeChoice(tester, "Custom"); + await pick(tester, "Fast"); + await view.openFeeChoice(tester, "0.00003000"); + + await pick(tester, "Custom"); + final rate = sliderRate(tester); + await view.preview(tester); + + expect(wallet.prepared!.satsPerVByte, rate); + }, + ); + + testWithoutAnimation( + "starts the custom rate over once the private balance hid it " + "($view Firo)", + (tester) async { + final wallet = FakeFiroWallet(); + await view.pump(tester, wallet: wallet, balance: testBalance("1", 8)); + await sendFrom(tester, "Public balance"); + await settle(tester); + await view.openFeeChoice(tester, "0.00001000"); + await setCustomRate(tester); + await sendFrom(tester, "Private balance"); + await sendFrom(tester, "Public balance"); + await enterField(tester, view.addressField, firoAddress); + await enterAmount(tester, view, "0.1"); + + final rate = sliderRate(tester); + await view.preview(tester); + + expect(wallet.prepared!.satsPerVByte, rate); + }, + ); + + testWithoutAnimation( + "starts at the Average level in a new send form ($view)", + (tester) async { + final wallet = FakeBitcoinWallet(); + await view.pump(tester, wallet: wallet, balance: testBalance("1", 8)); + await view.openFeeChoice(tester, "0.00002000"); + await pick(tester, "Fast"); + final navigator = Navigator.of( + tester.element(find.byType(Scaffold).first), + ); + unawaited( + navigator.pushReplacement( + MaterialPageRoute(builder: (_) => const SizedBox()), + ), + ); + await tester.pumpAndSettle(); + unawaited( + navigator.pushReplacement( + MaterialPageRoute( + builder: (_) => Scaffold(body: view.view(wallet)), + ), + ), + ); + await tester.pumpAndSettle(); + + await enterField(tester, view.addressField, btcAddress); + await enterAmount(tester, view, "0.1"); + await view.preview(tester); + + expect(wallet.prepared!.feeRateType, FeeRateType.average); + }, + ); + } + + for (final (view, fee) in ethFeeViews) { + Future pumpCustomEthFee(WidgetTester tester) async { + final wallet = FakeEthereumWallet(); + await view.pump(tester, wallet: wallet, balance: testBalance("1", 18)); + await enterField(tester, view.addressField, testEthContract.address); + await enterAmount(tester, view, "0.1"); + await view.openFeeChoice(tester, fee); + await pick(tester, "Custom"); + await enterField(tester, const Key("ethMaxFeePerGasField"), "50"); + await enterField( + tester, + const Key("ethMaxPriorityFeePerGasField"), + "2", + ); + return view.feeEstimator(wallet); + } + + testWithoutAnimation( + "drops the custom Ethereum fee once a level is picked " + "($view)", + (tester) async { + final sender = await pumpCustomEthFee(tester); + await view.openFeeChoice(tester, "Custom"); + + await pick(tester, "Fast"); + await view.preview(tester); + + expect(sender.prepared!.ethEIP1559Fee, isNull); + }, + ); + + testWithoutAnimation( + "drops the custom Ethereum fee once another send form sets " + "a level ($view)", + (tester) async { + final sender = await pumpCustomEthFee(tester); + + ProviderScope.containerOf(tester.element(find.byType(EthFeeForm))) + .read(pFeeRateType.notifier) + .state = + FeeRateType.average; + await tester.pumpAndSettle(); + await view.preview(tester); + + expect(sender.prepared!.ethEIP1559Fee, isNull); + }, + ); + } + }); + + group("the fee sent to coin control", () { + /// The amount coin control is opened with. + Future selectCoins( + WidgetTester tester, + RecordedRoutes routes, + ) async { + await tester.tap(find.text("Select coins", findRichText: true)); + await settle(tester); + return (routes.arguments.single! as Tuple4).item3; + } + + testWithoutAnimation("is the picked level's fee (mobile)", (tester) async { + final routes = await pumpCoinControl(tester); + await enterAmount(tester, mobileCoin, "0.1"); + await mobileCoin.openFeeChoice(tester, "0.00002000"); + await pick(tester, "Fast"); + + expect( + await selectCoins(tester, routes), + Amount.tryParseCanonicalAmount("0.10003", fractionDigits: 8), + ); + }); + + testWithoutAnimation("is none with a custom fee (mobile)", (tester) async { + final routes = await pumpCoinControl(tester); + await enterAmount(tester, mobileCoin, "0.1"); + await mobileCoin.openFeeChoice(tester, "0.00002000"); + await pick(tester, "Custom"); + + expect( + await selectCoins(tester, routes), + Amount.tryParseCanonicalAmount("0.1", fractionDigits: 8), + ); + }); + }); + + group("a failed fee", () { + for (final (view, newWallet) in <(SendViewCase, Wallet Function())>[ + (mobileCoin, FakeBitcoinWallet.new), + (desktopCoin, FakeBitcoinWallet.new), + (mobileEthToken, FakeEthereumWallet.new), + (desktopEthToken, FakeEthereumWallet.new), + ]) { + testWithoutAnimation("shows as calculating ($view)", (tester) async { + final wallet = newWallet(); + final fees = view.feeEstimator(wallet) + ..feeError = Exception("node down"); + await view.pump(tester, wallet: wallet); + await enterAmount(tester, view, "1"); + final estimates = fees.feeEstimates; + + await enterField(tester, view.addressField, "a"); + await enterField(tester, view.addressField, "ab"); + + expect(estimates, greaterThan(0)); + expect(fees.feeEstimates, estimates); + expect(find.textContaining("Calculating"), findsOneWidget); + }); + + testWithoutAnimation("is estimated again once typing pauses ($view)", ( + tester, + ) async { + final wallet = newWallet(); + final fees = view.feeEstimator(wallet)..feeError = Exception("down"); + await view.pump(tester, wallet: wallet); + await enterAmount(tester, view, "1"); + final estimates = fees.feeEstimates; + + await enterField(tester, view.cryptoAmountField, "2"); + await settle(tester); + + expect(fees.feeEstimates, estimates + 1); + }); + + testWithoutAnimation( + "is estimated again when the amount field loses focus ($view)", + (tester) async { + final wallet = newWallet(); + final fees = view.feeEstimator(wallet)..feeError = Exception("down"); + await view.pump(tester, wallet: wallet); + await enterAmount(tester, view, "1"); + final estimates = fees.feeEstimates; + + await enterAmount(tester, view, "1"); + + expect(fees.feeEstimates, estimates + 1); + }, + ); + } + + testWithoutAnimation("is fetched again by a balance switch (mobile Firo)", ( + tester, + ) async { + final wallet = FakeFiroWallet()..holdFeeRateFetches = Completer(); + await mobileCoin.pump(tester, wallet: wallet); + await enterAmount(tester, mobileCoin, "0.1"); + await sendFrom(tester, "Public balance"); + await sendFrom(tester, "Private balance"); + await failHeldFeeRates(tester, wallet); + + await sendFrom(tester, "Public balance"); + + expect(find.textContaining("0.00001000"), findsOneWidget); + expect(wallet.feeRateFetches, 2); + }); + + testWithoutAnimation( + "is fetched again by a balance switch (desktop Firo)", + (tester) async { + final wallet = FakeFiroWallet()..failingFeeRateFetches = 2; + await desktopCoin.pump(tester, wallet: wallet); + await sendFrom(tester, "Public balance"); + await sendFrom(tester, "Private balance"); + + await sendFrom(tester, "Public balance"); + + expect(find.textContaining("0.00001000"), findsOneWidget); + expect(wallet.feeRateFetches, 3); + }, + ); + + testWithoutAnimation( + "falls back to the minimum fee (mobile Solana token)", + (tester) async { + final wallet = FakeSolanaWallet()..feeError = Exception("node down"); + await mobileSolToken.pump(tester, wallet: wallet); + + await enterField(tester, mobileSolToken.cryptoAmountField, "1"); + await settle(tester); + + expect(wallet.feeEstimates, greaterThan(0)); + expect(find.textContaining("0.000005"), findsOneWidget); + }, + ); + + testWithoutAnimation("is logged (mobile Solana token)", (tester) async { + final wallet = FakeSolanaWallet()..feeError = Exception("node down"); + + final logged = await logs(() async { + await mobileSolToken.pump( + tester, + wallet: wallet, + balance: testBalance("1", 9), + ); + await enterAmount(tester, mobileSolToken, "1"); + }); + + expect( + logged, + contains(contains("Failed to calculate Solana token fees")), + ); + }); + + testWithoutAnimation( + "is fetched again once typing pauses (mobile Solana token)", + (tester) async { + final wallet = FakeSolanaWallet()..failingFeeRateFetches = 1; + await mobileSolToken.pump( + tester, + wallet: wallet, + balance: testBalance("1", 9), + ); + + await enterAmount(tester, mobileSolToken, "1"); + + expect(wallet.feeRateFetches, 2); + expect(find.text("~0.000010000 SOL"), findsOneWidget); + }, + ); + }); + + group("fee rates", () { + for (final view in coinCases) { + testWithoutAnimation("are fetched once for every amount ($view)", ( + tester, + ) async { + final wallet = FakeBitcoinWallet(); + await view.pump(tester, wallet: wallet); + await enterAmount(tester, view, "0.1"); + + await enterAmount(tester, view, "0.2"); + + expect(wallet.feeRateFetches, 1); + }); + + testWithoutAnimation( + "are fetched fresh each time the fee choice opens ($view)", + (tester) async { + final wallet = FakeBitcoinWallet(); + await view.pump(tester, wallet: wallet); + await enterAmount(tester, view, "0.1"); + await view.openFeeChoice(tester, "0.00002000"); + await pick(tester, "Average"); + final fetches = wallet.feeRateFetches; + wallet.feeRateFactor = 2; + + await view.openFeeChoice(tester, "0.00002000"); + + expect(wallet.feeRateFetches, fetches + 1); + expect( + inFeeChoice(find.textContaining("0.00004000")), + findsOneWidget, + ); + }, + ); + } + }); + + group("fee estimates", () { + for (final view in coinCases) { + testWithoutAnimation("follow the amount typed last ($view)", ( + tester, + ) async { + final wallet = FakeBitcoinWallet(); + await view.pump(tester, wallet: wallet); + final estimates = wallet.feeEstimateAmounts.length; + + await tester.enterText(find.byKey(view.cryptoAmountField), "0.1"); + await tester.pump(const Duration(milliseconds: 100)); + await tester.enterText(find.byKey(view.cryptoAmountField), "0.2"); + await settle(tester); + + expect(wallet.feeEstimateAmounts.sublist(estimates), [ + Amount.tryParseCanonicalAmount("0.2", fractionDigits: 8), + ]); + }); + + testWithoutAnimation( + "follow the recipients' total typed last ($view Monero)", + (tester) async { + final wallet = FakeMoneroWallet(); + await view.pump(tester, wallet: wallet); + await enterField( + tester, + view.addressField, + "monero:address0;address1?tx_amount=1;2", + ); + await settle(tester); + final estimates = wallet.feeEstimateAmounts.length; + final amountField = find.descendant( + of: find.byType(Recipient).first, + matching: find.byKey(view.cryptoAmountField), + ); + + await tester.enterText(amountField, "5"); + await tester.pump(const Duration(milliseconds: 100)); + await tester.enterText(amountField, "6"); + await settle(tester); + + expect(wallet.feeEstimateAmounts.sublist(estimates), [ + Amount.tryParseCanonicalAmount("8", fractionDigits: 12), + ]); + }, + ); + } + }); + + group("the send view", () { + for (final (coin, wallet) in <(String, Wallet Function())>[ + ("Epic Cash", FakeEpiccashWallet.new), + ("Xelis", FakeXelisWallet.new), + ]) { + testWithoutAnimation("rebuilds nothing for an amount without a fee " + "(mobile $coin)", (tester) async { + await mobileCoin.pump(tester, wallet: wallet()); + await tester.enterText(find.byKey(mobileCoin.cryptoAmountField), "1"); + await tester.pump(); + + await tester.binding.delayed(const Duration(seconds: 1)); + + expect(tester.element(find.byType(SendView)).dirty, isFalse); + }); + } + }); +} diff --git a/test/pages/send_view/sol_token_send_locale_change_test.dart b/test/pages/send_view/sol_token_send_locale_change_test.dart index 8a90f2bc26..392d2c1f40 100644 --- a/test/pages/send_view/sol_token_send_locale_change_test.dart +++ b/test/pages/send_view/sol_token_send_locale_change_test.dart @@ -164,6 +164,7 @@ void main() { pWalletInfo(walletId).overrideWithValue(info), pWalletCoin(walletId).overrideWithValue(coin), pWalletName(walletId).overrideWithValue(info.name), + pWalletBalance(walletId).overrideWithValue(info.cachedBalance), pSolanaTokenBalance((walletId: walletId, tokenMint: mint)) .overrideWithValue( Balance( diff --git a/test/pages/send_view/sub_widgets/multi_recipient_test.dart b/test/pages/send_view/sub_widgets/multi_recipient_test.dart index 2ff96a8fa2..d006612cb0 100644 --- a/test/pages/send_view/sub_widgets/multi_recipient_test.dart +++ b/test/pages/send_view/sub_widgets/multi_recipient_test.dart @@ -14,20 +14,12 @@ import 'package:stackwallet/utilities/amount/amount.dart'; import 'package:stackwallet/utilities/amount/amount_formatter.dart'; import 'package:stackwallet/utilities/amount/amount_unit.dart'; import 'package:stackwallet/utilities/util.dart'; -import 'package:stackwallet/wallets/crypto_currency/crypto_currency.dart'; import '../../../sample_data/theme_json.dart'; - -/// Monero that validates addresses without the native library. -class _TestMonero extends Monero { - _TestMonero() : super(CryptoCurrencyNetwork.main); - - @override - bool validateAddress(String address) => address.startsWith("address"); -} +import '../support/fake_wallets.dart' show TestMonero; void main() { - final coin = _TestMonero(); + final coin = TestMonero(); PaymentUriData uri(List amounts, {List? addresses}) => PaymentUriData( diff --git a/test/pages/send_view/support/fake_wallets.dart b/test/pages/send_view/support/fake_wallets.dart new file mode 100644 index 0000000000..b8adce7142 --- /dev/null +++ b/test/pages/send_view/support/fake_wallets.dart @@ -0,0 +1,350 @@ +import 'dart:async'; + +import 'package:stackwallet/models/balance.dart'; +import 'package:stackwallet/models/isar/models/ethereum/eth_contract.dart'; +import 'package:stackwallet/models/isar/models/solana/sol_contract.dart'; +import 'package:stackwallet/models/paymint/fee_object_model.dart'; +import 'package:stackwallet/utilities/amount/amount.dart'; +import 'package:stackwallet/wallets/crypto_currency/crypto_currency.dart'; +import 'package:stackwallet/wallets/crypto_currency/intermediate/cryptonote_currency.dart'; +import 'package:stackwallet/wallets/isar/models/wallet_info.dart'; +import 'package:stackwallet/wallets/models/tx_data.dart'; +import 'package:stackwallet/wallets/wallet/impl/bitcoin_wallet.dart'; +import 'package:stackwallet/wallets/wallet/impl/cardano_wallet.dart'; +import 'package:stackwallet/wallets/wallet/impl/epiccash_wallet.dart'; +import 'package:stackwallet/wallets/wallet/impl/ethereum_wallet.dart'; +import 'package:stackwallet/wallets/wallet/impl/firo_wallet.dart'; +import 'package:stackwallet/wallets/wallet/impl/monero_wallet.dart'; +import 'package:stackwallet/wallets/wallet/impl/solana_wallet.dart'; +import 'package:stackwallet/wallets/wallet/impl/stellar_wallet.dart'; +import 'package:stackwallet/wallets/wallet/impl/sub_wallets/eth_token_wallet.dart'; +import 'package:stackwallet/wallets/wallet/impl/sub_wallets/solana_token_wallet.dart'; +import 'package:stackwallet/wallets/wallet/impl/wownero_wallet.dart'; +import 'package:stackwallet/wallets/wallet/wallet.dart'; + +/// A wallet answering what the send views read. It records the fee requests +/// and the prepared send. +abstract class FakeWallet { + FakeWallet(this.cryptoCurrency, [this.feeRates]); + + final T cryptoCurrency; + + /// Scaled by [feeRateFactor]. + FeeObject? feeRates; + int feeRateFactor = 1; + + int feeEstimates = 0; + final feeEstimateAmounts = []; + Object? feeError; + int feeRateFetches = 0; + int failingFeeRateFetches = 0; + Completer? holdFeeRateFetches; + TxData? prepared; + + String get walletId => cryptoCurrency.identifier; + + late final WalletInfo info = TestWalletInfo(walletId, cryptoCurrency); + + bool get supportsMultiRecipient => false; + + bool get hasFunds => info.cachedBalance.spendable.raw > BigInt.zero; + + /// In the coin's smallest unit. The fee rate itself unless a fake overrides + /// it. + BigInt rawFee(Amount amount, BigInt feeRate) => feeRate; + + Future get fees => recordFeeRateFetch( + FeeObject( + numberOfBlocksFast: feeRates!.numberOfBlocksFast, + numberOfBlocksAverage: feeRates!.numberOfBlocksAverage, + numberOfBlocksSlow: feeRates!.numberOfBlocksSlow, + fast: feeRates!.fast * BigInt.from(feeRateFactor), + medium: feeRates!.medium * BigInt.from(feeRateFactor), + slow: feeRates!.slow * BigInt.from(feeRateFactor), + ), + ); + + Future estimateFeeFor(Amount amount, BigInt feeRate) { + feeEstimateAmounts.add(amount); + return recordFeeEstimate( + Amount( + rawValue: rawFee(amount, feeRate), + fractionDigits: cryptoCurrency.fractionDigits, + ), + ); + } + + Future recordFeeEstimate(Amount fee) async { + feeEstimates++; + if (feeError != null) throw feeError!; + return fee; + } + + Future recordFeeRateFetch(R rates) async { + final fails = ++feeRateFetches <= failingFeeRateFetches; + await holdFeeRateFetches?.future; + if (fails) throw Exception("node down"); + return rates; + } + + Future prepareSend({required TxData txData}) async { + prepared = txData; + return txData.copyWith( + fee: Amount( + rawValue: BigInt.from(1000), + fractionDigits: cryptoCurrency.fractionDigits, + ), + ); + } + + Future cancelSend({required TxData txData}) async {} + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +/// Rates in sats per kB, as the UTXO wallets give them. +final testFees = FeeObject( + numberOfBlocksFast: 1, + numberOfBlocksAverage: 3, + numberOfBlocksSlow: 6, + fast: BigInt.from(30000), + medium: BigInt.from(20000), + slow: BigInt.from(10000), +); + +FeeObject _feeRates(int slow, int medium, int fast) => FeeObject( + numberOfBlocksFast: 1, + numberOfBlocksAverage: 1, + numberOfBlocksSlow: 1, + fast: BigInt.from(fast), + medium: BigInt.from(medium), + slow: BigInt.from(slow), +); + +class TestWalletInfo extends WalletInfo { + TestWalletInfo(String walletId, this._coin) + : super( + walletId: walletId, + name: "${_coin.prettyName} test wallet", + mainAddressType: _coin.defaultAddressType, + coinName: _coin.identifier, + ); + + final CryptoCurrency _coin; + + // Desktop send takes its coin from the wallet info. + @override + CryptoCurrency get coin => _coin; + + Balance? balance; + + @override + Balance get cachedBalance => balance ?? Balance.zeroFor(currency: _coin); +} + +Balance testBalance(String spendable, int fractionDigits) { + final amount = Amount.tryParseCanonicalAmount( + spendable, + fractionDigits: fractionDigits, + )!; + final zero = Amount.zeroWith(fractionDigits: fractionDigits); + return Balance( + total: amount, + spendable: amount, + blockedTotal: zero, + pendingSpendable: zero, + ); +} + +const btcAddress = "bc1qu58svs9983e2vuyqh7gq7ratf8k5qehz5k0cn5"; +const firoAddress = "a6ESWKz7szru5syLtYAPRhHLdKvMq3Yt1j"; +const firoExchangeAddress = "EXXMGtieRLNGfgewJ4jJCN4kZFTUcjYMDdHs"; + +class FakeBitcoinWallet extends FakeWallet + implements BitcoinWallet { + FakeBitcoinWallet() : super(Bitcoin(CryptoCurrencyNetwork.main), testFees); + + /// A 100-vbyte transaction. + @override + BigInt rawFee(Amount amount, BigInt feeRate) => + feeRate * BigInt.from(100) ~/ BigInt.from(1000); +} + +final testEthFees = EthFeeObject( + suggestBaseFee: BigInt.from(1000000000), + numberOfBlocksFast: 1, + numberOfBlocksAverage: 3, + numberOfBlocksSlow: 6, + fast: BigInt.from(3000000000), + medium: BigInt.from(2000000000), + slow: BigInt.from(1000000000), +); + +final testEthContract = EthContract( + address: "0xdAC17F958D2ee523a2206206994597C13D831ec7", + name: "Tether USD", + symbol: "USDT", + decimals: 6, + type: EthContractType.erc20, +); + +final testSolContract = SolContract( + address: "Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB", + name: "Tether USD", + symbol: "USDT", + decimals: 6, +); + +class FakeEthereumWallet extends FakeWallet + implements EthereumWallet { + FakeEthereumWallet() : super(Ethereum(CryptoCurrencyNetwork.main)); + + late final tokenWallet = FakeEthTokenWallet(this); + + @override + Future get fees => recordFeeRateFetch(testEthFees); + + /// A 21000-gas transfer. + @override + BigInt rawFee(Amount amount, BigInt feeRate) => BigInt.from(21000) * feeRate; +} + +class FakeEthTokenWallet extends FakeWallet + implements EthTokenWallet { + FakeEthTokenWallet(FakeEthereumWallet ethWallet) + : super(ethWallet.cryptoCurrency); + + @override + EthContract get tokenContract => testEthContract; + + @override + Future get fees => recordFeeRateFetch(testEthFees); + + /// A 65000-gas transfer. + @override + BigInt rawFee(Amount amount, BigInt feeRate) => BigInt.from(65000) * feeRate; +} + +class FakeSolanaWallet extends FakeWallet implements SolanaWallet { + /// The fee of each priority, in lamports. + FakeSolanaWallet() + : super(Solana(CryptoCurrencyNetwork.main), _feeRates(10000, 15000, 20000)); + + late final tokenWallet = FakeSolanaTokenWallet(this); + + @override + BigInt rawFee(Amount amount, BigInt feeRate) => + hasFunds ? feeRate : BigInt.zero; +} + +class FakeSolanaTokenWallet extends FakeWallet + implements SolanaTokenWallet { + FakeSolanaTokenWallet(FakeSolanaWallet parentSolanaWallet) + : super(parentSolanaWallet.cryptoCurrency); + + @override + SolContract get solContract => testSolContract; + + @override + String get tokenMint => testSolContract.address; + + @override + String get tokenSymbol => testSolContract.symbol; + + @override + int get tokenDecimals => testSolContract.decimals; +} + +class FakeFiroWallet extends FakeWallet implements FiroWallet { + FakeFiroWallet() : super(Firo(CryptoCurrencyNetwork.main), testFees); + + /// A 50-vbyte transaction. + @override + BigInt rawFee(Amount amount, BigInt feeRate) => + feeRate * BigInt.from(50) ~/ BigInt.from(1000); + + @override + int estimateTxFee({required int vSize, required BigInt feeRatePerKB}) => + (feeRatePerKB * BigInt.from(vSize) ~/ BigInt.from(1000)).toInt(); + + @override + Future estimateFeeForSpark(Amount amount) => recordFeeEstimate( + Amount( + rawValue: BigInt.from(amount.raw > BigInt.zero ? 2000 : 0), + fractionDigits: 8, + ), + ); +} + +class FakeEpiccashWallet extends FakeWallet + implements EpiccashWallet { + FakeEpiccashWallet() : super(Epiccash(CryptoCurrencyNetwork.main), testFees); +} + +/// Monero checking only an address prefix, as tests lack its native library. +class TestMonero extends Monero { + TestMonero() : super(CryptoCurrencyNetwork.main); + + @override + bool validateAddress(String address) => address.startsWith("address"); +} + +abstract class FakeSyncDependentFeeWallet + extends FakeWallet + implements Wallet { + /// Cryptonote wallets give their tx priorities as fee rates. + FakeSyncDependentFeeWallet(T coin) : super(coin, _feeRates(1, 2, 3)); + + // The send views of the test-first commit take the Average priority from + // the wallet. + int getTxPriorityMedium() => 2; + + /// The real wallet estimates a zero fee until it has synchronized. + bool isSynced = true; + + bool get canEstimateFee => isSynced; + + @override + BigInt rawFee(Amount amount, BigInt feeRate) => + isSynced && amount.raw > BigInt.zero + ? feeRate * BigInt.from(10000000) + : BigInt.zero; +} + +class FakeMoneroWallet extends FakeSyncDependentFeeWallet + implements MoneroWallet { + FakeMoneroWallet() : super(TestMonero()); + + @override + bool get supportsMultiRecipient => true; +} + +class FakeWowneroWallet extends FakeSyncDependentFeeWallet + implements WowneroWallet { + FakeWowneroWallet() : super(Wownero(CryptoCurrencyNetwork.main)); +} + +// XelisWallet's fee estimate takes more parameters. +class FakeXelisWallet extends FakeWallet implements Wallet { + FakeXelisWallet() : super(Xelis(CryptoCurrencyNetwork.main), testFees); +} + +class FakeStellarWallet extends FakeWallet implements StellarWallet { + /// The base fee at every rate, in stroops. + FakeStellarWallet() + : super(Stellar(CryptoCurrencyNetwork.main), _feeRates(100, 100, 100)); +} + +class FakeCardanoWallet extends FakeWallet implements CardanoWallet { + /// The fee of a basic transaction at every rate, in lovelace. + FakeCardanoWallet() + : super( + Cardano(CryptoCurrencyNetwork.main), + _feeRates(170000, 170000, 170000), + ); + + @override + BigInt rawFee(Amount amount, BigInt feeRate) => + hasFunds ? feeRate : BigInt.zero; +} diff --git a/test/pages/send_view/support/send_view_cases.dart b/test/pages/send_view/support/send_view_cases.dart new file mode 100644 index 0000000000..1e191ed6b1 --- /dev/null +++ b/test/pages/send_view/support/send_view_cases.dart @@ -0,0 +1,204 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:stackwallet/models/balance.dart'; +import 'package:stackwallet/pages/send_view/frost_ms/recipient.dart'; +import 'package:stackwallet/pages/send_view/send_view.dart'; +import 'package:stackwallet/pages/send_view/sol_token_send_view.dart'; +import 'package:stackwallet/pages/send_view/token_send_view.dart'; +import 'package:stackwallet/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_send.dart'; +import 'package:stackwallet/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_token_send.dart'; +import 'package:stackwallet/wallets/wallet/wallet.dart'; +import 'package:stackwallet/widgets/fee_slider.dart'; + +import 'fake_wallets.dart'; +import 'send_view_harness.dart'; + +class SendViewCase { + const SendViewCase({ + required this.name, + required this.desktop, + required this.view, + required this.addressField, + required this.cryptoAmountField, + required this.defaultWallet, + required this.feeEstimator, + this.tokenOverrides, + }); + + final String name; + final bool desktop; + final Widget Function(Wallet wallet) view; + final Key addressField; + final Key cryptoAmountField; + + final List Function(Wallet wallet)? tokenOverrides; + + final Wallet Function() defaultWallet; + + final FakeWallet Function(Wallet wallet) feeEstimator; + + String get previewLabel => desktop ? "Preview send" : "Preview"; + + Future pump( + WidgetTester tester, { + Wallet? wallet, + List overrides = const [], + Balance? balance, + HarnessPrefs? prefs, + RouteFactory? onGenerateRoute, + }) async { + final w = wallet ?? defaultWallet(); + await pumpSendView( + tester, + wallet: w, + desktop: desktop, + view: view(w), + balance: balance, + prefs: prefs, + onGenerateRoute: onGenerateRoute, + overrides: [...?tokenOverrides?.call(w), ...overrides], + ); + } + + Future preview(WidgetTester tester) async { + await tester.tap(find.text(previewLabel)); + // The views keep the building dialog up for at least 2.5 s. + await tester.pump(const Duration(seconds: 3)); + await tester.pumpAndSettle(); + } + + Future openFeeChoice(WidgetTester tester, String fee) async { + await tester.tap( + desktop + ? find.text("Edit", findRichText: true) + : find.textContaining(fee).last, + ); + await tester.pumpAndSettle(); + } + + @override + String toString() => name; +} + +const _cryptoAmountField = Key("amountInputFieldCryptoTextFieldKey"); + +final mobileCoin = SendViewCase( + name: "mobile", + desktop: false, + view: (wallet) => + SendView(walletId: wallet.walletId, coin: wallet.cryptoCurrency), + addressField: const Key("sendViewAddressFieldKey"), + cryptoAmountField: _cryptoAmountField, + defaultWallet: FakeBitcoinWallet.new, + feeEstimator: (wallet) => wallet as FakeWallet, +); + +final desktopCoin = SendViewCase( + name: "desktop", + desktop: true, + view: (wallet) => DesktopSend(walletId: wallet.walletId), + addressField: const Key("sendViewAddressFieldKey"), + cryptoAmountField: _cryptoAmountField, + defaultWallet: FakeBitcoinWallet.new, + feeEstimator: (wallet) => wallet as FakeWallet, +); + +final coinCases = [mobileCoin, desktopCoin]; + +List _ethToken(Wallet wallet) => + ethTokenOverrides((wallet as FakeEthereumWallet).tokenWallet); + +List _solToken(Wallet wallet) => + solTokenOverrides((wallet as FakeSolanaWallet).tokenWallet); + +FakeWallet _ethTokenFees(Wallet wallet) => + (wallet as FakeEthereumWallet).tokenWallet; + +// Solana token fees are the parent wallet's. +FakeWallet _solTokenFees(Wallet wallet) => wallet as FakeSolanaWallet; + +final mobileEthToken = SendViewCase( + name: "mobile Ethereum token", + desktop: false, + view: (wallet) => TokenSendView( + walletId: wallet.walletId, + coin: wallet.cryptoCurrency, + tokenContract: testEthContract, + ), + addressField: const Key("tokenSendViewAddressFieldKey"), + cryptoAmountField: _cryptoAmountField, + tokenOverrides: _ethToken, + defaultWallet: FakeEthereumWallet.new, + feeEstimator: _ethTokenFees, +); + +final desktopEthToken = SendViewCase( + name: "desktop Ethereum token", + desktop: true, + view: (wallet) => DesktopTokenSend(walletId: wallet.walletId), + addressField: const Key("sendViewAddressFieldKey"), + cryptoAmountField: _cryptoAmountField, + tokenOverrides: _ethToken, + defaultWallet: FakeEthereumWallet.new, + feeEstimator: _ethTokenFees, +); + +final mobileSolToken = SendViewCase( + name: "mobile Solana token", + desktop: false, + view: (wallet) => SolTokenSendView( + walletId: wallet.walletId, + tokenMint: testSolContract.address, + ), + addressField: const Key("solTokenSendViewAddressFieldKey"), + cryptoAmountField: const Key("solAmountInputFieldCryptoTextFieldKey"), + tokenOverrides: _solToken, + defaultWallet: FakeSolanaWallet.new, + feeEstimator: _solTokenFees, +); + +/// Recipient forms reuse the view's keys. +Finder _inView(Key key) => find.byElementPredicate( + (element) => + element.widget.key == key && + element.findAncestorWidgetOfExactType() == null, +); + +Future enterField(WidgetTester tester, Key key, String text) async { + await tester.enterText(_inView(key), text); + await tester.pumpAndSettle(); +} + +Future sendFrom(WidgetTester tester, String balance) async { + await tester.tap( + find.textContaining(RegExp(r"^(Public|Private) balance$")).last, + ); + await tester.pumpAndSettle(); + await tester.tap(find.text(balance).last); + await tester.pumpAndSettle(); +} + +Future pick(WidgetTester tester, String feeRate) async { + await tester.tap(find.textContaining(feeRate).last); + await tester.pumpAndSettle(); +} + +int sliderRate(WidgetTester tester) => int.parse( + tester + .widget( + find.descendant( + of: find.byType(FeeSlider), + matching: find.textContaining(RegExp(r"^[0-9]+$")), + ), + ) + .data!, +); + +Future setCustomRate(WidgetTester tester) async { + await tester.tap(find.text("Custom").last); + await tester.pumpAndSettle(); + await tester.drag(find.byType(Slider), const Offset(2000, 0)); + await tester.pumpAndSettle(); + return sliderRate(tester); +} diff --git a/test/pages/send_view/support/send_view_harness.dart b/test/pages/send_view/support/send_view_harness.dart new file mode 100644 index 0000000000..190c09e7ae --- /dev/null +++ b/test/pages/send_view/support/send_view_harness.dart @@ -0,0 +1,216 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:decimal/decimal.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/scheduler.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:meta/meta.dart'; +import 'package:platform/platform.dart'; +import 'package:stackwallet/models/balance.dart'; +import 'package:stackwallet/models/isar/stack_theme.dart'; +import 'package:stackwallet/providers/global/prefs_provider.dart'; +import 'package:stackwallet/providers/global/price_provider.dart'; +import 'package:stackwallet/providers/global/wallets_provider.dart'; +import 'package:stackwallet/services/price_service.dart'; +import 'package:stackwallet/themes/coin_icon_provider.dart'; +import 'package:stackwallet/themes/stack_colors.dart'; +import 'package:stackwallet/themes/theme_providers.dart'; +import 'package:stackwallet/utilities/amount/amount_unit.dart'; +import 'package:stackwallet/utilities/util.dart'; +import 'package:stackwallet/wallets/crypto_currency/crypto_currency.dart'; +import 'package:stackwallet/wallets/isar/providers/eth/current_token_wallet_provider.dart'; +import 'package:stackwallet/wallets/isar/providers/eth/token_balance_provider.dart'; +import 'package:stackwallet/wallets/isar/providers/solana/current_sol_token_wallet_provider.dart'; +import 'package:stackwallet/wallets/isar/providers/solana/sol_token_balance_provider.dart'; +import 'package:stackwallet/wallets/isar/providers/wallet_info_provider.dart'; +import 'package:stackwallet/wallets/wallet/impl/sub_wallets/eth_token_wallet.dart'; +import 'package:stackwallet/wallets/wallet/impl/sub_wallets/solana_token_wallet.dart'; +import 'package:stackwallet/wallets/wallet/wallet.dart'; + +import '../../../sample_data/theme_json.dart'; +import '../../../wallets/support/xelis_test_fakes.dart'; +import 'fake_wallets.dart'; + +class HarnessPrefs extends ConfirmationPrefs { + HarnessPrefs({this.enableCoinControl = false}); + + @override + final bool enableCoinControl; + + @override + AmountUnit amountUnit(CryptoCurrency coin) => AmountUnit.normal; + + @override + int maxDecimals(CryptoCurrency coin) => coin.fractionDigits; +} + +/// A test tapping a CustomTextButton needs its font loaded before the first +/// paint. +Future loadButtonFont() async { + final font = FontLoader('Inter_500'); + font.addFont(rootBundle.load('google_fonts/Inter-Medium.ttf')); + await font.load(); +} + +/// A faster widget test: semantics are off and animations end on their first +/// frame. +@isTest +void testWithoutAnimation(String description, WidgetTesterCallback callback) => + testWidgets(description, (tester) async { + // Timers keep the fake clock. testWidgets checks the dilation is back to + // 1 before the test ends. + timeDilation = 0.001; + try { + await callback(tester); + } finally { + timeDilation = 1; + } + }, semanticsEnabled: false); + +Future pumpSendView( + WidgetTester tester, { + required Wallet wallet, + required bool desktop, + required Widget view, + List overrides = const [], + Balance? balance, + HarnessPrefs? prefs, + RouteFactory? onGenerateRoute, +}) async { + tester.view.physicalSize = desktop + ? const Size(1600, 1200) + : const Size(800, 1600); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + final previousPlatform = Util.layoutPlatform; + addTearDown(() => Util.layoutPlatform = previousPlatform); + Util.layoutPlatform = FakePlatform( + operatingSystem: desktop ? "linux" : "android", + ); + + final coin = wallet.cryptoCurrency; + final walletId = wallet.walletId; + final info = wallet.info; + (info as TestWalletInfo).balance = balance; + final harnessPrefs = prefs ?? HarnessPrefs(); + final theme = StackTheme.fromJson(json: lightThemeJsonMap); + await tester.pumpWidget( + ProviderScope( + overrides: [ + pWallets.overrideWithValue(ConfirmationWallets(wallet)), + pWalletInfo(walletId).overrideWithValue(info), + pWalletCoin(walletId).overrideWithValue(coin), + pWalletName(walletId).overrideWithValue(info.name), + pWalletBalance(walletId) + .overrideWithProvider(Provider((_) => info.cachedBalance)), + pWalletBalanceTertiary(walletId) + .overrideWithValue(info.cachedBalanceTertiary), + prefsChangeNotifierProvider.overrideWithValue(harnessPrefs), + priceAnd24hChangeNotifierProvider.overrideWithValue(FakePriceService()), + themeProvider.overrideWithProvider(StateProvider((ref) => theme)), + coinIconProvider(coin).overrideWithValue( + File('test/sample_data/light/assets/dummy.svg').absolute.path, + ), + ...overrides, + ], + child: MaterialApp( + theme: ThemeData(extensions: [StackColors.fromStackColorTheme(theme)]), + onGenerateRoute: onGenerateRoute, + home: Scaffold(body: view), + ), + ), + ); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); +} + +List ethTokenOverrides(EthTokenWallet tokenWallet) => [ + tokenServiceStateProvider.overrideWithProvider( + StateProvider((ref) => tokenWallet), + ), + pTokenBalance.overrideWithProvider( + (_) => Provider( + (_) => + Balance.zeroWith(fractionDigits: tokenWallet.tokenContract.decimals), + ), + ), +]; + +List solTokenOverrides(SolanaTokenWallet tokenWallet) => [ + solanaTokenServiceStateProvider.overrideWithProvider( + StateProvider((ref) => tokenWallet), + ), + pSolanaTokenBalance.overrideWithProvider( + (_) => Provider( + (_) => Balance.zeroWith(fractionDigits: tokenWallet.tokenDecimals), + ), + ), +]; + +class FakePriceService extends ChangeNotifier implements PriceService { + @override + ({Decimal value, double change24h})? getPrice(CryptoCurrency coin) => null; + + @override + ({Decimal value, double change24h})? getTokenPrice(String contractAddress) => + null; + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +class RecordedRoutes { + final arguments = []; + + Route generate(RouteSettings settings) { + arguments.add(settings.arguments); + return MaterialPageRoute( + settings: settings, + builder: (_) => const _PopAtOnce(), + ); + } +} + +class _PopAtOnce extends StatefulWidget { + const _PopAtOnce(); + + @override + State<_PopAtOnce> createState() => _PopAtOnceState(); +} + +class _PopAtOnceState extends State<_PopAtOnce> { + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback( + (_) => Navigator.of(context).pop(), + ); + } + + @override + Widget build(BuildContext context) => const SizedBox.shrink(); +} + +Future> logs(Future Function() body) async { + final messages = []; + final debugPrintBefore = debugPrint; + void restore() => debugPrint = debugPrintBefore; + // The finally restores it before testWidgets checks it, and the teardown + // when [body] never finishes. + addTearDown(restore); + debugPrint = (message, {wrapWidth}) => messages.add(message); + try { + await runZoned( + body, + zoneSpecification: ZoneSpecification( + print: (_, _, _, line) => messages.add(line), + ), + ); + } finally { + restore(); + } + return messages; +} diff --git a/test/pages/send_view/token_amount_display_updates_test.dart b/test/pages/send_view/token_amount_display_updates_test.dart index 0dd3e1675e..a226ed8779 100644 --- a/test/pages/send_view/token_amount_display_updates_test.dart +++ b/test/pages/send_view/token_amount_display_updates_test.dart @@ -165,6 +165,7 @@ void main() { pWalletInfo(walletId).overrideWithValue(info), pWalletCoin(walletId).overrideWithValue(coin), pWalletName(walletId).overrideWithValue(info.name), + pWalletBalance(walletId).overrideWithValue(info.cachedBalance), pTokenBalance(( walletId: walletId, contractAddress: contract.address, diff --git a/test/pages/send_view/xelis_send_view_test.dart b/test/pages/send_view/xelis_send_view_test.dart deleted file mode 100644 index d0eff3a70e..0000000000 --- a/test/pages/send_view/xelis_send_view_test.dart +++ /dev/null @@ -1,113 +0,0 @@ -import 'dart:io'; - -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:stackwallet/models/balance.dart'; -import 'package:stackwallet/models/isar/models/blockchain_data/address.dart'; -import 'package:stackwallet/models/isar/stack_theme.dart'; -import 'package:stackwallet/pages/send_view/send_view.dart'; -import 'package:stackwallet/pages/send_view/sub_widgets/transaction_fee_selection_sheet.dart'; -import 'package:stackwallet/providers/global/prefs_provider.dart'; -import 'package:stackwallet/providers/global/wallets_provider.dart'; -import 'package:stackwallet/themes/coin_icon_provider.dart'; -import 'package:stackwallet/themes/stack_colors.dart'; -import 'package:stackwallet/themes/theme_providers.dart'; -import 'package:stackwallet/utilities/amount/amount_formatter.dart'; -import 'package:stackwallet/utilities/amount/amount_unit.dart'; -import 'package:stackwallet/utilities/stack_file_system.dart'; -import 'package:stackwallet/wallets/crypto_currency/crypto_currency.dart'; -import 'package:stackwallet/wallets/isar/models/wallet_info.dart'; -import 'package:stackwallet/wallets/isar/providers/wallet_info_provider.dart'; - -import '../../sample_data/theme_json.dart'; -import '../../wallets/support/xelis_test_fakes.dart'; - -class SendPrefs extends ConfirmationPrefs { - @override - bool get enableCoinControl => false; - @override - AmountUnit amountUnit(CryptoCurrency coin) => AmountUnit.normal; - @override - int maxDecimals(CryptoCurrency coin) => coin.fractionDigits; -} - -void main() { - testWidgets('Xelis mobile form shows deferred fees without a fee selector', ( - tester, - ) async { - // This exercises SendView (the mobile form) on the host test runner. - tester.view.physicalSize = const Size(800, 1200); - tester.view.devicePixelRatio = 1; - addTearDown(tester.view.resetPhysicalSize); - addTearDown(tester.view.resetDevicePixelRatio); - final previousThemesDir = StackFileSystem.themesDir; - addTearDown(() => StackFileSystem.themesDir = previousThemesDir); - StackFileSystem.themesDir = Directory('test/sample_data').absolute; - - final wallet = TestWallet(FakeNative()); - final coin = wallet.cryptoCurrency; - const walletId = 'xelis-send-form'; - final info = WalletInfo( - walletId: walletId, - name: 'Xelis test wallet', - mainAddressType: AddressType.xelis, - coinName: coin.identifier, - ); - final theme = StackTheme.fromJson(json: lightThemeJsonMap); - await tester.pumpWidget( - ProviderScope( - overrides: [ - pWallets.overrideWithValue(ConfirmationWallets(wallet)), - pWalletInfo(walletId).overrideWithValue(info), - pWalletCoin(walletId).overrideWithValue(coin), - pWalletName(walletId).overrideWithValue(info.name), - pWalletBalance(walletId) - .overrideWithValue(Balance.zeroFor(currency: coin)), - prefsChangeNotifierProvider.overrideWithValue(SendPrefs()), - themeProvider.overrideWithProvider(StateProvider((ref) => theme)), - coinIconProvider(coin).overrideWithValue( - File('test/sample_data/light/assets/dummy.svg').absolute.path, - ), - pAmountFormatter(coin).overrideWithValue( - AmountFormatter( - unit: AmountUnit.normal, - locale: 'en_US', - coin: coin, - maxDecimals: 8, - ), - ), - ], - child: MaterialApp( - theme: ThemeData( - extensions: [StackColors.fromStackColorTheme(theme)], - ), - home: SendView(walletId: walletId, coin: coin), - ), - ), - ); - await tester.pumpAndSettle(); - expect(tester.takeException(), isNull); - final feeLabel = find.text('Calculated when reviewing'); - expect(feeLabel, findsOneWidget); - expect(find.text('Transaction fee'), findsOneWidget); - expect(find.text('Transaction fee (estimated)'), findsNothing); - - await tester.enterText( - find.byKey(const Key('amountInputFieldCryptoTextFieldKey')), - '0.12345678', - ); - await tester.pump(const Duration(milliseconds: 600)); - await tester.pumpAndSettle(); - expect(tester.takeException(), isNull); - expect(feeLabel, findsOneWidget); - - await tester.ensureVisible(feeLabel); - await tester.tap(feeLabel); - await tester.pumpAndSettle(); - expect(find.byType(TransactionFeeSelectionSheet), findsNothing); - expect(tester.takeException(), isNull); - await tester.pumpWidget(const SizedBox.shrink()); - await tester.pumpAndSettle(); - }); -} diff --git a/test/pages_desktop_specific/wallet/desktop_send_fee_form_test.dart b/test/pages_desktop_specific/wallet/desktop_send_fee_form_test.dart index be15700d8f..541bfba2fd 100644 --- a/test/pages_desktop_specific/wallet/desktop_send_fee_form_test.dart +++ b/test/pages_desktop_specific/wallet/desktop_send_fee_form_test.dart @@ -1,164 +1,97 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:stackwallet/models/isar/models/blockchain_data/address.dart'; -import 'package:stackwallet/models/isar/stack_theme.dart'; -import 'package:stackwallet/models/paymint/fee_object_model.dart'; import 'package:stackwallet/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_send_fee_form.dart'; -import 'package:stackwallet/providers/global/wallets_provider.dart'; -import 'package:stackwallet/providers/wallet/desktop_fee_providers.dart'; -import 'package:stackwallet/themes/stack_colors.dart'; +import 'package:stackwallet/providers/wallet/public_private_balance_state_provider.dart'; import 'package:stackwallet/utilities/amount/amount.dart'; -import 'package:stackwallet/utilities/amount/amount_formatter.dart'; -import 'package:stackwallet/utilities/amount/amount_unit.dart'; -import 'package:stackwallet/wallets/crypto_currency/crypto_currency.dart'; -import 'package:stackwallet/wallets/isar/models/wallet_info.dart'; -import 'package:stackwallet/wallets/isar/providers/wallet_info_provider.dart'; -import 'package:stackwallet/wallets/wallet/wallet.dart'; -import 'package:stackwallet/widgets/desktop/desktop_fee_dialog.dart'; +import 'package:stackwallet/utilities/enums/fee_rate_type_enum.dart'; +import 'package:stackwallet/widgets/fee_slider.dart'; -import '../../sample_data/theme_json.dart'; -import '../../wallets/support/xelis_test_fakes.dart'; +import '../../pages/send_view/support/fake_wallets.dart'; +import '../../pages/send_view/support/send_view_cases.dart'; +import '../../pages/send_view/support/send_view_harness.dart'; -const _walletId = "wallet id"; -final _coin = Bitcoin(CryptoCurrencyNetwork.main); -final _info = WalletInfo( - walletId: _walletId, - name: "Bitcoin wallet", - mainAddressType: AddressType.p2wpkh, - coinName: _coin.identifier, -); +Amount _amount(String amount) => + Amount.tryParseCanonicalAmount(amount, fractionDigits: 8)!; -/// Bitcoin wallet counting its fee rate fetches, the first [failingFetches] -/// of which fail. -class _FeeWallet implements Wallet { - _FeeWallet({this.failingFetches = 0}); - - final int failingFetches; - var feeFetches = 0; +Future _pickCustomFee(WidgetTester tester) async { + await desktopCoin.openFeeChoice(tester, ""); + await pick(tester, FeeRateType.custom.prettyName); +} - @override - WalletInfo get info => _info; +void main() { + setUpAll(loadButtonFont); - @override - Future get fees async { - feeFetches++; - if (feeFetches <= failingFetches) throw Exception("node unreachable"); - return FeeObject( - numberOfBlocksFast: 1, - numberOfBlocksAverage: 5, - numberOfBlocksSlow: 20, - fast: BigInt.from(20000), - medium: BigInt.from(10000), - slow: BigInt.from(5000), + Future> pumpPublicFiroForm( + WidgetTester tester, + ) async { + final wallet = FakeFiroWallet(); + await pumpSendView( + tester, + wallet: wallet, + desktop: true, + view: DesktopSendFeeForm( + walletId: wallet.walletId, + isToken: false, + amount: SendFeeAmount()..sync(_amount("1")), + onCustomFeeSliderChanged: (_) {}, + ), ); + final balance = ProviderScope.containerOf( + tester.element(find.byType(DesktopSendFeeForm)), + ).read(publicPrivateBalanceStateProvider.state); + balance.state = BalanceType.public; + await tester.pumpAndSettle(); + return balance; } - @override - Future estimateFeeFor(Amount amount, BigInt feeRate) async => - Amount(rawValue: BigInt.from(1410), fractionDigits: 8); + group("the fee shown", () { + testWithoutAnimation("is the estimated Spark fee after Custom (Firo)", ( + tester, + ) async { + final balance = await pumpPublicFiroForm(tester); + await _pickCustomFee(tester); - @override - dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); -} + balance.state = BalanceType.private; + await tester.pumpAndSettle(); -/// Pump the fee form for [wallet] and return its parent's setState. -Future _pumpForm(WidgetTester tester, _FeeWallet wallet) async { - final theme = StackTheme.fromJson(json: lightThemeJsonMap); - late StateSetter rebuild; - await tester.pumpWidget( - ProviderScope( - overrides: [ - pWallets.overrideWithValue(ConfirmationWallets(wallet)), - pWalletCoin(_walletId).overrideWithValue(_coin), - pAmountFormatter(_coin).overrideWithValue( - AmountFormatter( - unit: AmountUnit.normal, - locale: "en_US", - coin: _coin, - maxDecimals: 8, - ), - ), - ], - child: MaterialApp( - theme: ThemeData(extensions: [StackColors.fromStackColorTheme(theme)]), - home: Material( - child: StatefulBuilder( - builder: (context, setState) { + expect(find.text("Transaction fee (estimated)"), findsOneWidget); + expect(find.textContaining("0.00002000"), findsOneWidget); + expect(find.byType(FeeSlider), findsNothing); + }); + }); + + group("the fee sent", () { + testWithoutAnimation( + "starts at the Average level in a form opened over another", + (tester) async { + final wallet = FakeBitcoinWallet(); + final form = DesktopSendFeeForm( + walletId: wallet.walletId, + isToken: false, + amount: SendFeeAmount(), + onCustomFeeSliderChanged: (_) {}, + ); + var forms = [form]; + late StateSetter rebuild; + await pumpSendView( + tester, + wallet: wallet, + desktop: true, + view: StatefulBuilder( + builder: (_, setState) { rebuild = setState; - return DesktopSendFeeForm( - walletId: _walletId, - isToken: false, - onCustomFeeSliderChanged: (_) {}, - onCustomFeeOptionChanged: () {}, - ); + return Column(children: forms); }, ), - ), - ), - ), - ); - await tester.pump(); - return rebuild; -} + ); + await _pickCustomFee(tester); -void main() { - testWidgets("a rebuild keeps the fee rates and the amount", (tester) async { - final wallet = _FeeWallet(); - final rebuild = await _pumpForm(tester, wallet); - final container = ProviderScope.containerOf( - tester.element(find.byType(DesktopSendFeeForm)), - ); - final amount = Amount(rawValue: BigInt.from(22000), fractionDigits: 8); - container.read(sendAmountProvider.notifier).state = amount; - await tester.pump(); + rebuild(() => forms = [form, form]); + await tester.pumpAndSettle(); - rebuild(() {}); - await tester.pump(); - await tester.pump(); - - expect(wallet.feeFetches, 1); - expect(container.read(sendAmountProvider), amount); - }); - - testWidgets("a rebuild fetches the fee rates again after a failure", ( - tester, - ) async { - final wallet = _FeeWallet(failingFetches: 1); - final rebuild = await _pumpForm(tester, wallet); - expect(find.byType(DesktopFeeItem), findsNothing); - - rebuild(() {}); - await tester.pump(); - await tester.pump(); - - expect(wallet.feeFetches, 2); - expect(find.byType(DesktopFeeItem), findsOneWidget); - - rebuild(() {}); - await tester.pump(); - - expect(wallet.feeFetches, 2); - expect(find.byType(DesktopFeeItem), findsOneWidget); - }); - - testWidgets("the amount is kept while no fee estimate is shown", ( - tester, - ) async { - final wallet = _FeeWallet(failingFetches: 1); - final rebuild = await _pumpForm(tester, wallet); - final container = ProviderScope.containerOf( - tester.element(find.byType(DesktopSendFeeForm)), + expect(find.text("Average (~0.00002000 BTC)"), findsNWidgets(2)); + }, ); - final amount = Amount(rawValue: BigInt.from(22000), fractionDigits: 8); - container.read(sendAmountProvider.notifier).state = amount; - await tester.pump(); - - rebuild(() {}); - await tester.pump(); - await tester.pump(); - - expect(find.byType(DesktopFeeItem), findsOneWidget); - expect(container.read(sendAmountProvider), amount); }); } diff --git a/test/providers/wallet/send_fee_providers_test.dart b/test/providers/wallet/send_fee_providers_test.dart new file mode 100644 index 0000000000..ef7f085c0e --- /dev/null +++ b/test/providers/wallet/send_fee_providers_test.dart @@ -0,0 +1,476 @@ +import 'dart:async'; + +import 'package:flutter/widgets.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:stackwallet/models/balance.dart'; +import 'package:stackwallet/models/paymint/fee_object_model.dart'; +import 'package:stackwallet/providers/global/wallets_provider.dart'; +import 'package:stackwallet/providers/ui/fee_rate_type_state_provider.dart'; +import 'package:stackwallet/providers/wallet/send_fee_providers.dart'; +import 'package:stackwallet/utilities/amount/amount.dart'; +import 'package:stackwallet/utilities/enums/fee_rate_type_enum.dart'; +import 'package:stackwallet/wallets/crypto_currency/crypto_currency.dart'; +import 'package:stackwallet/wallets/isar/providers/wallet_info_provider.dart'; +import 'package:stackwallet/wallets/wallet/wallet.dart'; + +import '../../pages/send_view/support/fake_wallets.dart'; +import '../../pages/send_view/support/send_view_harness.dart'; +import '../../wallets/support/xelis_test_fakes.dart'; + +Amount _btc(int satoshis) => + Amount(rawValue: BigInt.from(satoshis), fractionDigits: 8); + +Amount _units(Wallet wallet, int raw) => Amount( + rawValue: BigInt.from(raw), + fractionDigits: wallet.cryptoCurrency.fractionDigits, +); + +final _balance = StateProvider((_) => null); + +final _tertiaryBalance = StateProvider((_) => null); + +class _FeeForm extends ConsumerStatefulWidget { + const _FeeForm({required this.walletId}); + + final String walletId; + + @override + ConsumerState<_FeeForm> createState() => _FeeFormState(); +} + +/// A send form showing its fee in raw units, as the send views show theirs. +class _FeeFormState extends ConsumerState<_FeeForm> with SendFeeForm<_FeeForm> { + @override + void initState() { + super.initState(); + fees = SendFeeRates(ref.read, walletId: widget.walletId, isToken: false); + } + + @override + Widget build(BuildContext context) { + final estimate = watchFee(); + return FutureBuilder( + // The send views show "Calculating" until each new fee answers. + key: ObjectKey(estimate), + future: estimate, + builder: (_, fee) => Text( + "${fee.data?.raw ?? "Calculating"}", + textDirection: TextDirection.ltr, + ), + ); + } +} + +void main() { + (SendFeeRates, ProviderContainer) feesOf(Wallet wallet) { + final container = ProviderContainer( + overrides: [ + pWallets.overrideWithValue(ConfirmationWallets(wallet)), + pWalletBalance.overrideWithProvider( + (_) => Provider( + (ref) => ref.watch(_balance) ?? wallet.info.cachedBalance, + ), + ), + pWalletBalanceTertiary.overrideWithProvider( + (_) => Provider( + (ref) => + ref.watch(_tertiaryBalance) ?? + wallet.info.cachedBalanceTertiary, + ), + ), + ], + ); + addTearDown(container.dispose); + return ( + SendFeeRates(container.read, walletId: wallet.walletId, isToken: false), + container, + ); + } + + Future<_FeeFormState> pumpForm(WidgetTester tester, Wallet wallet) async { + await tester.pumpWidget( + ProviderScope( + overrides: [ + pWallets.overrideWithValue(ConfirmationWallets(wallet)), + pWalletBalance(wallet.walletId) + .overrideWithValue(wallet.info.cachedBalance), + pWalletBalanceTertiary(wallet.walletId) + .overrideWithValue(wallet.info.cachedBalanceTertiary), + ], + child: _FeeForm(walletId: wallet.walletId), + ), + ); + return tester.state(find.byType(_FeeForm)); + } + + void pickLevel(_FeeFormState form, FeeRateType level) => + ProviderScope.containerOf(form.context) + .read(pFeeRateType.notifier) + .state = + level; + + // A fee choice closed at once. + void choose(_FeeFormState form) => unawaited(form.chooseFee((_, _) async {})); + + void chooseLevel(_FeeFormState form, FeeRateType level) => + unawaited(form.chooseFee((_, _) async => pickLevel(form, level))); + + FeeObject testFeesTimes({ + int fast = 1, + int medium = 1, + int slow = 1, + int blocks = 1, + }) => FeeObject( + numberOfBlocksFast: testFees.numberOfBlocksFast * blocks, + numberOfBlocksAverage: testFees.numberOfBlocksAverage * blocks, + numberOfBlocksSlow: testFees.numberOfBlocksSlow * blocks, + fast: testFees.fast * BigInt.from(fast), + medium: testFees.medium * BigInt.from(medium), + slow: testFees.slow * BigInt.from(slow), + ); + + Future showsInNextFrames(WidgetTester tester, Finder finder) async { + var isShown = false; + for (var frame = 0; frame < 30; frame++) { + await tester.pump(const Duration(milliseconds: 20)); + isShown |= finder.evaluate().isNotEmpty; + } + return isShown; + } + + group("the fee shown", () { + test("follows the private balance (Spark)", () async { + final wallet = FakeFiroWallet(); + final (fees, container) = feesOf(wallet); + Future fee() => fees.estimate( + container.read, + FeeRateType.average, + amount: _btc(100000), + ); + await fee(); + + container.read(_tertiaryBalance.notifier).state = testBalance("1", 8); + await fee(); + + expect(wallet.feeEstimates, 2); + }); + + testWithoutAnimation("stays on screen through a retry", (tester) async { + final form = await pumpForm(tester, FakeBitcoinWallet()); + form.setFeeAmount(_btc(10000000)); + await tester.pumpAndSettle(); + + form.retryFee(); + + expect( + await showsInNextFrames(tester, find.text("Calculating")), + isFalse, + ); + }); + }); + + group("the fee choice", () { + test("gives each level the confirmation time of its own block count", () { + final coin = Bitcoin(CryptoCurrencyNetwork.main); + + expect(sendFeeTime(coin, testFees, FeeRateType.fast), "~10 minutes"); + expect(sendFeeTime(coin, testFees, FeeRateType.average), "~30 minutes"); + expect(sendFeeTime(coin, testFees, FeeRateType.slow), "~1 hour"); + }); + + testWithoutAnimation("shows its fresh fee once closed", (tester) async { + final wallet = FakeBitcoinWallet(); + final form = await pumpForm(tester, wallet); + form.setFeeAmount(_btc(10000000)); + await tester.pumpAndSettle(); + wallet.feeRateFactor = 2; + + choose(form); + await tester.pumpAndSettle(); + + expect(find.text("4000"), findsOneWidget); + }); + + testWithoutAnimation("offers the fees of the amount being typed", ( + tester, + ) async { + final form = await pumpForm(tester, FakeBitcoinWallet()); + final amounts = []; + form.followFeeAmount(_btc(10000000)); + + unawaited(form.chooseFee((_, amount) async => amounts.add(amount))); + await tester.pumpAndSettle(); + + expect(amounts, [_btc(10000000)]); + }); + + testWithoutAnimation("keeps the shown fee when its fresh rates fail", ( + tester, + ) async { + final wallet = FakeBitcoinWallet(); + final form = await pumpForm(tester, wallet); + form.setFeeAmount(_btc(10000000)); + await tester.pumpAndSettle(); + wallet.failingFeeRateFetches = wallet.feeRateFetches + 1; + + choose(form); + await tester.pumpAndSettle(); + + expect(find.text("2000"), findsOneWidget); + }); + + testWithoutAnimation( + "keeps the shown fee once Custom was picked while its fresh rates " + "failed", + (tester) async { + final wallet = FakeBitcoinWallet(); + final form = await pumpForm(tester, wallet); + form.setFeeAmount(_btc(10000000)); + await tester.pumpAndSettle(); + wallet.failingFeeRateFetches = wallet.feeRateFetches + 2; + unawaited( + form.chooseFee((fees, _) async { + fees.rates.ignore(); + pickLevel(form, FeeRateType.custom); + }), + ); + await tester.pumpAndSettle(); + + chooseLevel(form, FeeRateType.average); + await tester.pumpAndSettle(); + + expect(find.text("2000"), findsOneWidget); + }, + ); + + testWithoutAnimation("shows a picked level's fee at once", (tester) async { + final form = await pumpForm(tester, FakeBitcoinWallet()); + form.setFeeAmount(_btc(10000000)); + await tester.pumpAndSettle(); + + chooseLevel(form, FeeRateType.fast); + + expect( + await showsInNextFrames(tester, find.text("Calculating")), + isFalse, + ); + }); + + testWithoutAnimation( + "leaves the rates of an amount typed while its fee was estimated", + (tester) async { + final wallet = FakeBitcoinWallet(); + final form = await pumpForm(tester, wallet); + form.setFeeAmount(_btc(10000000)); + await tester.pumpAndSettle(); + final rates = Completer(); + wallet + ..feeRateFactor = 2 + ..holdFeeRateFetches = rates; + choose(form); + await tester.pumpAndSettle(); + form.setFeeAmount(_btc(20000000)); + await tester.pumpAndSettle(); + + rates.complete(); + await tester.pumpAndSettle(); + + expect(find.text("2000"), findsOneWidget); + }, + ); + + testWithoutAnimation("wins over an earlier one still estimated", ( + tester, + ) async { + final wallet = FakeBitcoinWallet(); + final form = await pumpForm(tester, wallet); + form.setFeeAmount(_btc(10000000)); + await tester.pumpAndSettle(); + final rates = Completer(); + wallet + ..feeRateFactor = 2 + ..holdFeeRateFetches = rates; + choose(form); + await tester.pumpAndSettle(); + wallet + ..feeRateFactor = 3 + ..holdFeeRateFetches = null; + choose(form); + await tester.pumpAndSettle(); + + rates.complete(); + await tester.pumpAndSettle(); + + expect(find.text("6000"), findsOneWidget); + }); + + for (final (level, rates, fee) in [ + (FeeRateType.fast, testFeesTimes(fast: 2), "6000"), + (FeeRateType.average, testFeesTimes(medium: 2), "4000"), + (FeeRateType.slow, testFeesTimes(slow: 2), "2000"), + ]) { + testWithoutAnimation("shows the new fee once closed when only the " + "${level.prettyName} rate changed", (tester) async { + final wallet = FakeBitcoinWallet(); + final form = await pumpForm(tester, wallet); + form.setFeeAmount(_btc(10000000)); + await tester.pumpAndSettle(); + chooseLevel(form, level); + await tester.pumpAndSettle(); + wallet.feeRates = rates; + + choose(form); + await tester.pumpAndSettle(); + + expect(find.text(fee), findsOneWidget); + }); + } + + testWithoutAnimation( + "keeps the shown fee at the same rates with new confirmation " + "times", + (tester) async { + final wallet = FakeBitcoinWallet(); + final form = await pumpForm(tester, wallet); + form.setFeeAmount(_btc(10000000)); + await tester.pumpAndSettle(); + wallet.feeRates = testFeesTimes(blocks: 2); + + choose(form); + + expect( + await showsInNextFrames(tester, find.text("Calculating")), + isFalse, + ); + }, + ); + }); + + group("a failed fee", () { + test("is logged once when its rates fail", () async { + final wallet = FakeBitcoinWallet()..failingFeeRateFetches = 1; + final (fees, container) = feesOf(wallet); + + final logged = await logs( + () => Future.wait([ + fees.estimate(container.read, FeeRateType.fast), + fees.estimate(container.read, FeeRateType.average), + fees.estimate(container.read, FeeRateType.slow), + ]).then((_) {}, onError: (Object _) {}), + ); + + expect("Failed to calculate".allMatches(logged.join()), hasLength(1)); + }); + + testWithoutAnimation("shows once its rates answer, even a minute later", ( + tester, + ) async { + final rates = Completer(); + final form = await pumpForm( + tester, + FakeBitcoinWallet()..holdFeeRateFetches = rates, + ); + form.setFeeAmount(_btc(10000000)); + await tester.pump(const Duration(minutes: 1)); + + rates.complete(); + await tester.pumpAndSettle(); + + expect(find.text("2000"), findsOneWidget); + }); + }); + + group("fee estimates", () { + test("reuse the fee of an unchanged rate once refreshed", () async { + final wallet = FakeBitcoinWallet(); + final (fees, container) = feesOf(wallet); + await fees.estimate(container.read, FeeRateType.average); + + await fees.refreshed().estimate(container.read, FeeRateType.average); + + expect(wallet.feeEstimates, 1); + }); + + test("reuse the Spark fee at another level once refreshed", () async { + final wallet = FakeFiroWallet(); + final (fees, container) = feesOf(wallet); + await fees.estimate( + container.read, + FeeRateType.average, + amount: _btc(100000), + ); + + await fees.refreshed().estimate( + container.read, + FeeRateType.fast, + amount: _btc(100000), + ); + + expect(wallet.feeEstimates, 1); + }); + + test("are asked by the fee choice once the wallet has synchronized " + "(Monero)", () async { + final wallet = FakeMoneroWallet()..isSynced = false; + final (fees, container) = feesOf(wallet); + container.read(_balance.notifier).state = testBalance("10", 12); + unawaited( + fees.estimate( + container.read, + FeeRateType.average, + amount: _units(wallet, 1), + ), + ); + await pumpEventQueue(); + wallet.isSynced = true; + + final fee = await fees.refreshed().estimate( + container.read, + FeeRateType.average, + amount: _units(wallet, 1), + ); + + expect(fee, _units(wallet, 20000000)); + }); + + for (final (coin, newWallet) in <(String, Wallet Function())>[ + ("Ethereum", FakeEthereumWallet.new), + ("Solana", FakeSolanaWallet.new), + ("Stellar", FakeStellarWallet.new), + ("Cardano", FakeCardanoWallet.new), + ]) { + test("are asked once for every $coin amount", () async { + final wallet = newWallet(); + final (fees, container) = feesOf(wallet); + await fees.estimate( + container.read, + FeeRateType.average, + amount: _units(wallet, 1), + ); + + await fees.estimate( + container.read, + FeeRateType.average, + amount: _units(wallet, 2), + ); + + expect((wallet as FakeWallet).feeEstimates, 1); + }); + } + + test("are asked again only once funds arrive when the fee ignores the " + "amount (Cardano)", () async { + final wallet = FakeCardanoWallet(); + final (fees, container) = feesOf(wallet); + await fees.estimate(container.read, FeeRateType.average); + container.read(_balance.notifier).state = testBalance("10", 6); + await fees.estimate(container.read, FeeRateType.average); + + container.read(_balance.notifier).state = testBalance("20", 6); + await fees.estimate(container.read, FeeRateType.average); + + expect(wallet.feeEstimates, 2); + }); + }); +} diff --git a/test/widgets/custom_buttons/blue_text_button_test.dart b/test/widgets/custom_buttons/blue_text_button_test.dart new file mode 100644 index 0000000000..82a1ea4e28 --- /dev/null +++ b/test/widgets/custom_buttons/blue_text_button_test.dart @@ -0,0 +1,73 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:stackwallet/themes/stack_colors.dart'; +import 'package:stackwallet/widgets/custom_buttons/blue_text_button.dart'; + +import '../../pages/send_view/support/fake_wallets.dart'; +import '../../pages/send_view/support/send_view_harness.dart'; + +void main() { + testWithoutAnimation("a button takes the disabled color once disabled", ( + tester, + ) async { + var enabled = true; + late StateSetter rebuild; + await pumpSendView( + tester, + wallet: FakeBitcoinWallet(), + desktop: true, + view: StatefulBuilder( + builder: (_, setState) { + rebuild = setState; + return CustomTextButton(text: "Edit", enabled: enabled); + }, + ), + ); + + rebuild(() => enabled = false); + await tester.pump(); + + expect( + tester.widget(find.byType(RichText)).text.style!.color, + Theme.of(tester.element(find.byType(CustomTextButton))) + .extension()! + .customTextButtonDisabledText, + ); + }); + + testWithoutAnimation("a button takes the color of a new theme", ( + tester, + ) async { + var color = const Color(0xFF112233); + late StateSetter rebuild; + await pumpSendView( + tester, + wallet: FakeBitcoinWallet(), + desktop: true, + view: StatefulBuilder( + builder: (context, setState) { + rebuild = setState; + final theme = Theme.of(context); + return Theme( + data: theme.copyWith( + extensions: [ + theme.extension()!.copyWith( + customTextButtonEnabledText: color, + ), + ], + ), + child: const CustomTextButton(text: "Edit"), + ); + }, + ), + ); + + rebuild(() => color = const Color(0xFF445566)); + await tester.pump(); + + expect( + tester.widget(find.byType(RichText)).text.style!.color, + const Color(0xFF445566), + ); + }); +}