Add paste suggestion flyout
Adds a Flyout when the clipboard contains an amount.
ADD
WalletWasabi.Fluent/Behaviors/FlyoutSuggestionBehavior.cs
+using System.Reactive;
+using System.Reactive.Disposables;
+using System.Reactive.Linq;
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Controls.Templates;
+using Avalonia.Xaml.Interactivity;
+using ReactiveUI;
+
+namespace WalletWasabi.Fluent.Behaviors;
+
+public class FlyoutSuggestionBehavior : Behavior<Control>
+{
+ public static readonly StyledProperty<string> ContentProperty = AvaloniaProperty.Register<FlyoutSuggestionBehavior, string>(nameof(Content));
+
+ public static readonly StyledProperty<IDataTemplate> HintTemplateProperty = AvaloniaProperty.Register<FlyoutSuggestionBehavior, IDataTemplate>(nameof(HintTemplate));
+
+ public static readonly StyledProperty<Control?> TargetProperty = AvaloniaProperty.Register<FlyoutSuggestionBehavior, Control?>(nameof(Target));
+
+ public static readonly StyledProperty<FlyoutPlacementMode> PlacementModeProperty = AvaloniaProperty.Register<FlyoutSuggestionBehavior, FlyoutPlacementMode>(nameof(PlacementMode));
+
+ private readonly CompositeDisposable _disposables = new();
+
+ private readonly Flyout _flyout;
+
+ public FlyoutSuggestionBehavior()
+ {
+ _flyout = new Flyout { ShowMode = FlyoutShowMode.Transient };
+
+ this.WhenAnyValue(x => x.PlacementMode)
+ .Do(x => _flyout.Placement = x)
+ .Subscribe()
+ .DisposeWith(_disposables);
+ }
+
+ public FlyoutPlacementMode PlacementMode
+ {
+ get => GetValue(PlacementModeProperty);
+ set => SetValue(PlacementModeProperty, value);
+ }
+
+ public string Content
+ {
+ get => GetValue(ContentProperty);
+ set => SetValue(ContentProperty, value);
+ }
+
+ public IDataTemplate HintTemplate
+ {
+ get => GetValue(HintTemplateProperty);
+ set => SetValue(HintTemplateProperty, value);
+ }
+
+ public Control? Target
+ {
+ get => GetValue(TargetProperty);
+ set => SetValue(TargetProperty, value);
+ }
+
+ protected override void OnAttachedToVisualTree()
+ {
+ var targets = this
+ .WhenAnyValue(x => x.Target)
+ .WhereNotNull();
+
+ Displayer(targets).DisposeWith(_disposables);
+ Hider(targets).DisposeWith(_disposables);
+
+ Target ??= AssociatedObject as TextBox;
+
+ base.OnAttachedToVisualTree();
+ }
+
+ private IDisposable Hider(IObservable<Control> targets)
+ {
+ return targets
+ .Select(x => Observable.FromEventPattern(x, nameof(x.LostFocus)))
+ .Switch()
+ .Select(x => (TextBox?) x.Sender)
+ .Do(_ => _flyout.Hide())
+ .Subscribe();
+ }
+
+ private IDisposable Displayer(IObservable<Control> targets)
+ {
+ return targets
+ .Select(x => Observable.FromEventPattern(x, nameof(x.GotFocus)))
+ .Switch()
+ .Select(x => (TextBox?) x.Sender)
+ .WithLatestFrom(this.WhenAnyValue(x => x.Content))
+ .Where(tuple => !string.IsNullOrWhiteSpace(tuple.Second) && tuple.First?.Text != tuple.Second)
+ .Select(tuple => CreateSuggestion(tuple.First, tuple.Second))
+ .Do(ShowHint)
+ .Subscribe();
+ }
+
+ private Suggestion CreateSuggestion(TextBox? textBox, string content)
+ {
+ return new Suggestion(
+ content,
+ () =>
+ {
+ if (textBox != null)
+ {
+ textBox.Text = content;
+ }
+
+ _flyout.Hide();
+ });
+ }
+
+ private void ShowHint(Suggestion suggestion)
+ {
+ _flyout.Content = new ContentControl { ContentTemplate = HintTemplate, Content = suggestion };
+ if (Target != null)
+ {
+ _flyout.ShowAt(Target);
+ }
+ }
+}
+
+public class Suggestion : ReactiveObject
+{
+ public Suggestion(string text, Action onAccept)
+ {
+ Text = text;
+ AcceptHintCommand = ReactiveCommand.Create(onAccept);
+ }
+
+ public string Text { get; }
+
+ public ReactiveCommand<Unit, Unit> AcceptHintCommand { get; set; }
+}

MODIFY
WalletWasabi.Fluent/Controls/DualCurrencyEntryBox.axaml
WalletWasabi.Fluent/Controls/DualCurrencyEntryBox.axaml
<Styles xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
- xmlns:c="clr-namespace:WalletWasabi.Fluent.Controls;assembly=WalletWasabi.Fluent">
+ xmlns:i="clr-namespace:Avalonia.Xaml.Interactivity;assembly=Avalonia.Xaml.Interactivity"
+ xmlns:c="clr-namespace:WalletWasabi.Fluent.Controls;assembly=WalletWasabi.Fluent"
+ xmlns:b="clr-namespace:WalletWasabi.Fluent.Behaviors">
<Styles.Resources>
<Thickness x:Key="TextControlBorderThemeThickness">0,0,0,2</Thickness>
<Thickness x:Key="TextControlBorderThemeThicknessFocused">0,0,0,2</Thickness>
Grid.Column="{TemplateBinding LeftColumn}"
Background="Transparent"
BorderThickness="0"
- IsRightSide="{TemplateBinding IsConversionReversed}" />
+ IsRightSide="{TemplateBinding IsConversionReversed}">
+ </c:CurrencyEntryBox>
<Button x:Name="PART_SwapButton" Focusable="False"
HorizontalAlignment="Center" VerticalAlignment="Center" Background="Transparent"

MODIFY
WalletWasabi.Fluent/Controls/DualCurrencyEntryBox.axaml.cs
WalletWasabi.Fluent/Controls/DualCurrencyEntryBox.axaml.cs
@@ -62,8 +62,23 @@ public class DualCurrencyEntryBox : UserControl
private readonly char _decimalSeparator = '.';
private readonly char _groupSeparator = ' ';
private Button? _swapButton;
- private CurrencyEntryBox? _leftEntryBox;
- private CurrencyEntryBox? _rightEntryBox;
+
+ public static readonly StyledProperty<CurrencyEntryBox?> RightEntryBoxProperty = AvaloniaProperty.Register<DualCurrencyEntryBox, CurrencyEntryBox?>("RightEntryBox");
+
+ public CurrencyEntryBox? RightEntryBox
+ {
+ get => GetValue(RightEntryBoxProperty);
+ set => SetValue(RightEntryBoxProperty, value);
+ }
+
+ public static readonly StyledProperty<CurrencyEntryBox?> LeftEntryBoxProperty = AvaloniaProperty.Register<DualCurrencyEntryBox, CurrencyEntryBox?>("LeftEntryBox");
+
+ public CurrencyEntryBox? LeftEntryBox
+ {
+ get => GetValue(LeftEntryBoxProperty);
+ set => SetValue(LeftEntryBoxProperty, value);
+ }
+
private decimal _amountBtc;
private bool _canUpdateDisplay = true;
private bool _canUpdateFiat = true;
@@ -323,8 +338,8 @@ public class DualCurrencyEntryBox : UserControl
_disposable = new CompositeDisposable();
_swapButton = e.NameScope.Find<Button>("PART_SwapButton");
- _leftEntryBox = e.NameScope.Find<CurrencyEntryBox>("PART_LeftEntryBox");
- _rightEntryBox = e.NameScope.Find<CurrencyEntryBox>("PART_RightEntryBox");
+ LeftEntryBox = e.NameScope.Find<CurrencyEntryBox>("PART_LeftEntryBox");
+ RightEntryBox = e.NameScope.Find<CurrencyEntryBox>("PART_RightEntryBox");
if (_swapButton is { })
{
@@ -346,8 +361,8 @@ public class DualCurrencyEntryBox : UserControl
{
var focusOn =
IsConversionReversed
- ? _rightEntryBox
- : _leftEntryBox;
+ ? RightEntryBox
+ : LeftEntryBox;
focusOn?.Focus();
}
@@ -384,21 +399,21 @@ public class DualCurrencyEntryBox : UserControl
// setting Grid.Column via pseudoclass based style doesn't work, not even using AffectsMeasure()... Avalonia bug?
private void ReorganizeVisuals()
{
- if (_leftEntryBox is { } && _rightEntryBox is { })
+ if (LeftEntryBox is { } && RightEntryBox is { })
{
- var grid = _leftEntryBox.FindAncestorOfType<Grid>();
- grid?.Children.Remove(_leftEntryBox);
- grid?.Children.Remove(_rightEntryBox);
+ var grid = LeftEntryBox.FindAncestorOfType<Grid>();
+ grid?.Children.Remove(LeftEntryBox);
+ grid?.Children.Remove(RightEntryBox);
if (IsConversionReversed)
{
- grid?.Children.Add(_rightEntryBox);
- grid?.Children.Add(_leftEntryBox);
+ grid?.Children.Add(RightEntryBox);
+ grid?.Children.Add(LeftEntryBox);
}
else
{
- grid?.Children.Add(_leftEntryBox);
- grid?.Children.Add(_rightEntryBox);
+ grid?.Children.Add(LeftEntryBox);
+ grid?.Children.Add(RightEntryBox);
}
}
}

ADD
WalletWasabi.Fluent/Converters/BooleanConverter.cs
+using System.Globalization;
+using Avalonia;
+using Avalonia.Data.Converters;
+
+namespace WalletWasabi.Fluent.Converters;
+
+public class BooleanConverter : IValueConverter
+{
+ public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
+ {
+ if (value is bool b)
+ {
+ if (b)
+ {
+ return TrueValue;
+ }
+ else
+ {
+ return FalseValue;
+ }
+ }
+
+ return AvaloniaProperty.UnsetValue;
+ }
+
+ public object? TrueValue { get; set; }
+
+ public object? FalseValue { get; set; }
+
+ public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
+ {
+ throw new NotSupportedException();
+ }
+}

MODIFY
WalletWasabi.Fluent/Converters/MoneyConverters.cs
WalletWasabi.Fluent/Converters/MoneyConverters.cs
using Avalonia.Data.Converters;
+using NBitcoin;
using WalletWasabi.Fluent.Extensions;
+using WalletWasabi.Fluent.Helpers;
namespace WalletWasabi.Fluent.Converters;
@@ -10,4 +12,7 @@ public static class MoneyConverters
public static readonly IValueConverter ToUsdAproxBetweenParens =
new FuncValueConverter<decimal, string>(n => n.ToUsdAproxBetweenParens());
+
+ public static readonly IValueConverter ToFormattedBtc =
+ new FuncValueConverter<Money?, string>(n => n?.ToFormattedString() ?? "" + " BTC");
}

ADD
WalletWasabi.Fluent/Extensions/FunctionalExtensions.cs
+namespace WalletWasabi.Fluent.Extensions;
+
+public static class FunctionalExtensions
+{
+ public static T? Ensure<T>(this T? obj, Func<T, bool> condition)
+ {
+ if (obj == null)
+ {
+ return default;
+ }
+
+ if (condition(obj))
+ {
+ return obj;
+ }
+
+ return default;
+ }
+}

MODIFY
WalletWasabi.Fluent/Helpers/ApplicationHelper.cs
WalletWasabi.Fluent/Helpers/ApplicationHelper.cs
+using System.Reactive.Concurrency;
+using System.Reactive.Linq;
+using System.Reactive.Threading.Tasks;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.ApplicationLifetimes;
+using ReactiveUI;
namespace WalletWasabi.Fluent.Helpers;
public static class ApplicationHelper
{
public static Window? MainWindow => (Application.Current?.ApplicationLifetime as IClassicDesktopStyleApplicationLifetime)?.MainWindow;
+
+ public static IObservable<string> GetClipboardTextChanged(IScheduler? scheduler = default)
+ {
+ return Observable.Interval(TimeSpan.FromSeconds(0.2), scheduler ?? Scheduler.Default)
+ .SelectMany(
+ _ => Application.Current?.Clipboard?.GetTextAsync()
+ .ToObservable() ?? Observable.Return<string?>(null)
+ .WhereNotNull())
+ .DistinctUntilChanged();
+ }
}

MODIFY
WalletWasabi.Fluent/ViewModels/Wallets/Home/Tiles/WalletBalanceTileViewModel.cs
WalletWasabi.Fluent/ViewModels/Wallets/Home/Tiles/WalletBalanceTileViewModel.cs
using System.Reactive.Linq;
using NBitcoin;
+using ReactiveUI;
using WalletWasabi.Fluent.Extensions;
-using WalletWasabi.Fluent.Helpers;
namespace WalletWasabi.Fluent.ViewModels.Wallets.Home.Tiles;
@@ -9,24 +9,21 @@ public class WalletBalanceTileViewModel : ActivatableViewModel
{
public WalletBalanceTileViewModel(WalletViewModel walletVm)
{
- var wallet = walletVm.Wallet;
+ WalletVm = walletVm;
- var balance = walletVm.UiTriggers.BalanceUpdateTrigger
- .Select(_ => wallet.Coins.TotalAmount());
-
- BalanceBtc = balance
- .Select(money => $"{money.ToFormattedString()} BTC");
+ ExchangeRate = this.WhenAnyValue(x => x.WalletVm.Wallet.Synchronizer.UsdExchangeRate);
+ BalanceBtc = this.WhenAnyValue(x => x.WalletVm.UiTriggers.BalanceUpdateTrigger).Select(_ => walletVm.Wallet.Coins.TotalAmount());
+ BalanceFiat = BalanceBtc.WithLatestFrom(ExchangeRate, (money, exRate) => money.BtcToUsd(exRate));
+ HasBalance = BalanceBtc.Select(money => money > Money.Zero);
+ }
- BalanceFiat = balance
- .Select(money => money.BtcToUsd(wallet.Synchronizer.UsdExchangeRate));
+ public IObservable<decimal> ExchangeRate { get; }
- HasBalance = balance
- .Select(money => money > Money.Zero);
- }
+ private WalletViewModel WalletVm { get; }
public IObservable<bool> HasBalance { get; }
public IObservable<decimal> BalanceFiat { get; }
- public IObservable<string> BalanceBtc { get; }
+ public IObservable<Money> BalanceBtc { get; }
}

MODIFY
WalletWasabi.Fluent/ViewModels/Wallets/Send/SendViewModel.cs
WalletWasabi.Fluent/ViewModels/Wallets/Send/SendViewModel.cs
@@ -9,6 +9,8 @@ using NBitcoin;
using NBitcoin.Payment;
using ReactiveUI;
using WalletWasabi.Blockchain.Analysis.Clustering;
+using WalletWasabi.Fluent.Extensions;
+using WalletWasabi.Fluent.Helpers;
using WalletWasabi.Fluent.Models;
using WalletWasabi.Fluent.Validation;
using WalletWasabi.Fluent.ViewModels.Dialogs;
@@ -124,8 +126,51 @@ public partial class SendViewModel : RoutableViewModel
this.WhenAnyValue(x => x.ConversionReversed)
.Skip(1)
.Subscribe(x => Services.UiConfig.SendAmountConversionReversed = x);
+
+ BitcoinContent = ClipboardBtcContent();
+ UsdContent = ClipboardUsdContent();
+ }
+
+ private IObservable<string?> ClipboardUsdContent()
+ {
+ return ApplicationHelper.GetClipboardTextChanged(RxApp.MainThreadScheduler)
+ .CombineLatest(
+ Balance.BalanceBtc,
+ Balance.ExchangeRate,
+ (text, balance, exchangeRate) =>
+ {
+ return ParseToUsd(text).Ensure(n => n <= balance.ToDecimal(MoneyUnit.BTC) * exchangeRate);
+ })
+ .Select(money => money?.ToString("0.00"));
+ }
+
+ private IObservable<string?> ClipboardBtcContent()
+ {
+ return ApplicationHelper.GetClipboardTextChanged(RxApp.MainThreadScheduler)
+ .CombineLatest(
+ Balance.BalanceBtc,
+ (text, balance) =>
+ {
+ return ParseToMoney(text)
+ .Ensure(m => m <= balance);
+ })
+ .Select(money => money?.ToDecimal(MoneyUnit.BTC).FormattedBtc());
}
+ private static decimal? ParseToUsd(string text)
+ {
+ return decimal.TryParse(text, out var n) ? n : (decimal?)default;
+ }
+
+ private static Money? ParseToMoney(string text)
+ {
+ return (Money.TryParse(text, out var n) ? n : default);
+ }
+
+ public IObservable<string?> UsdContent { get; }
+
+ public IObservable<string?> BitcoinContent { get; }
+
public bool IsQrButtonVisible { get; }
public ICommand PasteCommand { get; }

MODIFY
WalletWasabi.Fluent/Views/Wallets/Home/Tiles/WalletBalanceTileView.axaml
WalletWasabi.Fluent/Views/Wallets/Home/Tiles/WalletBalanceTileView.axaml
<c:TileControl Title="BALANCE" IsBottomContentVisible="{Binding HasBalance^}" SeparatorMargin="0 0 0 6">
<Viewbox Margin="0 3" VerticalAlignment="Center" MaxHeight="40">
<c:PrivacyContentControl Classes="h2 monoSpaced" HorizontalContentAlignment="Center"
- Content="{Binding BalanceBtc^}" />
+ Content="{Binding BalanceBtc^, Converter={x:Static converters:MoneyConverters.ToFormattedBtc}}" />
</Viewbox>
<c:TileControl.BottomContent>
<c:PrivacyContentControl Classes="bold monoSpaced" Margin="4" VerticalAlignment="Center"

MODIFY
WalletWasabi.Fluent/Views/Wallets/Send/SendView.axaml
WalletWasabi.Fluent/Views/Wallets/Send/SendView.axaml
xmlns:behaviors="using:WalletWasabi.Fluent.Behaviors"
xmlns:c="using:WalletWasabi.Fluent.Controls"
xmlns:vm="using:WalletWasabi.Fluent.ViewModels.Wallets.Send"
- xmlns:converters="clr-namespace:WalletWasabi.Fluent.Converters"
+ xmlns:conv="clr-namespace:WalletWasabi.Fluent.Converters"
mc:Ignorable="d"
x:DataType="vm:SendViewModel"
x:CompileBindings="True"
x:Class="WalletWasabi.Fluent.Views.Wallets.Send.SendView">
+
+
+ <UserControl.DataTemplates>
+
+ <DataTemplate DataType="behaviors:Suggestion">
+ <Button Background="Transparent" HorizontalAlignment="Center" Margin="8" Padding="0" DockPanel.Dock="Right" Command="{Binding AcceptHintCommand}" Focusable="False" Cursor="Hand">
+ <DockPanel>
+ <PathIcon Data="{StaticResource paste_regular}" Width="20" DockPanel.Dock="Left" />
+ <TextBlock Margin="8" Text="{Binding Text}" VerticalAlignment="Center" />
+ </DockPanel>
+ </Button>
+ </DataTemplate>
+ </UserControl.DataTemplates>
+
+ <UserControl.Styles>
+ <Style Selector="FlyoutPresenter">
+ <Setter Property="Padding" Value="0" />
+ </Style>
+
+ </UserControl.Styles>
+
<c:ContentArea
EnableBack="{Binding EnableBack}"
EnableNext="True"
<TextBlock DataContext="{Binding Balance}">
<TextBlock.Text>
<MultiBinding StringFormat="Balance: {0} {1}">
- <Binding Path="BalanceBtc^" />
- <Binding Path="BalanceFiat^" Converter="{x:Static converters:MoneyConverters.ToUsdAproxBetweenParens}" />
+ <Binding Path="BalanceBtc^" Converter="{x:Static conv:MoneyConverters.ToFormattedBtc}" />
+ <Binding Path="BalanceFiat^" Converter="{x:Static conv:MoneyConverters.ToUsdAproxBetweenParens}" />
</MultiBinding>
</TextBlock.Text>
</TextBlock>
ConversionRate="{Binding ExchangeRate}"
CurrencyCode="BTC"
ConversionCurrencyCode="USD"
- IsConversionReversed="{Binding ConversionReversed, Mode=TwoWay}" />
+ IsConversionReversed="{Binding ConversionReversed, Mode=TwoWay}">
+ <c:DualCurrencyEntryBox.Resources>
+
+ <conv:BooleanConverter x:Key="LeftBooleanConverter">
+ <conv:BooleanConverter.TrueValue>
+ <FlyoutPlacementMode>BottomEdgeAlignedRight</FlyoutPlacementMode>
+ </conv:BooleanConverter.TrueValue>
+ <conv:BooleanConverter.FalseValue>
+ <FlyoutPlacementMode>BottomEdgeAlignedLeft</FlyoutPlacementMode>
+ </conv:BooleanConverter.FalseValue>
+ </conv:BooleanConverter>
+
+ <conv:BooleanConverter x:Key="RightBooleanConverter">
+ <conv:BooleanConverter.TrueValue>
+ <FlyoutPlacementMode>BottomEdgeAlignedLeft</FlyoutPlacementMode>
+ </conv:BooleanConverter.TrueValue>
+ <conv:BooleanConverter.FalseValue>
+ <FlyoutPlacementMode>BottomEdgeAlignedRight</FlyoutPlacementMode>
+ </conv:BooleanConverter.FalseValue>
+ </conv:BooleanConverter>
+
+ </c:DualCurrencyEntryBox.Resources>
+ <i:Interaction.Behaviors>
+ <behaviors:FlyoutSuggestionBehavior Target="{Binding #amountTb.LeftEntryBox}" Content="{Binding BitcoinContent^}"
+ PlacementMode="{Binding ConversionReversed, Converter={StaticResource LeftBooleanConverter}}" />
+ <behaviors:FlyoutSuggestionBehavior Target="{Binding #amountTb.RightEntryBox}" Content="{Binding UsdContent^}"
+ PlacementMode="{Binding ConversionReversed, Converter={StaticResource RightBooleanConverter}}" />
+ </i:Interaction.Behaviors>
+ </c:DualCurrencyEntryBox>
</DockPanel>
</DockPanel>
</DockPanel>

Add paste suggestion flyout

Adds a Flyout when the clipboard contains an amount.