enable volume input when taking range offer

This commit is contained in:
woodser 2025-07-08 09:29:52 -04:00 committed by woodser
parent 0cf34f3170
commit 8f505ab17b
4 changed files with 168 additions and 29 deletions

View file

@ -207,7 +207,6 @@ public abstract class MutableOfferViewModel<M extends MutableOfferDataModel> ext
@Named(FormattingUtils.BTC_FORMATTER_KEY) CoinFormatter xmrFormatter,
OfferUtil offerUtil) {
super(dataModel);
this.fiatVolumeValidator = fiatVolumeValidator;
this.amountValidator4Decimals = amountValidator4Decimals;
this.amountValidator8Decimals = amountValidator8Decimals;

View file

@ -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<Price> isNonZeroPrice = (p) -> p != null && !p.isZero();
private final Predicate<ObjectProperty<Volume>> isNonZeroVolume = (v) -> v.get() != null && !v.get().isZero();
///////////////////////////////////////////////////////////////////////////////////////////
@ -317,6 +322,10 @@ class TakeOfferDataModel extends OfferDataModel {
return offer;
}
ReadOnlyObjectProperty<Volume> getVolume() {
return volume;
}
ObservableList<PaymentAccount> getPossiblePaymentAccounts() {
Set<PaymentAccount> 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);

View file

@ -144,9 +144,9 @@ public class TakeOfferView extends ActivatableViewAndModel<AnchorPane, TakeOffer
waitingForFundsLabel, offerAvailabilityLabel, priceAsPercentageDescription,
tradeFeeDescriptionLabel, resultLabel, tradeFeeInXmrLabel, xLabel,
fakeXLabel, extraInfoLabel;
private InputTextField amountTextField;
private InputTextField amountTextField, volumeTextField;
private TextField paymentMethodTextField, currencyTextField, priceTextField, priceAsPercentageTextField,
volumeTextField, amountRangeTextField;
amountRangeTextField;
private FundsTextField totalToPayTextField;
private AddressTextField addressTextField;
private BalanceTextField balanceTextField;
@ -159,7 +159,7 @@ public class TakeOfferView extends ActivatableViewAndModel<AnchorPane, TakeOffer
private Notification walletFundedNotification;
private OfferView.CloseHandler closeHandler;
private Subscription balanceSubscription,
showTransactionPublishedScreenSubscription, showWarningInvalidBtcDecimalPlacesSubscription,
showTransactionPublishedScreenSubscription, showWarningInvalidXmrDecimalPlacesSubscription,
isWaitingForFundsSubscription, offerWarningSubscription, errorMessageSubscription,
isOfferAvailableSubscription;
private ChangeListener<BigInteger> missingCoinListener;
@ -170,7 +170,7 @@ public class TakeOfferView extends ActivatableViewAndModel<AnchorPane, TakeOffer
takeOfferFromUnsignedAccountWarningDisplayed, payByMailWarningDisplayed, cashAtAtmWarningDisplayed,
australiaPayidWarningDisplayed, paypalWarningDisplayed, cashAppWarningDisplayed, F2FWarningDisplayed;
private SimpleBooleanProperty errorPopupDisplayed;
private ChangeListener<Boolean> amountFocusedListener, getShowWalletFundedNotificationListener;
private ChangeListener<Boolean> amountFocusedListener, volumeFocusedListener, getShowWalletFundedNotificationListener;
private InfoInputTextField volumeInfoTextField;
@ -208,21 +208,6 @@ public class TakeOfferView extends ActivatableViewAndModel<AnchorPane, TakeOffer
balanceTextField.setFormatter(model.getXmrFormatter());
amountFocusedListener = (o, oldValue, newValue) -> {
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<AnchorPane, TakeOffer
amountRangeTextField.setText(model.getAmountRange());
amountRangeBox.setVisible(true);
amountRangeBox.setManaged(true);
volumeTextField.setDisable(false);
} else {
amountTextField.setDisable(true);
amountTextField.setManaged(true);
@ -604,6 +590,7 @@ public class TakeOfferView extends ActivatableViewAndModel<AnchorPane, TakeOffer
totalToPayTextField.textProperty().bind(model.totalToPay);
addressTextField.amountAsProperty().bind(model.dataModel.getMissingCoin());
amountTextField.validationResultProperty().bind(model.amountValidationResult);
volumeTextField.validationResultProperty().bind(model.volumeValidationResult);
priceCurrencyLabel.textProperty().bind(createStringBinding(() -> CurrencyUtil.getCounterCurrency(model.dataModel.getCurrencyCode())));
priceAsPercentageLabel.prefWidthProperty().bind(priceCurrencyLabel.widthProperty());
nextButton.disableProperty().bind(model.isNextButtonDisabled);
@ -703,10 +690,10 @@ public class TakeOfferView extends ActivatableViewAndModel<AnchorPane, TakeOffer
waitingForFundsLabel.setManaged(isWaitingForFunds);
});
showWarningInvalidBtcDecimalPlacesSubscription = EasyBind.subscribe(model.showWarningInvalidBtcDecimalPlaces, newValue -> {
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<AnchorPane, TakeOffer
errorMessageSubscription.unsubscribe();
isOfferAvailableSubscription.unsubscribe();
isWaitingForFundsSubscription.unsubscribe();
showWarningInvalidBtcDecimalPlacesSubscription.unsubscribe();
showWarningInvalidXmrDecimalPlacesSubscription.unsubscribe();
showTransactionPublishedScreenSubscription.unsubscribe();
// noSufficientFeeSubscription.unsubscribe();
balanceSubscription.unsubscribe();
}
private void createListeners() {
amountFocusedListener = (o, oldValue, newValue) -> {
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<AnchorPane, TakeOffer
private void addListeners() {
amountTextField.focusedProperty().addListener(amountFocusedListener);
volumeTextField.focusedProperty().addListener(volumeFocusedListener);
model.dataModel.getShowWalletFundedNotification().addListener(getShowWalletFundedNotificationListener);
model.dataModel.getMissingCoin().addListener(missingCoinListener);
}
private void removeListeners() {
amountTextField.focusedProperty().removeListener(amountFocusedListener);
volumeTextField.focusedProperty().removeListener(volumeFocusedListener);
model.dataModel.getShowWalletFundedNotification().removeListener(getShowWalletFundedNotificationListener);
model.dataModel.getMissingCoin().removeListener(missingCoinListener);
}

View file

@ -22,14 +22,17 @@ import com.google.inject.Inject;
import com.google.inject.name.Named;
import haveno.common.UserThread;
import haveno.core.account.witness.AccountAgeWitnessService;
import haveno.core.locale.CurrencyUtil;
import haveno.core.locale.Res;
import haveno.core.monetary.Price;
import haveno.core.monetary.Volume;
import haveno.core.offer.Offer;
import haveno.core.offer.OfferDirection;
import haveno.core.offer.OfferRestrictions;
import haveno.core.offer.OfferUtil;
import haveno.core.payment.PaymentAccount;
import haveno.core.payment.payload.PaymentMethod;
import haveno.core.payment.validation.FiatVolumeValidator;
import haveno.core.payment.validation.XmrValidator;
import haveno.core.trade.HavenoUtils;
import haveno.core.trade.Trade;
@ -37,7 +40,10 @@ import haveno.core.util.FormattingUtils;
import haveno.core.util.VolumeUtil;
import haveno.core.util.coin.CoinFormatter;
import haveno.core.util.coin.CoinUtil;
import haveno.core.util.validation.AmountValidator4Decimals;
import haveno.core.util.validation.AmountValidator8Decimals;
import haveno.core.util.validation.InputValidator;
import haveno.core.util.validation.MonetaryValidator;
import haveno.desktop.Navigation;
import haveno.desktop.common.model.ActivatableWithDataModel;
import haveno.desktop.common.model.ViewModel;
@ -76,12 +82,15 @@ class TakeOfferViewModel extends ActivatableWithDataModel<TakeOfferDataModel> 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<TakeOfferDataModel> 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<InputValidator.ValidationResult> amountValidationResult = new SimpleObjectProperty<>();
final ObjectProperty<InputValidator.ValidationResult> volumeValidationResult = new SimpleObjectProperty<>();
private ChangeListener<String> amountStrListener;
private ChangeListener<BigInteger> amountListener;
private ChangeListener<String> volumeStringListener;
private ChangeListener<Volume> volumeListener;
private ChangeListener<Boolean> isWalletFundedListener;
private ChangeListener<Trade.State> tradeStateListener;
private ChangeListener<Offer.State> offerStateListener;
@ -124,6 +136,9 @@ class TakeOfferViewModel extends ActivatableWithDataModel<TakeOfferDataModel> 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<TakeOfferDataModel> 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<TakeOfferDataModel> im
xmrValidator.setMaxValue(offer.getAmount());
xmrValidator.setMaxTradeLimit(dataModel.getMaxTradeLimit());
xmrValidator.setMinValue(offer.getMinAmount());
setVolumeToModel();
}
///////////////////////////////////////////////////////////////////////////////////////////
@ -288,7 +308,7 @@ class TakeOfferViewModel extends ActivatableWithDataModel<TakeOfferDataModel> 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<TakeOfferDataModel> 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<TakeOfferDataModel> 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<TakeOfferDataModel> 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<TakeOfferDataModel> 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<TakeOfferDataModel> 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<TakeOfferDataModel> 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
///////////////////////////////////////////////////////////////////////////////////////////