feat: WIP desktop custom fee selection for supported coins

This commit is contained in:
julian 2023-06-19 10:04:25 -06:00
parent 04e0446aaf
commit fae0c778ef
3 changed files with 629 additions and 82 deletions

View file

@ -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<StackColors>()!
.textFieldDefaultBG,
child: Text(
"~${(transactionInfo["fee"] / transactionInfo["vSize"]).toInt()}",
style: STextStyles.itemSubtitle(context),
),
),
),
if (!isDesktop) const Spacer(),
SizedBox(
height: isDesktop ? 23 : 12,

View file

@ -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<DesktopSend> {
bool get isPaynymSend => widget.accountLite != null;
bool isCustomFee = false;
int customFeeRate = 1;
(FeeRateType, String?, String?)? feeSelectionResult;
final stringsToLoopThrough = [
"Calculating",
"Calculating.",
"Calculating..",
"Calculating...",
];
Future<void> previewSend() async {
final manager =
ref.read(walletsChangeNotifierProvider).getManager(walletId);
@ -283,6 +299,7 @@ class _DesktopSendState extends ConsumerState<DesktopSend> {
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<DesktopSend> {
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<DesktopSend> {
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<DesktopSend> {
);
} 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<DesktopSend> {
}
},
),
// const SizedBox(
// height: 20,
// ),
// Text(
// "Note (optional)",
// style: STextStyles.desktopTextExtraSmall(context).copyWith(
// color: Theme.of(context)
// .extension<StackColors>()!
// .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<StackColors>()!
// .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<StackColors>()!
.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<StackColors>()!
.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<StackColors>()!
.textFieldActiveText,
),
),
],
);
}
},
)
: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
feeSelectionResult?.$2 ?? "",
style: STextStyles.desktopTextExtraExtraSmall(context)
.copyWith(
color: Theme.of(context)
.extension<StackColors>()!
.textFieldActiveText,
),
textAlign: TextAlign.left,
),
Text(
feeSelectionResult?.$3 ?? "",
style: STextStyles.desktopTextExtraExtraSmall(context)
.copyWith(
color: Theme.of(context)
.extension<StackColors>()!
.textFieldActiveSearchIconRight,
),
),
],
),
if (isCustomFee)
Padding(
padding: const EdgeInsets.only(
bottom: 12,
top: 16,
),
child: FeeSlider(
onSatVByteChanged: (rate) {
customFeeRate = rate;
},
),
),
const SizedBox(
height: 36,

View file

@ -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<DesktopFeeDialog> createState() => _DesktopFeeDialogState();
}
class _DesktopFeeDialogState extends ConsumerState<DesktopFeeDialog> {
late final String walletId;
FeeObject? feeObject;
FeeRateType feeRateType = FeeRateType.average;
Future<Amount> 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<Amount> Function({
required Amount amount,
required FeeRateType feeRateType,
required int feeRate,
required Coin coin,
}) feeFor;
final bool isSelected;
@override
ConsumerState<DesktopFeeItem> createState() => _DesktopFeeItemState();
}
class _DesktopFeeItemState extends ConsumerState<DesktopFeeItem> {
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<StackColors>()!
.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<StackColors>()!
.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<Amount> 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<StackColors>()!
.textFieldActiveText,
),
textAlign: TextAlign.left,
),
if (widget.feeObject != null)
Text(
timeString!,
style: STextStyles.desktopTextExtraExtraSmall(context)
.copyWith(
color: Theme.of(context)
.extension<StackColors>()!
.textFieldActiveSearchIconRight,
),
),
],
);
} else {
return AnimatedText(
stringsToLoopThrough: stringsToLoopThrough,
style: STextStyles.desktopTextExtraExtraSmall(context)
.copyWith(
color: Theme.of(context)
.extension<StackColors>()!
.textFieldActiveText,
),
);
}
},
);
}
},
),
);
}
}