Custom Fee Rate input
MODIFY
WalletWasabi.Fluent/Controls/CurrencyEntryBox.axaml
WalletWasabi.Fluent/Controls/CurrencyEntryBox.axaml
<Setter Property="Template">
<ControlTemplate>
<DockPanel>
+ <DataValidationErrors DockPanel.Dock="Bottom" x:Name="PART_DataValidationErrors" />
<!-- TODO bind Content -> Header and ContentTemplate -> HeaderTemplate -->
<ContentPresenter x:Name="PART_HeaderContentPresenter"
CaretBrush="{TemplateBinding CaretBrush}"
HorizontalAlignment="{TemplateBinding HorizontalContentAlignment}"
VerticalAlignment="{TemplateBinding VerticalContentAlignment}" />
-
</DockPanel>
</Panel>
</DockPanel>
<Setter Property="Margin" Value="0 0 63 0" />
</Style>
+ <!-- Standalone style: pointerover border -->
+ <Style Selector="c|CurrencyEntryBox.standalone:pointerover /template/ Border#PART_BorderElement">
+ <Setter Property="BorderBrush" Value="{DynamicResource TextControlBorderBrushPointerOver}" />
+ <Setter Property="BorderThickness" Value="{DynamicResource TextControlBorderThemeThicknessFocused}" />
+ </Style>
+
+ <!-- Standalone style: error border -->
+ <Style Selector="c|CurrencyEntryBox.standalone:error /template/ Border#PART_BorderElement">
+ <Setter Property="BorderBrush" Value="{DynamicResource SystemControlErrorTextForegroundBrush}" />
+ </Style>
+
+ <!-- Standalone style: focus border -->
+ <Style Selector="c|CurrencyEntryBox.standalone:focus-within /template/ Border#PART_BorderElement">
+ <Setter Property="Background" Value="{DynamicResource TextControlBackgroundFocused}" />
+ <Setter Property="BorderBrush" Value="{DynamicResource TextControlBorderBrushFocused}" />
+ <Setter Property="BorderThickness" Value="{DynamicResource TextControlBorderThemeThicknessFocused}" />
+ </Style>
+
+ <!-- Hide DataValidationErrors by default -->
+ <Style Selector="c|CurrencyEntryBox /template/ DataValidationErrors#PART_DataValidationErrors">
+ <Setter Property="IsVisible" Value="False" />
+ </Style>
+
+ <!-- Show DataValidationErrors for standalone -->
+ <Style Selector="c|CurrencyEntryBox.standalone /template/ DataValidationErrors#PART_DataValidationErrors">
+ <Setter Property="IsVisible" Value="True" />
+ </Style>
</Styles>

MODIFY
WalletWasabi.Fluent/ViewModels/Wallets/Send/SendFeeViewModel.cs
WalletWasabi.Fluent/ViewModels/Wallets/Send/SendFeeViewModel.cs
-using System.Collections.Generic;
+using System.Globalization;
using System.Linq;
+using System.Reactive;
using System.Reactive.Concurrency;
using System.Reactive.Disposables;
using System.Reactive.Linq;
@@ -8,7 +9,9 @@ using NBitcoin;
using ReactiveUI;
using WalletWasabi.Blockchain.Analysis.FeesEstimation;
using WalletWasabi.Fluent.Helpers;
+using WalletWasabi.Fluent.Validation;
using WalletWasabi.Fluent.ViewModels.Dialogs.Base;
+using WalletWasabi.Models;
using WalletWasabi.Wallets;
namespace WalletWasabi.Fluent.ViewModels.Wallets.Send;
@@ -25,6 +28,9 @@ public partial class SendFeeViewModel : DialogViewModelBase<FeeRate>
private readonly Wallet _wallet;
private readonly TransactionInfo _transactionInfo;
private readonly bool _isSilent;
+ private bool _isSliderChange;
+
+ [AutoNotify] private string _feeRateString;
public SendFeeViewModel(Wallet wallet, TransactionInfo transactionInfo, bool isSilent)
{
@@ -33,28 +39,118 @@ public partial class SendFeeViewModel : DialogViewModelBase<FeeRate>
_wallet = wallet;
_transactionInfo = transactionInfo;
+ _feeRateString =
+ transactionInfo.IsCustomFeeUsed
+ ? transactionInfo.FeeRate.SatoshiPerByte.ToString(CultureInfo.InvariantCulture)
+ : "";
+
FeeChart = new FeeChartViewModel();
+ FeeChart.WhenAnyValue(x => x.CurrentSatoshiPerByte)
+ .Subscribe(OnSliderValueChanged);
+
+ this.WhenAnyValue(x => x.FeeRateString)
+ .Subscribe(OnFeeRateStringChanged);
+
+ this.ValidateProperty(x => x.FeeRateString, ValidateCustomFee);
+
SetupCancel(false, true, false);
EnableBack = true;
- NextCommand = ReactiveCommand.Create(() =>
+ var nextCommandCanExecute =
+ this.WhenAnyValue(x => x.FeeRateString)
+ .Select(_ =>
{
- _transactionInfo.ConfirmationTimeSpan = TransactionFeeHelper.CalculateConfirmationTime(FeeChart.CurrentConfirmationTarget);
+ var noError = !Validations.Any;
+ var somethingFilled = FeeRateString is not null or "";
- Complete();
+ return noError && somethingFilled;
});
+
+ NextCommand = ReactiveCommand.Create(OnNext, nextCommandCanExecute);
+ }
+
+ private void OnSliderValueChanged(decimal x)
+ {
+ _isSliderChange = true;
+
+ _transactionInfo.IsCustomFeeUsed = false;
+ _transactionInfo.FeeRate = FeeRate.Zero;
+ FeeRateString = x.ToString("0");
+ _isSliderChange = false;
}
public FeeChartViewModel FeeChart { get; }
- private void Complete()
+ private void OnFeeRateStringChanged(string feeRateString)
+ {
+ if (_isSliderChange)
+ {
+ return;
+ }
+
+ if (decimal.TryParse(feeRateString, NumberStyles.AllowDecimalPoint, CultureInfo.InvariantCulture, out var feeRate))
+ {
+ _transactionInfo.FeeRate = new FeeRate(feeRate);
+ _transactionInfo.IsCustomFeeUsed = true;
+ FeeChart.InitCurrentConfirmationTarget(_transactionInfo.FeeRate);
+ }
+ else if (_transactionInfo.IsCustomFeeUsed)
+ {
+ _transactionInfo.FeeRate = FeeRate.Zero;
+ _transactionInfo.IsCustomFeeUsed = false;
+ }
+ }
+
+ private void ValidateCustomFee(IValidationErrors errors)
+ {
+ var customFeeString = FeeRateString;
+
+ if (customFeeString is null or "")
+ {
+ return;
+ }
+
+ if (!decimal.TryParse(customFeeString, NumberStyles.AllowDecimalPoint, CultureInfo.InvariantCulture, out var value))
+ {
+ errors.Add(ErrorSeverity.Error, "The entered fee is not valid.");
+ return;
+ }
+
+ if (value < decimal.One)
+ {
+ errors.Add(ErrorSeverity.Error, "Cannot be less than 1 sat/vByte.");
+ return;
+ }
+
+ try
+ {
+ _ = new FeeRate(value);
+ }
+ catch (OverflowException)
{
+ errors.Add(ErrorSeverity.Error, "The entered fee is too high.");
+ return;
+ }
+ }
+
+ private void OnNext()
+ {
+ if (_transactionInfo.IsCustomFeeUsed)
+ {
+ var feeRate = decimal.Parse(FeeRateString, NumberStyles.AllowDecimalPoint, CultureInfo.InvariantCulture);
+ Close(DialogResultKind.Normal, new FeeRate(feeRate));
+ }
+ else
+ {
+ _transactionInfo.ConfirmationTimeSpan = TransactionFeeHelper.CalculateConfirmationTime(FeeChart.CurrentConfirmationTarget);
+
var blockTarget = FeeChart.CurrentConfirmationTarget;
Services.UiConfig.FeeTarget = (int)blockTarget;
Close(DialogResultKind.Normal, new FeeRate(FeeChart.GetSatoshiPerByte(blockTarget)));
}
+ }
protected override void OnNavigatedTo(bool isInHistory, CompositeDisposable disposables)
{
@@ -89,7 +185,7 @@ public partial class SendFeeViewModel : DialogViewModelBase<FeeRate>
{
_transactionInfo.ConfirmationTimeSpan = TransactionFeeHelper.CalculateConfirmationTime(FeeChart.CurrentConfirmationTarget);
- Complete();
+ OnNext();
}
else
{

MODIFY
WalletWasabi.Fluent/ViewModels/Wallets/Send/TransactionPreviewViewModel.cs
WalletWasabi.Fluent/ViewModels/Wallets/Send/TransactionPreviewViewModel.cs
@@ -117,17 +117,7 @@ public partial class TransactionPreviewViewModel : RoutableViewModel
_nextButtonText = "Confirm";
}
- AdjustFeeCommand = ReactiveCommand.CreateFromTask(async () =>
- {
- if (_info.IsCustomFeeUsed)
- {
- await ShowAdvancedDialogAsync();
- }
- else
- {
- await OnAdjustFeeAsync();
- }
- });
+ AdjustFeeCommand = ReactiveCommand.CreateFromTask(OnAdjustFeeAsync);
UndoCommand = ReactiveCommand.CreateFromTask(async () =>
{
@@ -159,16 +149,6 @@ public partial class TransactionPreviewViewModel : RoutableViewModel
public ICommand UndoCommand { get; }
- private async Task ShowAdvancedDialogAsync()
- {
- var result = await NavigateDialogAsync(new AdvancedSendOptionsViewModel(_info), NavigationTarget.CompactDialogScreen);
-
- if (result.Kind == DialogResultKind.Normal)
- {
- await BuildAndUpdateAsync(BuildTransactionReason.FeeChanged);
- }
- }
-
private async Task OnExportPsbtAsync()
{
if (_transaction is { })

MODIFY
WalletWasabi.Fluent/Views/Wallets/Send/SendFeeView.axaml
WalletWasabi.Fluent/Views/Wallets/Send/SendFeeView.axaml
EnableBack="true"
EnableNext="True" NextContent="Continue"
IsBusy="{Binding IsBusy}">
+ <DockPanel>
+ <StackPanel Orientation="Horizontal" DockPanel.Dock="Bottom" HorizontalAlignment="Center" Spacing="20">
+ <StackPanel Orientation="Horizontal">
+ <Label Content="_Fee rate:" Target="FeeRate" Margin="0 10 0 0" />
+ <c:CurrencyEntryBox Text="{Binding FeeRateString}" CurrencyCode="sat/vbyte"
+ Width="220" MaxLength="10" Classes="standalone" />
+ </StackPanel>
+ <StackPanel Orientation="Horizontal">
+ <Label Content="{Binding FeeChart.CurrentConfirmationTargetString, Mode=OneWay, StringFormat='Estimated Time: \{0\}'}"
+ Margin="0 10 0 0" MinWidth="200" />
+ </StackPanel>
+ </StackPanel>
<DockPanel MinHeight="80" DataContext="{Binding FeeChart}">
<DockPanel.Resources>
<Thickness x:Key="SliderTopHeaderMargin">0,0,0,4</Thickness>
<GridLength x:Key="SliderPreContentMargin">0</GridLength>
<GridLength x:Key="SliderPostContentMargin">0</GridLength>
</DockPanel.Resources>
+
<Slider x:Name="CursorValue2"
Margin="38,0,18,0"
KeyboardNavigation.IsTabStop="False"
Value="{Binding SliderValue}">
<Slider.Styles>
<Style Selector="Slider /template/ Thumb">
- <Setter Property="ToolTip.Tip">
- <Template>
- <ToolTip>
- <StackPanel Spacing="10" x:CompileBindings="False">
- <TextBlock Text="{Binding $parent[Slider].DataContext.CurrentSatoshiPerByte, Mode=OneWay, StringFormat='Fee Rate: \{0:0\} sat/vByte'}" />
- <TextBlock Text="{Binding $parent[Slider].DataContext.CurrentConfirmationTargetString, Mode=OneWay, StringFormat='Estimated Time: \{0\}'}" />
- </StackPanel>
- </ToolTip>
- </Template>
- </Setter>
<Setter Property="ToolTip.Placement" Value="Bottom" />
<Setter Property="ToolTip.VerticalOffset" Value="10" />
<Setter Property="ToolTip.HorizontalOffset" Value="-30" />
</i:Interaction.Behaviors>
</c:LineChart>
</DockPanel>
+ </DockPanel>
</c:ContentArea>
</UserControl>

Custom Fee Rate input