diff --git a/lib/pages/send_view/confirm_transaction_view.dart b/lib/pages/send_view/confirm_transaction_view.dart index 7babc0fb6..e50d15193 100644 --- a/lib/pages/send_view/confirm_transaction_view.dart +++ b/lib/pages/send_view/confirm_transaction_view.dart @@ -931,6 +931,43 @@ class _ConfirmTransactionViewState ), ), ), + if (isDesktop && + !widget.isPaynymTransaction && + transactionInfo["fee"] is int && + transactionInfo["vSize"] is int) + Padding( + padding: const EdgeInsets.only( + left: 32, + ), + child: Text( + "sats/vByte", + style: STextStyles.desktopTextExtraExtraSmall(context), + ), + ), + if (isDesktop && + !widget.isPaynymTransaction && + transactionInfo["fee"] is int && + transactionInfo["vSize"] is int) + Padding( + padding: const EdgeInsets.only( + top: 10, + left: 32, + right: 32, + ), + child: RoundedContainer( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 18, + ), + color: Theme.of(context) + .extension()! + .textFieldDefaultBG, + child: Text( + "~${(transactionInfo["fee"] / transactionInfo["vSize"]).toInt()}", + style: STextStyles.itemSubtitle(context), + ), + ), + ), if (!isDesktop) const Spacer(), SizedBox( height: isDesktop ? 23 : 12, 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 a63d36689..748166169 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 @@ -12,6 +12,7 @@ import 'dart:async'; import 'dart:math'; import 'package:bip47/bip47.dart'; +import 'package:cw_core/monero_transaction_priority.dart'; import 'package:decimal/decimal.dart'; import 'package:dropdown_button2/dropdown_button2.dart'; import 'package:flutter/material.dart'; @@ -45,16 +46,20 @@ import 'package:stackwallet/utilities/barcode_scanner_interface.dart'; import 'package:stackwallet/utilities/clipboard_interface.dart'; import 'package:stackwallet/utilities/constants.dart'; import 'package:stackwallet/utilities/enums/coin_enum.dart'; +import 'package:stackwallet/utilities/enums/fee_rate_type_enum.dart'; import 'package:stackwallet/utilities/logger.dart'; import 'package:stackwallet/utilities/prefs.dart'; import 'package:stackwallet/utilities/text_styles.dart'; import 'package:stackwallet/utilities/util.dart'; import 'package:stackwallet/widgets/animated_text.dart'; +import 'package:stackwallet/widgets/conditional_parent.dart'; import 'package:stackwallet/widgets/custom_buttons/blue_text_button.dart'; import 'package:stackwallet/widgets/desktop/desktop_dialog.dart'; import 'package:stackwallet/widgets/desktop/desktop_dialog_close_button.dart'; +import 'package:stackwallet/widgets/desktop/desktop_fee_dialog.dart'; import 'package:stackwallet/widgets/desktop/primary_button.dart'; import 'package:stackwallet/widgets/desktop/secondary_button.dart'; +import 'package:stackwallet/widgets/fee_slider.dart'; import 'package:stackwallet/widgets/icon_widgets/addressbook_icon.dart'; import 'package:stackwallet/widgets/icon_widgets/clipboard_icon.dart'; import 'package:stackwallet/widgets/icon_widgets/x_icon.dart'; @@ -115,6 +120,17 @@ class _DesktopSendState extends ConsumerState { bool get isPaynymSend => widget.accountLite != null; + bool isCustomFee = false; + int customFeeRate = 1; + (FeeRateType, String?, String?)? feeSelectionResult; + + final stringsToLoopThrough = [ + "Calculating", + "Calculating.", + "Calculating..", + "Calculating...", + ]; + Future previewSend() async { final manager = ref.read(walletsChangeNotifierProvider).getManager(walletId); @@ -283,6 +299,7 @@ class _DesktopSendState extends ConsumerState { isSegwit: widget.accountLite!.segwit, amount: amount, args: { + "satsPerVByte": isCustomFee ? customFeeRate : null, "feeRate": feeRate, "UTXOs": (manager.hasCoinControlSupport && coinControlEnabled && @@ -299,6 +316,7 @@ class _DesktopSendState extends ConsumerState { amount: amount, args: { "feeRate": ref.read(feeRateTypeStateProvider), + "satsPerVByte": isCustomFee ? customFeeRate : null, "UTXOs": (manager.hasCoinControlSupport && coinControlEnabled && ref.read(desktopUseUTXOs).isNotEmpty) @@ -312,6 +330,7 @@ class _DesktopSendState extends ConsumerState { amount: amount, args: { "feeRate": ref.read(feeRateTypeStateProvider), + "satsPerVByte": isCustomFee ? customFeeRate : null, "UTXOs": (manager.hasCoinControlSupport && coinControlEnabled && ref.read(desktopUseUTXOs).isNotEmpty) @@ -561,12 +580,7 @@ class _DesktopSendState extends ConsumerState { ); } else { return AnimatedText( - stringsToLoopThrough: const [ - "Loading balance", - "Loading balance.", - "Loading balance..", - "Loading balance...", - ], + stringsToLoopThrough: stringsToLoopThrough, style: STextStyles.itemSubtitle(context), ); } @@ -1359,94 +1373,178 @@ class _DesktopSendState extends ConsumerState { } }, ), - // const SizedBox( - // height: 20, - // ), - // Text( - // "Note (optional)", - // style: STextStyles.desktopTextExtraSmall(context).copyWith( - // color: Theme.of(context) - // .extension()! - // .textFieldActiveSearchIconRight, - // ), - // textAlign: TextAlign.left, - // ), - // const SizedBox( - // height: 10, - // ), - // ClipRRect( - // borderRadius: BorderRadius.circular( - // Constants.size.circularBorderRadius, - // ), - // child: TextField( - // minLines: 1, - // maxLines: 5, - // autocorrect: Util.isDesktop ? false : true, - // enableSuggestions: Util.isDesktop ? false : true, - // controller: noteController, - // focusNode: _noteFocusNode, - // style: STextStyles.desktopTextExtraSmall(context).copyWith( - // color: Theme.of(context) - // .extension()! - // .textFieldActiveText, - // height: 1.8, - // ), - // onChanged: (_) => setState(() {}), - // decoration: standardInputDecoration( - // "Type something...", - // _noteFocusNode, - // context, - // desktopMed: true, - // ).copyWith( - // contentPadding: const EdgeInsets.only( - // left: 16, - // top: 11, - // bottom: 12, - // right: 5, - // ), - // suffixIcon: noteController.text.isNotEmpty - // ? Padding( - // padding: const EdgeInsets.only(right: 0), - // child: UnconstrainedBox( - // child: Row( - // children: [ - // TextFieldIconButton( - // child: const XIcon(), - // onTap: () async { - // setState(() { - // noteController.text = ""; - // }); - // }, - // ), - // ], - // ), - // ), - // ) - // : null, - // ), - // ), - // ), if (!isPaynymSend) const SizedBox( height: 20, ), if (!([Coin.nano, Coin.banano, Coin.epicCash].contains(coin))) - Text( - "Transaction fee (${coin == Coin.ethereum ? "max" : "estimated"})", - style: STextStyles.desktopTextExtraSmall(context).copyWith( - color: Theme.of(context) - .extension()! - .textFieldActiveSearchIconRight, + ConditionalParent( + condition: coin.isElectrumXCoin, + builder: (child) => Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + child, + CustomTextButton( + text: "Edit", + onTap: () async { + feeSelectionResult = await showDialog< + ( + FeeRateType, + String?, + String?, + )?>( + context: context, + builder: (_) => DesktopFeeDialog( + walletId: walletId, + ), + ); + + if (feeSelectionResult != null) { + if (isCustomFee && + feeSelectionResult!.$1 != FeeRateType.custom) { + isCustomFee = false; + } else if (!isCustomFee && + feeSelectionResult!.$1 == FeeRateType.custom) { + isCustomFee = true; + } + } + + setState(() {}); + }, + ), + ], + ), + child: Text( + "Transaction fee" + "${isCustomFee ? "" : " (${coin == Coin.ethereum ? "max" : "estimated"})"}", + style: STextStyles.desktopTextExtraSmall(context).copyWith( + color: Theme.of(context) + .extension()! + .textFieldActiveSearchIconRight, + ), + textAlign: TextAlign.left, ), - textAlign: TextAlign.left, ), if (!([Coin.nano, Coin.banano, Coin.epicCash].contains(coin))) const SizedBox( height: 10, ), if (!([Coin.nano, Coin.banano, Coin.epicCash].contains(coin))) - DesktopFeeDropDown( - walletId: walletId, + if (!isCustomFee) + (feeSelectionResult?.$2 == null) + ? FutureBuilder( + future: ref.watch( + walletsChangeNotifierProvider.select( + (value) => value.getManager(walletId).fees, + ), + ), + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.done && + snapshot.hasData) { + return DesktopFeeItem( + feeObject: snapshot.data, + feeRateType: FeeRateType.average, + walletId: walletId, + feeFor: ({ + required Amount amount, + required FeeRateType feeRateType, + required int feeRate, + required Coin coin, + }) async { + if (ref + .read(feeSheetSessionCacheProvider) + .average[amount] == + null) { + final manager = ref + .read(walletsChangeNotifierProvider) + .getManager(walletId); + + if (coin == Coin.monero || coin == Coin.wownero) { + final fee = await manager.estimateFeeFor(amount, + MoneroTransactionPriority.regular.raw!); + ref + .read(feeSheetSessionCacheProvider) + .average[amount] = fee; + } else if ((coin == Coin.firo || + coin == Coin.firoTestNet) && + ref + .read( + publicPrivateBalanceStateProvider + .state) + .state != + "Private") { + ref + .read(feeSheetSessionCacheProvider) + .average[amount] = + await (manager.wallet as FiroWallet) + .estimateFeeForPublic(amount, feeRate); + } else { + ref + .read(feeSheetSessionCacheProvider) + .average[amount] = + await manager.estimateFeeFor( + amount, feeRate); + } + } + return ref + .read(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, + ), + ), + ], + ), + if (isCustomFee) + Padding( + padding: const EdgeInsets.only( + bottom: 12, + top: 16, + ), + child: FeeSlider( + onSatVByteChanged: (rate) { + customFeeRate = rate; + }, + ), ), const SizedBox( height: 36, diff --git a/lib/widgets/desktop/desktop_fee_dialog.dart b/lib/widgets/desktop/desktop_fee_dialog.dart new file mode 100644 index 000000000..f2c8c9fba --- /dev/null +++ b/lib/widgets/desktop/desktop_fee_dialog.dart @@ -0,0 +1,412 @@ +import 'package:cw_core/monero_transaction_priority.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:stackwallet/models/models.dart'; +import 'package:stackwallet/pages/send_view/sub_widgets/transaction_fee_selection_sheet.dart'; +import 'package:stackwallet/pages/token_view/token_view.dart'; +import 'package:stackwallet/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_fee_dropdown.dart'; +import 'package:stackwallet/providers/global/wallets_provider.dart'; +import 'package:stackwallet/providers/wallet/public_private_balance_state_provider.dart'; +import 'package:stackwallet/services/coins/firo/firo_wallet.dart'; +import 'package:stackwallet/themes/stack_colors.dart'; +import 'package:stackwallet/utilities/amount/amount.dart'; +import 'package:stackwallet/utilities/amount/amount_formatter.dart'; +import 'package:stackwallet/utilities/constants.dart'; +import 'package:stackwallet/utilities/enums/coin_enum.dart'; +import 'package:stackwallet/utilities/enums/fee_rate_type_enum.dart'; +import 'package:stackwallet/utilities/text_styles.dart'; +import 'package:stackwallet/widgets/animated_text.dart'; +import 'package:stackwallet/widgets/desktop/desktop_dialog.dart'; +import 'package:stackwallet/widgets/desktop/desktop_dialog_close_button.dart'; + +class DesktopFeeDialog extends ConsumerStatefulWidget { + const DesktopFeeDialog({ + Key? key, + required this.walletId, + this.isToken = false, + }) : super(key: key); + + 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 int feeRate, + required Coin coin, + }) async { + switch (feeRateType) { + case FeeRateType.fast: + if (ref + .read(widget.isToken + ? tokenFeeSessionCacheProvider + : feeSheetSessionCacheProvider) + .fast[amount] == + null) { + if (widget.isToken == false) { + final manager = + ref.read(walletsChangeNotifierProvider).getManager(walletId); + + if (coin == Coin.monero || coin == Coin.wownero) { + final fee = await manager.estimateFeeFor( + amount, MoneroTransactionPriority.fast.raw!); + ref.read(feeSheetSessionCacheProvider).fast[amount] = fee; + } else if ((coin == Coin.firo || coin == Coin.firoTestNet) && + ref.read(publicPrivateBalanceStateProvider.state).state != + "Private") { + ref.read(feeSheetSessionCacheProvider).fast[amount] = + await (manager.wallet as FiroWallet) + .estimateFeeForPublic(amount, feeRate); + } else { + ref.read(feeSheetSessionCacheProvider).fast[amount] = + await manager.estimateFeeFor(amount, feeRate); + } + } else { + final tokenWallet = ref.read(tokenServiceProvider)!; + final fee = tokenWallet.estimateFeeFor(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 manager = + ref.read(walletsChangeNotifierProvider).getManager(walletId); + + if (coin == Coin.monero || coin == Coin.wownero) { + final fee = await manager.estimateFeeFor( + amount, MoneroTransactionPriority.regular.raw!); + ref.read(feeSheetSessionCacheProvider).average[amount] = fee; + } else if ((coin == Coin.firo || coin == Coin.firoTestNet) && + ref.read(publicPrivateBalanceStateProvider.state).state != + "Private") { + ref.read(feeSheetSessionCacheProvider).average[amount] = + await (manager.wallet as FiroWallet) + .estimateFeeForPublic(amount, feeRate); + } else { + ref.read(feeSheetSessionCacheProvider).average[amount] = + await manager.estimateFeeFor(amount, feeRate); + } + } else { + final tokenWallet = ref.read(tokenServiceProvider)!; + final fee = tokenWallet.estimateFeeFor(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 manager = + ref.read(walletsChangeNotifierProvider).getManager(walletId); + + if (coin == Coin.monero || coin == Coin.wownero) { + final fee = await manager.estimateFeeFor( + amount, MoneroTransactionPriority.slow.raw!); + ref.read(feeSheetSessionCacheProvider).slow[amount] = fee; + } else if ((coin == Coin.firo || coin == Coin.firoTestNet) && + ref.read(publicPrivateBalanceStateProvider.state).state != + "Private") { + ref.read(feeSheetSessionCacheProvider).slow[amount] = + await (manager.wallet as FiroWallet) + .estimateFeeForPublic(amount, feeRate); + } else { + ref.read(feeSheetSessionCacheProvider).slow[amount] = + await manager.estimateFeeFor(amount, feeRate); + } + } else { + final tokenWallet = ref.read(tokenServiceProvider)!; + final fee = tokenWallet.estimateFeeFor(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(); + } + + @override + Widget build(BuildContext context) { + return DesktopDialog( + maxWidth: 450, + maxHeight: double.infinity, + child: FutureBuilder( + future: ref.watch( + walletsChangeNotifierProvider.select( + (value) => value.getManager(walletId).fees, + ), + ), + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.done && + snapshot.hasData) { + feeObject = snapshot.data!; + } + + return Column( + 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, + ), + ), + ), + const SizedBox( + height: 16, + ), + ], + ); + }, + ), + ); + } +} + +class DesktopFeeItem extends ConsumerStatefulWidget { + const DesktopFeeItem({ + Key? key, + required this.feeObject, + required this.feeRateType, + required this.walletId, + required this.feeFor, + required this.isSelected, + }) : super(key: key); + + final FeeObject? feeObject; + final FeeRateType feeRateType; + final String walletId; + final Future Function({ + required Amount amount, + required FeeRateType feeRateType, + required int feeRate, + required Coin coin, + }) feeFor; + final bool isSelected; + + @override + ConsumerState createState() => _DesktopFeeItemState(); +} + +class _DesktopFeeItemState extends ConsumerState { + String? feeString; + String? timeString; + + static const stringsToLoopThrough = [ + "Calculating", + "Calculating.", + "Calculating..", + "Calculating...", + ]; + + String estimatedTimeToBeIncludedInNextBlock( + int targetBlockTime, int estimatedNumberOfBlocks) { + int time = targetBlockTime * estimatedNumberOfBlocks; + + 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}"); + + return MaterialButton( + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + onPressed: () { + Navigator.of(context).pop( + ( + widget.feeRateType, + feeString, + timeString, + ), + ); + }, + child: Builder( + builder: (_) { + if (widget.feeRateType == FeeRateType.custom) { + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + widget.feeRateType.prettyName, + style: + STextStyles.desktopTextExtraExtraSmall(context).copyWith( + color: Theme.of(context) + .extension()! + .textFieldActiveText, + ), + textAlign: TextAlign.left, + ), + ], + ); + } + + final manager = ref.watch(walletsChangeNotifierProvider + .select((value) => value.getManager(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: manager.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(manager.coin)).format( + snapshot.data!, + indicatePrecisionLoss: false, + )})"; + + timeString = manager.coin == Coin.ethereum + ? "" + : estimatedTimeToBeIncludedInNextBlock( + Constants.targetBlockTimeInSeconds(manager.coin), + 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, + ), + 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, + ), + ); + } + }, + ); + } + }, + ), + ); + } +}