diff --git a/desktop/src/main/java/haveno/desktop/main/offer/MutableOfferViewModel.java b/desktop/src/main/java/haveno/desktop/main/offer/MutableOfferViewModel.java index 3dc2849872..e563af3601 100644 --- a/desktop/src/main/java/haveno/desktop/main/offer/MutableOfferViewModel.java +++ b/desktop/src/main/java/haveno/desktop/main/offer/MutableOfferViewModel.java @@ -207,7 +207,6 @@ public abstract class MutableOfferViewModel ext @Named(FormattingUtils.BTC_FORMATTER_KEY) CoinFormatter xmrFormatter, OfferUtil offerUtil) { super(dataModel); - this.fiatVolumeValidator = fiatVolumeValidator; this.amountValidator4Decimals = amountValidator4Decimals; this.amountValidator8Decimals = amountValidator8Decimals; diff --git a/desktop/src/main/java/haveno/desktop/main/offer/takeoffer/TakeOfferDataModel.java b/desktop/src/main/java/haveno/desktop/main/offer/takeoffer/TakeOfferDataModel.java index 590a9a2c31..e8da31e3bf 100644 --- a/desktop/src/main/java/haveno/desktop/main/offer/takeoffer/TakeOfferDataModel.java +++ b/desktop/src/main/java/haveno/desktop/main/offer/takeoffer/TakeOfferDataModel.java @@ -41,6 +41,7 @@ import haveno.core.trade.handlers.TradeResultHandler; import haveno.core.user.Preferences; import haveno.core.user.User; import haveno.core.util.VolumeUtil; +import haveno.core.util.coin.CoinUtil; import haveno.core.xmr.listeners.XmrBalanceListener; import haveno.core.xmr.model.XmrAddressEntry; import haveno.core.xmr.wallet.XmrWalletService; @@ -59,6 +60,7 @@ import org.jetbrains.annotations.NotNull; import javax.annotation.Nullable; import java.math.BigInteger; import java.util.Set; +import java.util.function.Predicate; import static com.google.common.base.Preconditions.checkArgument; import static com.google.common.base.Preconditions.checkNotNull; @@ -91,7 +93,10 @@ class TakeOfferDataModel extends OfferDataModel { private XmrBalanceListener balanceListener; private PaymentAccount paymentAccount; private boolean isTabSelected; + protected boolean allowAmountUpdate = true; Price tradePrice; + private final Predicate isNonZeroPrice = (p) -> p != null && !p.isZero(); + private final Predicate> isNonZeroVolume = (v) -> v.get() != null && !v.get().isZero(); /////////////////////////////////////////////////////////////////////////////////////////// @@ -317,6 +322,10 @@ class TakeOfferDataModel extends OfferDataModel { return offer; } + ReadOnlyObjectProperty getVolume() { + return volume; + } + ObservableList getPossiblePaymentAccounts() { Set paymentAccounts = user.getPaymentAccounts(); checkNotNull(paymentAccounts, "paymentAccounts must not be null"); @@ -387,6 +396,32 @@ class TakeOfferDataModel extends OfferDataModel { } } + void calculateAmount() { + if (isNonZeroPrice.test(tradePrice) && isNonZeroVolume.test(volume) && allowAmountUpdate) { + try { + Volume volumeBefore = volume.get(); + calculateVolume(); + + // if the volume != amount * price, we need to adjust the amount + if (amount.get() == null || !volumeBefore.equals(tradePrice.getVolumeByAmount(amount.get()))) { + BigInteger value = tradePrice.getAmountByVolume(volumeBefore); + value = value.min(offer.getAmount()); // adjust if above maximum + value = value.max(offer.getMinAmount()); // adjust if below minimum + value = CoinUtil.getRoundedAmount(value, tradePrice, offer.getMinAmount(), getMaxTradeLimit(), offer.getCounterCurrencyCode(), paymentAccount.getPaymentMethod().getId()); + amount.set(value); + } + + calculateTotalToPay(); + } catch (Throwable t) { + log.error(t.toString()); + } + } + } + + protected void setVolume(Volume volume) { + this.volume.set(volume); + } + void maybeApplyAmount(BigInteger amount) { if (amount.compareTo(offer.getMinAmount()) >= 0 && amount.compareTo(getMaxTradeLimit()) <= 0) { this.amount.set(amount); diff --git a/desktop/src/main/java/haveno/desktop/main/offer/takeoffer/TakeOfferView.java b/desktop/src/main/java/haveno/desktop/main/offer/takeoffer/TakeOfferView.java index 73b3aa526e..bf929d6f3b 100644 --- a/desktop/src/main/java/haveno/desktop/main/offer/takeoffer/TakeOfferView.java +++ b/desktop/src/main/java/haveno/desktop/main/offer/takeoffer/TakeOfferView.java @@ -144,9 +144,9 @@ public class TakeOfferView extends ActivatableViewAndModel missingCoinListener; @@ -170,7 +170,7 @@ public class TakeOfferView extends ActivatableViewAndModel amountFocusedListener, getShowWalletFundedNotificationListener; + private ChangeListener amountFocusedListener, volumeFocusedListener, getShowWalletFundedNotificationListener; private InfoInputTextField volumeInfoTextField; @@ -208,21 +208,6 @@ public class TakeOfferView extends ActivatableViewAndModel { - model.onFocusOutAmountTextField(oldValue, newValue, amountTextField.getText()); - amountTextField.setText(model.amount.get()); - }; - - getShowWalletFundedNotificationListener = (observable, oldValue, newValue) -> { - if (newValue) { - Notification walletFundedNotification = new Notification() - .headLine(Res.get("notification.walletUpdate.headline")) - .notification(Res.get("notification.walletUpdate.msg", HavenoUtils.formatXmr(model.dataModel.getTotalToPay().get(), true))) - .autoClose(); - - walletFundedNotification.show(); - } - }; GUIUtil.focusWhenAddedToScene(amountTextField); } @@ -342,6 +327,7 @@ public class TakeOfferView extends ActivatableViewAndModel CurrencyUtil.getCounterCurrency(model.dataModel.getCurrencyCode()))); priceAsPercentageLabel.prefWidthProperty().bind(priceCurrencyLabel.widthProperty()); nextButton.disableProperty().bind(model.isNextButtonDisabled); @@ -703,10 +690,10 @@ public class TakeOfferView extends ActivatableViewAndModel { + showWarningInvalidXmrDecimalPlacesSubscription = EasyBind.subscribe(model.showWarningInvalidXmrDecimalPlaces, newValue -> { if (newValue) { new Popup().warning(Res.get("takeOffer.amountPriceBox.warning.invalidXmrDecimalPlaces")).show(); - model.showWarningInvalidBtcDecimalPlaces.set(false); + model.showWarningInvalidXmrDecimalPlaces.set(false); } }); @@ -742,13 +729,31 @@ public class TakeOfferView extends ActivatableViewAndModel { + model.onFocusOutAmountTextField(oldValue, newValue, amountTextField.getText()); + amountTextField.setText(model.amount.get()); + }; + getShowWalletFundedNotificationListener = (observable, oldValue, newValue) -> { + if (newValue) { + Notification walletFundedNotification = new Notification() + .headLine(Res.get("notification.walletUpdate.headline")) + .notification(Res.get("notification.walletUpdate.msg", HavenoUtils.formatXmr(model.dataModel.getTotalToPay().get(), true))) + .autoClose(); + + walletFundedNotification.show(); + } + }; + volumeFocusedListener = (o, oldValue, newValue) -> { + model.onFocusOutVolumeTextField(oldValue, newValue); + volumeTextField.setText(model.volume.get()); + }; missingCoinListener = (observable, oldValue, newValue) -> { if (!newValue.toString().equals("")) { updateQrCode(); @@ -758,12 +763,14 @@ public class TakeOfferView extends ActivatableViewAndModel im private final AccountAgeWitnessService accountAgeWitnessService; private final Navigation navigation; private final CoinFormatter xmrFormatter; + private final FiatVolumeValidator fiatVolumeValidator; + private final AmountValidator4Decimals amountValidator4Decimals; + private final AmountValidator8Decimals amountValidator8Decimals; private String amountRange; private String paymentLabel; - private boolean takeOfferRequested; + private boolean takeOfferRequested, ignoreVolumeStringListener; private Trade trade; - private Offer offer; + protected Offer offer; private String price; private String amountDescription; @@ -101,15 +110,18 @@ class TakeOfferViewModel extends ActivatableWithDataModel im final BooleanProperty isTakeOfferButtonDisabled = new SimpleBooleanProperty(true); final BooleanProperty isNextButtonDisabled = new SimpleBooleanProperty(true); final BooleanProperty isWaitingForFunds = new SimpleBooleanProperty(); - final BooleanProperty showWarningInvalidBtcDecimalPlaces = new SimpleBooleanProperty(); + final BooleanProperty showWarningInvalidXmrDecimalPlaces = new SimpleBooleanProperty(); final BooleanProperty showTransactionPublishedScreen = new SimpleBooleanProperty(); final BooleanProperty takeOfferCompleted = new SimpleBooleanProperty(); final BooleanProperty showPayFundsScreenDisplayed = new SimpleBooleanProperty(); final ObjectProperty amountValidationResult = new SimpleObjectProperty<>(); + final ObjectProperty volumeValidationResult = new SimpleObjectProperty<>(); private ChangeListener amountStrListener; private ChangeListener amountListener; + private ChangeListener volumeStringListener; + private ChangeListener volumeListener; private ChangeListener isWalletFundedListener; private ChangeListener tradeStateListener; private ChangeListener offerStateListener; @@ -124,6 +136,9 @@ class TakeOfferViewModel extends ActivatableWithDataModel im @Inject public TakeOfferViewModel(TakeOfferDataModel dataModel, + FiatVolumeValidator fiatVolumeValidator, + AmountValidator4Decimals amountValidator4Decimals, + AmountValidator8Decimals amountValidator8Decimals, OfferUtil offerUtil, XmrValidator btcValidator, P2PService p2PService, @@ -138,6 +153,9 @@ class TakeOfferViewModel extends ActivatableWithDataModel im this.accountAgeWitnessService = accountAgeWitnessService; this.navigation = navigation; this.xmrFormatter = btcFormatter; + this.fiatVolumeValidator = fiatVolumeValidator; + this.amountValidator4Decimals = amountValidator4Decimals; + this.amountValidator8Decimals = amountValidator8Decimals; createListeners(); } @@ -210,6 +228,8 @@ class TakeOfferViewModel extends ActivatableWithDataModel im xmrValidator.setMaxValue(offer.getAmount()); xmrValidator.setMaxTradeLimit(dataModel.getMaxTradeLimit()); xmrValidator.setMinValue(offer.getMinAmount()); + + setVolumeToModel(); } /////////////////////////////////////////////////////////////////////////////////////////// @@ -288,7 +308,7 @@ class TakeOfferViewModel extends ActivatableWithDataModel im InputValidator.ValidationResult result = isXmrInputValid(amount.get()); amountValidationResult.set(result); if (result.isValid) { - showWarningInvalidBtcDecimalPlaces.set(!DisplayUtils.hasBtcValidDecimals(userInput, xmrFormatter)); + if (userInput != null) showWarningInvalidXmrDecimalPlaces.set(!DisplayUtils.hasBtcValidDecimals(userInput, xmrFormatter)); // only allow max 4 decimal places for xmr values setAmountToModel(); // reformat input @@ -338,6 +358,30 @@ class TakeOfferViewModel extends ActivatableWithDataModel im } } + void onFocusOutVolumeTextField(boolean oldValue, boolean newValue) { + if (oldValue && !newValue) { + InputValidator.ValidationResult result = isVolumeInputValid(volume.get()); + volumeValidationResult.set(result); + if (result.isValid) { + setVolumeToModel(); + ignoreVolumeStringListener = true; + + Volume volume = dataModel.getVolume().get(); + if (volume != null) { + volume = VolumeUtil.getAdjustedVolume(volume, offer.getPaymentMethod().getId()); + this.volume.set(VolumeUtil.formatVolume(volume)); + } + + ignoreVolumeStringListener = false; + + dataModel.calculateAmount(); + + if (amount.get() != null) + amountValidationResult.set(isXmrInputValid(amount.get())); + } + } + } + /////////////////////////////////////////////////////////////////////////////////////////// // States /////////////////////////////////////////////////////////////////////////////////////////// @@ -454,14 +498,12 @@ class TakeOfferViewModel extends ActivatableWithDataModel im /////////////////////////////////////////////////////////////////////////////////////////// private void addBindings() { - volume.bind(createStringBinding(() -> VolumeUtil.formatVolume(dataModel.volume.get()), dataModel.volume)); totalToPay.bind(createStringBinding(() -> HavenoUtils.formatXmr(dataModel.getTotalToPay().get(), true), dataModel.getTotalToPay())); } private void removeBindings() { volumeDescriptionLabel.unbind(); - volume.unbind(); totalToPay.unbind(); } @@ -475,10 +517,33 @@ class TakeOfferViewModel extends ActivatableWithDataModel im } updateButtonDisableState(); }; + amountListener = (ov, oldValue, newValue) -> { amount.set(HavenoUtils.formatXmr(newValue)); applyTakerFee(); }; + + volumeStringListener = (ov, oldValue, newValue) -> { + if (!ignoreVolumeStringListener) { + if (isVolumeInputValid(newValue).isValid) { + setVolumeToModel(); + dataModel.calculateAmount(); + dataModel.calculateTotalToPay(); + } + updateButtonDisableState(); + } + }; + + volumeListener = (ov, oldValue, newValue) -> { + ignoreVolumeStringListener = true; + if (newValue != null) + volume.set(VolumeUtil.formatVolume(newValue)); + else + volume.set(""); + + ignoreVolumeStringListener = false; + }; + isWalletFundedListener = (ov, oldValue, newValue) -> updateButtonDisableState(); tradeStateListener = (ov, oldValue, newValue) -> applyTradeState(); @@ -527,9 +592,11 @@ class TakeOfferViewModel extends ActivatableWithDataModel im // Bidirectional bindings are used for all input fields: amount, price, volume and minAmount // We do volume/amount calculation during input, so user has immediate feedback amount.addListener(amountStrListener); + volume.addListener(volumeStringListener); // Binding with Bindings.createObjectBinding does not work because of bi-directional binding dataModel.getAmount().addListener(amountListener); + dataModel.getVolume().addListener(volumeListener); dataModel.getIsXmrWalletFunded().addListener(isWalletFundedListener); p2PService.getNetworkNode().addConnectionListener(connectionListener); @@ -541,9 +608,11 @@ class TakeOfferViewModel extends ActivatableWithDataModel im private void removeListeners() { amount.removeListener(amountStrListener); + volume.removeListener(volumeStringListener); // Binding with Bindings.createObjectBinding does not work because of bi-directional binding dataModel.getAmount().removeListener(amountListener); + dataModel.getVolume().removeListener(volumeListener); dataModel.getIsXmrWalletFunded().removeListener(isWalletFundedListener); if (offer != null) { @@ -583,6 +652,35 @@ class TakeOfferViewModel extends ActivatableWithDataModel im } } + private void setVolumeToModel() { + if (volume.get() != null && !volume.get().isEmpty()) { + try { + dataModel.setVolume(Volume.parse(volume.get(), offer.getCounterCurrencyCode())); + } catch (Throwable t) { + log.debug(t.getMessage()); + } + } else { + dataModel.setVolume(null); + } + } + + private InputValidator.ValidationResult isVolumeInputValid(String input) { + return getVolumeValidator().validate(input); + } + + // TODO: replace with VolumeUtils? + + private MonetaryValidator getVolumeValidator() { + final String code = offer.getCounterCurrencyCode(); + if (CurrencyUtil.isFiatCurrency(code)) { + return fiatVolumeValidator; + } else if (CurrencyUtil.isVolumeRoundedToNearestUnit(code)) { + return amountValidator4Decimals; + } else { + return amountValidator8Decimals; + } + } + /////////////////////////////////////////////////////////////////////////////////////////// // Getters ///////////////////////////////////////////////////////////////////////////////////////////