support cloning up to 10 offers with shared reserved funds (#1668)

This commit is contained in:
woodser 2025-04-05 17:29:55 -04:00 committed by GitHub
parent 7e3a47de4a
commit 40e18890d6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
55 changed files with 2006 additions and 611 deletions

View file

@ -366,7 +366,7 @@ public class HavenoApp extends Application implements UncaughtExceptionHandler {
}
// check for open offers
if (injector.getInstance(OpenOfferManager.class).hasOpenOffers()) {
if (injector.getInstance(OpenOfferManager.class).hasAvailableOpenOffers()) {
String key = "showOpenOfferWarnPopupAtShutDown";
if (injector.getInstance(Preferences.class).showAgain(key) && !DevEnv.isDevMode()) {
new Popup().warning(Res.get("popup.info.shutDownWithOpenOffers"))

View file

@ -822,6 +822,10 @@ tree-table-view:focused {
-fx-text-fill: -bs-rd-error-red;
}
.icon {
-fx-fill: -bs-text-color;
}
.opaque-icon {
-fx-fill: -bs-color-gray-bbb;
-fx-opacity: 1;

View file

@ -346,7 +346,7 @@ public class DepositView extends ActivatableView<VBox, Void> {
List<XmrAddressEntry> addressEntries = xmrWalletService.getAddressEntries();
List<DepositListItem> items = new ArrayList<>();
for (XmrAddressEntry addressEntry : addressEntries) {
if (addressEntry.isTrade()) continue; // skip reserved for trade
if (addressEntry.isTradePayout()) continue; // do not show trade payout addresses
items.add(new DepositListItem(addressEntry, xmrWalletService, formatter));
}

View file

@ -77,7 +77,7 @@ class WithdrawalListItem {
public final String getLabel() {
if (addressEntry.isOpenOffer())
return Res.getWithCol("shared.offerId") + " " + addressEntry.getShortOfferId();
else if (addressEntry.isTrade())
else if (addressEntry.isTradePayout())
return Res.getWithCol("shared.tradeId") + " " + addressEntry.getShortOfferId();
else if (addressEntry.getContext() == XmrAddressEntry.Context.ARBITRATOR)
return Res.get("funds.withdrawal.arbitrationFee");

View file

@ -82,7 +82,7 @@ import lombok.Getter;
import org.jetbrains.annotations.NotNull;
public abstract class MutableOfferDataModel extends OfferDataModel {
private final CreateOfferService createOfferService;
protected final CreateOfferService createOfferService;
protected final OpenOfferManager openOfferManager;
private final XmrWalletService xmrWalletService;
private final Preferences preferences;
@ -115,7 +115,7 @@ public abstract class MutableOfferDataModel extends OfferDataModel {
protected PaymentAccount paymentAccount;
boolean isTabSelected;
protected double marketPriceMargin = 0;
protected double marketPriceMarginPct = 0;
@Getter
private boolean marketPriceAvailable;
protected boolean allowAmountUpdate = true;
@ -189,12 +189,12 @@ public abstract class MutableOfferDataModel extends OfferDataModel {
}
private void addListeners() {
xmrWalletService.addBalanceListener(xmrBalanceListener);
if (xmrBalanceListener != null) xmrWalletService.addBalanceListener(xmrBalanceListener);
user.getPaymentAccountsAsObservable().addListener(paymentAccountsChangeListener);
}
private void removeListeners() {
xmrWalletService.removeBalanceListener(xmrBalanceListener);
if (xmrBalanceListener != null) xmrWalletService.removeBalanceListener(xmrBalanceListener);
user.getPaymentAccountsAsObservable().removeListener(paymentAccountsChangeListener);
}
@ -204,14 +204,16 @@ public abstract class MutableOfferDataModel extends OfferDataModel {
///////////////////////////////////////////////////////////////////////////////////////////
// called before activate()
public boolean initWithData(OfferDirection direction, TradeCurrency tradeCurrency) {
addressEntry = xmrWalletService.getOrCreateAddressEntry(offerId, XmrAddressEntry.Context.OFFER_FUNDING);
xmrBalanceListener = new XmrBalanceListener(getAddressEntry().getSubaddressIndex()) {
@Override
public void onBalanceChanged(BigInteger balance) {
updateBalances();
}
};
public boolean initWithData(OfferDirection direction, TradeCurrency tradeCurrency, boolean initAddressEntry) {
if (initAddressEntry) {
addressEntry = xmrWalletService.getOrCreateAddressEntry(offerId, XmrAddressEntry.Context.OFFER_FUNDING);
xmrBalanceListener = new XmrBalanceListener(getAddressEntry().getSubaddressIndex()) {
@Override
public void onBalanceChanged(BigInteger balance) {
updateBalances();
}
};
}
this.direction = direction;
this.tradeCurrency = tradeCurrency;
@ -278,6 +280,7 @@ public abstract class MutableOfferDataModel extends OfferDataModel {
}
protected void updateBalances() {
if (addressEntry == null) return;
super.updateBalances();
// update remaining balance
@ -302,7 +305,7 @@ public abstract class MutableOfferDataModel extends OfferDataModel {
minAmount.get(),
useMarketBasedPrice.get() ? null : price.get(),
useMarketBasedPrice.get(),
useMarketBasedPrice.get() ? marketPriceMargin : 0,
useMarketBasedPrice.get() ? marketPriceMarginPct : 0,
securityDepositPct.get(),
paymentAccount,
buyerAsTakerWithoutDeposit.get(), // private offer if buyer as taker without deposit
@ -316,6 +319,7 @@ public abstract class MutableOfferDataModel extends OfferDataModel {
triggerPrice,
reserveExactAmount,
false, // desktop ui resets address entries on cancel
null,
resultHandler,
errorMessageHandler);
}
@ -387,7 +391,7 @@ public abstract class MutableOfferDataModel extends OfferDataModel {
volume.set(null);
minVolume.set(null);
price.set(null);
marketPriceMargin = 0;
marketPriceMarginPct = 0;
}
this.tradeCurrency = tradeCurrency;
@ -416,10 +420,6 @@ public abstract class MutableOfferDataModel extends OfferDataModel {
updateBalances();
}
protected void setMarketPriceMarginPct(double marketPriceMargin) {
this.marketPriceMargin = marketPriceMargin;
}
///////////////////////////////////////////////////////////////////////////////////////////
// Getters
///////////////////////////////////////////////////////////////////////////////////////////
@ -469,7 +469,7 @@ public abstract class MutableOfferDataModel extends OfferDataModel {
}
public double getMarketPriceMarginPct() {
return marketPriceMargin;
return marketPriceMarginPct;
}
long getMaxTradeLimit() {
@ -609,6 +609,10 @@ public abstract class MutableOfferDataModel extends OfferDataModel {
this.triggerPrice = triggerPrice;
}
public void setMarketPriceMarginPct(double marketPriceMarginPct) {
this.marketPriceMarginPct = marketPriceMarginPct;
}
public void setReserveExactAmount(boolean reserveExactAmount) {
this.reserveExactAmount = reserveExactAmount;
}
@ -684,6 +688,14 @@ public abstract class MutableOfferDataModel extends OfferDataModel {
return Restrictions.getMinSecurityDeposit().max(value);
}
protected double getSecurityAsPercent(Offer offer) {
BigInteger offerSellerSecurityDeposit = getBoundedSecurityDeposit(offer.getMaxSellerSecurityDeposit());
double offerSellerSecurityDepositAsPercent = CoinUtil.getAsPercentPerXmr(offerSellerSecurityDeposit,
offer.getAmount());
return Math.min(offerSellerSecurityDepositAsPercent,
Restrictions.getMaxSecurityDepositAsPercent());
}
ReadOnlyObjectProperty<BigInteger> totalToPayAsProperty() {
return totalToPay;
}

View file

@ -297,11 +297,13 @@ public abstract class MutableOfferView<M extends MutableOfferViewModel<?>> exten
model.getDataModel().onTabSelected(isSelected);
}
public void initWithData(OfferDirection direction, TradeCurrency tradeCurrency,
public void initWithData(OfferDirection direction,
TradeCurrency tradeCurrency,
boolean initAddressEntry,
OfferView.OfferActionHandler offerActionHandler) {
this.offerActionHandler = offerActionHandler;
boolean result = model.initWithData(direction, tradeCurrency);
boolean result = model.initWithData(direction, tradeCurrency, initAddressEntry);
if (!result) {
new Popup().headLine(Res.get("popup.warning.noTradingAccountSetup.headline"))

View file

@ -601,8 +601,8 @@ public abstract class MutableOfferViewModel<M extends MutableOfferDataModel> ext
// API
///////////////////////////////////////////////////////////////////////////////////////////
boolean initWithData(OfferDirection direction, TradeCurrency tradeCurrency) {
boolean result = dataModel.initWithData(direction, tradeCurrency);
boolean initWithData(OfferDirection direction, TradeCurrency tradeCurrency, boolean initAddressEntry) {
boolean result = dataModel.initWithData(direction, tradeCurrency, initAddressEntry);
if (dataModel.getAddressEntry() != null) {
addressAsString = dataModel.getAddressEntry().getAddressString();
}

View file

@ -47,11 +47,10 @@ public class CreateOfferView extends MutableOfferView<CreateOfferViewModel> {
super(model, navigation, preferences, offerDetailsWindow, btcFormatter);
}
@Override
public void initWithData(OfferDirection direction,
TradeCurrency tradeCurrency,
OfferView.OfferActionHandler offerActionHandler) {
super.initWithData(direction, tradeCurrency, offerActionHandler);
super.initWithData(direction, tradeCurrency, true, offerActionHandler);
}
@Override

View file

@ -19,13 +19,12 @@ package haveno.desktop.main.offer.offerbook;
import com.google.inject.Inject;
import com.google.inject.Singleton;
import haveno.core.filter.FilterManager;
import haveno.common.UserThread;
import haveno.core.offer.Offer;
import haveno.core.offer.OfferBookService;
import static haveno.core.offer.OfferDirection.BUY;
import haveno.core.offer.OfferRestrictions;
import haveno.network.p2p.storage.P2PDataStorage;
import haveno.network.utils.Utils;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@ -49,7 +48,6 @@ public class OfferBook {
private final ObservableList<OfferBookListItem> offerBookListItems = FXCollections.observableArrayList();
private final Map<String, Integer> buyOfferCountMap = new HashMap<>();
private final Map<String, Integer> sellOfferCountMap = new HashMap<>();
private final FilterManager filterManager;
///////////////////////////////////////////////////////////////////////////////////////////
@ -57,64 +55,47 @@ public class OfferBook {
///////////////////////////////////////////////////////////////////////////////////////////
@Inject
OfferBook(OfferBookService offerBookService, FilterManager filterManager) {
OfferBook(OfferBookService offerBookService) {
this.offerBookService = offerBookService;
this.filterManager = filterManager;
offerBookService.addOfferBookChangedListener(new OfferBookService.OfferBookChangedListener() {
@Override
public void onAdded(Offer offer) {
printOfferBookListItems("Before onAdded");
// We get onAdded called every time a new ProtectedStorageEntry is received.
// Mostly it is the same OfferPayload but the ProtectedStorageEntry is different.
// We filter here to only add new offers if the same offer (using equals) was not already added and it
// is not banned.
UserThread.execute(() -> {
printOfferBookListItems("Before onAdded");
if (filterManager.isOfferIdBanned(offer.getId())) {
log.debug("Ignored banned offer. ID={}", offer.getId());
return;
}
if (OfferRestrictions.requiresNodeAddressUpdate() && !Utils.isV3Address(offer.getMakerNodeAddress().getHostName())) {
log.debug("Ignored offer with Tor v2 node address. ID={}", offer.getId());
return;
}
// Use offer.equals(offer) to see if the OfferBook list contains an exact
// match -- offer.equals(offer) includes comparisons of payload, state
// and errorMessage.
synchronized (offerBookListItems) {
boolean hasSameOffer = offerBookListItems.stream().anyMatch(item -> item.getOffer().equals(offer));
if (!hasSameOffer) {
OfferBookListItem newOfferBookListItem = new OfferBookListItem(offer);
removeDuplicateItem(newOfferBookListItem);
offerBookListItems.add(newOfferBookListItem); // Add replacement.
if (log.isDebugEnabled()) { // TODO delete debug stmt in future PR.
log.debug("onAdded: Added new offer {}\n"
+ "\twith newItem.payloadHash: {}",
offer.getId(),
newOfferBookListItem.hashOfPayload.getHex());
// Use offer.equals(offer) to see if the OfferBook list contains an exact
// match -- offer.equals(offer) includes comparisons of payload, state
// and errorMessage.
synchronized (offerBookListItems) {
boolean hasSameOffer = offerBookListItems.stream().anyMatch(item -> item.getOffer().equals(offer));
if (!hasSameOffer) {
OfferBookListItem newOfferBookListItem = new OfferBookListItem(offer);
removeDuplicateItem(newOfferBookListItem);
offerBookListItems.add(newOfferBookListItem); // Add replacement.
if (log.isDebugEnabled()) { // TODO delete debug stmt in future PR.
log.debug("onAdded: Added new offer {}\n"
+ "\twith newItem.payloadHash: {}",
offer.getId(),
newOfferBookListItem.hashOfPayload.getHex());
}
} else {
log.debug("We have the exact same offer already in our list and ignore the onAdded call. ID={}", offer.getId());
}
} else {
log.debug("We have the exact same offer already in our list and ignore the onAdded call. ID={}", offer.getId());
printOfferBookListItems("After onAdded");
}
printOfferBookListItems("After onAdded");
}
});
}
@Override
public void onRemoved(Offer offer) {
synchronized (offerBookListItems) {
printOfferBookListItems("Before onRemoved");
removeOffer(offer);
printOfferBookListItems("After onRemoved");
}
}
});
filterManager.filterProperty().addListener((observable, oldValue, newValue) -> {
if (newValue != null) {
// any notifications
UserThread.execute(() -> {
synchronized (offerBookListItems) {
printOfferBookListItems("Before onRemoved");
removeOffer(offer);
printOfferBookListItems("After onRemoved");
}
});
}
});
}
@ -212,7 +193,6 @@ public class OfferBook {
// Investigate why....
offerBookListItems.clear();
offerBookListItems.addAll(offerBookService.getOffers().stream()
.filter(this::isOfferAllowed)
.map(OfferBookListItem::new)
.collect(Collectors.toList()));
@ -248,13 +228,6 @@ public class OfferBook {
return sellOfferCountMap;
}
private boolean isOfferAllowed(Offer offer) {
boolean isBanned = filterManager.isOfferIdBanned(offer.getId());
boolean isV3NodeAddressCompliant = !OfferRestrictions.requiresNodeAddressUpdate()
|| Utils.isV3Address(offer.getMakerNodeAddress().getHostName());
return !isBanned && isV3NodeAddressCompliant;
}
private void fillOfferCountMaps() {
buyOfferCountMap.clear();
sellOfferCountMap.clear();

View file

@ -695,8 +695,13 @@ abstract public class OfferBookView<R extends GridPane, M extends OfferBookViewM
case SIGNATURE_NOT_VALIDATED:
new Popup().warning(Res.get("offerbook.warning.signatureNotValidated")).show();
break;
case RESERVE_FUNDS_SPENT:
new Popup().warning(Res.get("offerbook.warning.reserveFundsSpent")).show();
break;
case VALID:
break;
default:
log.warn("Unhandled offer filter service result: " + result);
break;
}
}

View file

@ -173,6 +173,11 @@ abstract class OfferBookViewModel extends ActivatableViewModel {
tradeCurrencyListChangeListener = c -> fillCurrencies();
// refresh filter on changes
offerBook.getOfferBookListItems().addListener((ListChangeListener<OfferBookListItem>) c -> {
filterOffers();
});
filterItemsListener = c -> {
final Optional<OfferBookListItem> highestAmountOffer = filteredItems.stream()
.max(Comparator.comparingLong(o -> o.getOffer().getAmount().longValueExact()));

View file

@ -30,6 +30,8 @@ import haveno.desktop.common.view.CachingViewLoader;
import haveno.desktop.common.view.FxmlView;
import haveno.desktop.common.view.View;
import haveno.desktop.main.MainView;
import haveno.desktop.main.overlays.popups.Popup;
import haveno.desktop.main.portfolio.cloneoffer.CloneOfferView;
import haveno.desktop.main.portfolio.closedtrades.ClosedTradesView;
import haveno.desktop.main.portfolio.duplicateoffer.DuplicateOfferView;
import haveno.desktop.main.portfolio.editoffer.EditOfferView;
@ -49,7 +51,7 @@ public class PortfolioView extends ActivatableView<TabPane, Void> {
@FXML
Tab openOffersTab, pendingTradesTab, closedTradesTab;
private Tab editOpenOfferTab, duplicateOfferTab;
private Tab editOpenOfferTab, duplicateOfferTab, cloneOpenOfferTab;
private final Tab failedTradesTab = new Tab(Res.get("portfolio.tab.failed").toUpperCase());
private Tab currentTab;
private Navigation.Listener navigationListener;
@ -61,7 +63,8 @@ public class PortfolioView extends ActivatableView<TabPane, Void> {
private final FailedTradesManager failedTradesManager;
private EditOfferView editOfferView;
private DuplicateOfferView duplicateOfferView;
private boolean editOpenOfferViewOpen;
private CloneOfferView cloneOfferView;
private boolean editOpenOfferViewOpen, cloneOpenOfferViewOpen;
private OpenOffer openOffer;
private OpenOffersView openOffersView;
@ -99,12 +102,16 @@ public class PortfolioView extends ActivatableView<TabPane, Void> {
navigation.navigateTo(MainView.class, PortfolioView.class, EditOfferView.class);
else if (newValue == duplicateOfferTab) {
navigation.navigateTo(MainView.class, PortfolioView.class, DuplicateOfferView.class);
} else if (newValue == cloneOpenOfferTab) {
navigation.navigateTo(MainView.class, PortfolioView.class, CloneOfferView.class);
}
if (oldValue != null && oldValue == editOpenOfferTab)
editOfferView.onTabSelected(false);
if (oldValue != null && oldValue == duplicateOfferTab)
duplicateOfferView.onTabSelected(false);
if (oldValue != null && oldValue == cloneOpenOfferTab)
cloneOfferView.onTabSelected(false);
};
@ -115,6 +122,8 @@ public class PortfolioView extends ActivatableView<TabPane, Void> {
onEditOpenOfferRemoved();
if (removedTabs.size() == 1 && removedTabs.get(0).equals(duplicateOfferTab))
onDuplicateOfferRemoved();
if (removedTabs.size() == 1 && removedTabs.get(0).equals(cloneOpenOfferTab))
onCloneOpenOfferRemoved();
};
}
@ -137,6 +146,16 @@ public class PortfolioView extends ActivatableView<TabPane, Void> {
navigation.navigateTo(MainView.class, this.getClass(), OpenOffersView.class);
}
private void onCloneOpenOfferRemoved() {
cloneOpenOfferViewOpen = false;
if (cloneOfferView != null) {
cloneOfferView.onClose();
cloneOfferView = null;
}
navigation.navigateTo(MainView.class, this.getClass(), OpenOffersView.class);
}
@Override
protected void activate() {
failedTradesManager.getObservableList().addListener((ListChangeListener<Trade>) c -> {
@ -166,6 +185,9 @@ public class PortfolioView extends ActivatableView<TabPane, Void> {
} else if (root.getSelectionModel().getSelectedItem() == duplicateOfferTab) {
navigation.navigateTo(MainView.class, PortfolioView.class, DuplicateOfferView.class);
if (duplicateOfferView != null) duplicateOfferView.onTabSelected(true);
} else if (root.getSelectionModel().getSelectedItem() == cloneOpenOfferTab) {
navigation.navigateTo(MainView.class, PortfolioView.class, CloneOfferView.class);
if (cloneOfferView != null) cloneOfferView.onTabSelected(true);
}
}
@ -178,10 +200,9 @@ public class PortfolioView extends ActivatableView<TabPane, Void> {
}
private void loadView(Class<? extends View> viewClass, @Nullable Object data) {
// we want to get activate/deactivate called, so we remove the old view on tab change
// TODO Don't understand the check for currentTab != editOpenOfferTab
if (currentTab != null && currentTab != editOpenOfferTab)
currentTab.setContent(null);
// nullify current tab to trigger activate/deactivate
if (currentTab != null) currentTab.setContent(null);
View view = viewLoader.load(viewClass);
@ -235,6 +256,28 @@ public class PortfolioView extends ActivatableView<TabPane, Void> {
view = viewLoader.load(OpenOffersView.class);
selectOpenOffersView((OpenOffersView) view);
}
} else if (view instanceof CloneOfferView) {
if (data instanceof OpenOffer) {
openOffer = (OpenOffer) data;
}
if (openOffer != null) {
if (cloneOfferView == null) {
cloneOfferView = (CloneOfferView) view;
cloneOfferView.applyOpenOffer(openOffer);
cloneOpenOfferTab = new Tab(Res.get("portfolio.tab.cloneOpenOffer").toUpperCase());
cloneOfferView.setCloseHandler(() -> {
root.getTabs().remove(cloneOpenOfferTab);
});
root.getTabs().add(cloneOpenOfferTab);
}
if (currentTab != cloneOpenOfferTab)
cloneOfferView.onTabSelected(true);
currentTab = cloneOpenOfferTab;
} else {
view = viewLoader.load(OpenOffersView.class);
selectOpenOffersView((OpenOffersView) view);
}
}
currentTab.setContent(view.getRoot());
@ -245,20 +288,35 @@ public class PortfolioView extends ActivatableView<TabPane, Void> {
openOffersView = view;
currentTab = openOffersTab;
OpenOfferActionHandler openOfferActionHandler = openOffer -> {
EditOpenOfferHandler editOpenOfferHandler = openOffer -> {
if (!editOpenOfferViewOpen) {
editOpenOfferViewOpen = true;
PortfolioView.this.openOffer = openOffer;
navigation.navigateTo(MainView.class, PortfolioView.this.getClass(), EditOfferView.class);
} else {
log.error("You have already a \"Edit Offer\" tab open.");
new Popup().warning(Res.get("editOffer.openTabWarning")).show();
}
};
openOffersView.setOpenOfferActionHandler(openOfferActionHandler);
openOffersView.setEditOpenOfferHandler(editOpenOfferHandler);
CloneOpenOfferHandler cloneOpenOfferHandler = openOffer -> {
if (!cloneOpenOfferViewOpen) {
cloneOpenOfferViewOpen = true;
PortfolioView.this.openOffer = openOffer;
navigation.navigateTo(MainView.class, PortfolioView.this.getClass(), CloneOfferView.class);
} else {
new Popup().warning(Res.get("cloneOffer.openTabWarning")).show();
}
};
openOffersView.setCloneOpenOfferHandler(cloneOpenOfferHandler);
}
public interface OpenOfferActionHandler {
public interface EditOpenOfferHandler {
void onEditOpenOffer(OpenOffer openOffer);
}
public interface CloneOpenOfferHandler {
void onCloneOpenOffer(OpenOffer openOffer);
}
}

View file

@ -0,0 +1,195 @@
/*
* This file is part of Bisq.
*
* Bisq is free software: you can redistribute it and/or modify it
* under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or (at
* your option) any later version.
*
* Bisq is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public
* License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Bisq. If not, see <http://www.gnu.org/licenses/>.
*/
package haveno.desktop.main.portfolio.cloneoffer;
import haveno.desktop.Navigation;
import haveno.desktop.main.offer.MutableOfferDataModel;
import haveno.core.account.witness.AccountAgeWitnessService;
import haveno.core.locale.CurrencyUtil;
import haveno.core.locale.TradeCurrency;
import haveno.core.offer.CreateOfferService;
import haveno.core.offer.Offer;
import haveno.core.offer.OfferDirection;
import haveno.core.offer.OfferUtil;
import haveno.core.offer.OpenOffer;
import haveno.core.offer.OpenOfferManager;
import haveno.core.payment.PaymentAccount;
import haveno.core.proto.persistable.CorePersistenceProtoResolver;
import haveno.core.provider.price.PriceFeedService;
import haveno.core.trade.statistics.TradeStatisticsManager;
import haveno.core.user.Preferences;
import haveno.core.user.User;
import haveno.core.util.FormattingUtils;
import haveno.core.util.coin.CoinFormatter;
import haveno.core.xmr.wallet.XmrWalletService;
import haveno.network.p2p.P2PService;
import haveno.common.handlers.ErrorMessageHandler;
import haveno.common.handlers.ResultHandler;
import com.google.inject.Inject;
import com.google.inject.name.Named;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.stream.Collectors;
class CloneOfferDataModel extends MutableOfferDataModel {
private final CorePersistenceProtoResolver corePersistenceProtoResolver;
private OpenOffer sourceOpenOffer;
@Inject
CloneOfferDataModel(CreateOfferService createOfferService,
OpenOfferManager openOfferManager,
OfferUtil offerUtil,
XmrWalletService xmrWalletService,
Preferences preferences,
User user,
P2PService p2PService,
PriceFeedService priceFeedService,
AccountAgeWitnessService accountAgeWitnessService,
@Named(FormattingUtils.BTC_FORMATTER_KEY) CoinFormatter xmrFormatter,
CorePersistenceProtoResolver corePersistenceProtoResolver,
TradeStatisticsManager tradeStatisticsManager,
Navigation navigation) {
super(createOfferService,
openOfferManager,
offerUtil,
xmrWalletService,
preferences,
user,
p2PService,
priceFeedService,
accountAgeWitnessService,
xmrFormatter,
tradeStatisticsManager,
navigation);
this.corePersistenceProtoResolver = corePersistenceProtoResolver;
}
public void reset() {
direction = null;
tradeCurrency = null;
tradeCurrencyCode.set(null);
useMarketBasedPrice.set(false);
amount.set(null);
minAmount.set(null);
price.set(null);
volume.set(null);
minVolume.set(null);
securityDepositPct.set(0);
paymentAccounts.clear();
paymentAccount = null;
marketPriceMarginPct = 0;
sourceOpenOffer = null;
}
public void applyOpenOffer(OpenOffer openOffer) {
this.sourceOpenOffer = openOffer;
Offer offer = openOffer.getOffer();
direction = offer.getDirection();
CurrencyUtil.getTradeCurrency(offer.getCurrencyCode())
.ifPresent(c -> this.tradeCurrency = c);
tradeCurrencyCode.set(offer.getCurrencyCode());
PaymentAccount tmpPaymentAccount = user.getPaymentAccount(openOffer.getOffer().getMakerPaymentAccountId());
Optional<TradeCurrency> optionalTradeCurrency = CurrencyUtil.getTradeCurrency(openOffer.getOffer().getCurrencyCode());
if (optionalTradeCurrency.isPresent() && tmpPaymentAccount != null) {
TradeCurrency selectedTradeCurrency = optionalTradeCurrency.get();
this.paymentAccount = PaymentAccount.fromProto(tmpPaymentAccount.toProtoMessage(), corePersistenceProtoResolver);
if (paymentAccount.getSingleTradeCurrency() != null)
paymentAccount.setSingleTradeCurrency(selectedTradeCurrency);
else
paymentAccount.setSelectedTradeCurrency(selectedTradeCurrency);
}
allowAmountUpdate = false;
}
public boolean initWithData(OfferDirection direction, TradeCurrency tradeCurrency) {
try {
return super.initWithData(direction, tradeCurrency, false);
} catch (NullPointerException e) {
if (e.getMessage().contains("tradeCurrency")) {
throw new IllegalArgumentException("Offers of removed assets cannot be edited. You can only cancel it.", e);
}
return false;
}
}
@Override
protected Set<PaymentAccount> getUserPaymentAccounts() {
return Objects.requireNonNull(user.getPaymentAccounts()).stream()
.filter(account -> !account.getPaymentMethod().isBsqSwap())
.collect(Collectors.toSet());
}
@Override
protected PaymentAccount getPreselectedPaymentAccount() {
return paymentAccount;
}
public void populateData() {
Offer offer = sourceOpenOffer.getOffer();
// Min amount need to be set before amount as if minAmount is null it would be set by amount
setMinAmount(offer.getMinAmount());
setAmount(offer.getAmount());
setPrice(offer.getPrice());
setVolume(offer.getVolume());
setUseMarketBasedPrice(offer.isUseMarketBasedPrice());
setTriggerPrice(sourceOpenOffer.getTriggerPrice());
if (offer.isUseMarketBasedPrice()) {
setMarketPriceMarginPct(offer.getMarketPriceMarginPct());
}
setBuyerAsTakerWithoutDeposit(offer.hasBuyerAsTakerWithoutDeposit());
setSecurityDepositPct(getSecurityAsPercent(offer));
setExtraInfo(offer.getOfferExtraInfo());
}
public void onCloneOffer(ResultHandler resultHandler, ErrorMessageHandler errorMessageHandler) {
Offer clonedOffer = createClonedOffer();
openOfferManager.placeOffer(clonedOffer,
false,
triggerPrice,
false,
true,
sourceOpenOffer.getId(),
transaction -> resultHandler.handleResult(),
errorMessageHandler);
}
private Offer createClonedOffer() {
return createOfferService.createClonedOffer(sourceOpenOffer.getOffer(),
tradeCurrencyCode.get(),
useMarketBasedPrice.get() ? null : price.get(),
useMarketBasedPrice.get(),
useMarketBasedPrice.get() ? marketPriceMarginPct : 0,
paymentAccount,
extraInfo.get());
}
public boolean hasConflictingClone() {
Offer clonedOffer = createClonedOffer();
return openOfferManager.hasConflictingClone(clonedOffer, sourceOpenOffer);
}
}

View file

@ -0,0 +1,24 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
~ This file is part of Bisq.
~
~ Bisq is free software: you can redistribute it and/or modify it
~ under the terms of the GNU Affero General Public License as published by
~ the Free Software Foundation, either version 3 of the License, or (at
~ your option) any later version.
~
~ Bisq is distributed in the hope that it will be useful, but WITHOUT
~ ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
~ FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public
~ License for more details.
~
~ You should have received a copy of the GNU Affero General Public License
~ along with Bisq. If not, see <http://www.gnu.org/licenses/>.
-->
<?import javafx.scene.layout.AnchorPane?>
<AnchorPane fx:id="root" fx:controller="haveno.desktop.main.portfolio.cloneoffer.CloneOfferView"
xmlns:fx="http://javafx.com/fxml">
</AnchorPane>

View file

@ -0,0 +1,261 @@
/*
* This file is part of Bisq.
*
* Bisq is free software: you can redistribute it and/or modify it
* under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or (at
* your option) any later version.
*
* Bisq is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public
* License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Bisq. If not, see <http://www.gnu.org/licenses/>.
*/
package haveno.desktop.main.portfolio.cloneoffer;
import haveno.desktop.Navigation;
import haveno.desktop.common.view.FxmlView;
import haveno.desktop.components.AutoTooltipButton;
import haveno.desktop.components.BusyAnimation;
import haveno.desktop.main.offer.MutableOfferView;
import haveno.desktop.main.overlays.popups.Popup;
import haveno.desktop.main.overlays.windows.OfferDetailsWindow;
import haveno.core.locale.CurrencyUtil;
import haveno.core.locale.Res;
import haveno.core.offer.OpenOffer;
import haveno.core.payment.PaymentAccount;
import haveno.core.user.DontShowAgainLookup;
import haveno.core.user.Preferences;
import haveno.core.util.FormattingUtils;
import haveno.core.util.coin.CoinFormatter;
import haveno.common.UserThread;
import haveno.common.util.Tuple4;
import com.google.inject.Inject;
import com.google.inject.name.Named;
import javafx.scene.control.Button;
import javafx.scene.control.Label;
import javafx.scene.image.ImageView;
import javafx.scene.layout.GridPane;
import javafx.scene.layout.HBox;
import javafx.geometry.HPos;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.collections.ObservableList;
import static haveno.desktop.util.FormBuilder.addButtonBusyAnimationLabelAfterGroup;
@FxmlView
public class CloneOfferView extends MutableOfferView<CloneOfferViewModel> {
private BusyAnimation busyAnimation;
private Button cloneButton;
private Button cancelButton;
private Label spinnerInfoLabel;
///////////////////////////////////////////////////////////////////////////////////////////
// Constructor, lifecycle
///////////////////////////////////////////////////////////////////////////////////////////
@Inject
private CloneOfferView(CloneOfferViewModel model,
Navigation navigation,
Preferences preferences,
OfferDetailsWindow offerDetailsWindow,
@Named(FormattingUtils.BTC_FORMATTER_KEY) CoinFormatter btcFormatter) {
super(model, navigation, preferences, offerDetailsWindow, btcFormatter);
}
@Override
protected void initialize() {
super.initialize();
addCloneGroup();
renameAmountGroup();
}
private void renameAmountGroup() {
amountTitledGroupBg.setText(Res.get("editOffer.setPrice"));
}
@Override
protected void doSetFocus() {
// Don't focus in any field before data was set
}
@Override
protected void doActivate() {
super.doActivate();
addBindings();
hideOptionsGroup();
hideNextButtons();
// Lock amount field as it would require bigger changes to support increased amount values.
amountTextField.setDisable(true);
amountBtcLabel.setDisable(true);
minAmountTextField.setDisable(true);
minAmountBtcLabel.setDisable(true);
volumeTextField.setDisable(true);
volumeCurrencyLabel.setDisable(true);
// Workaround to fix margin on top of amount group
gridPane.setPadding(new Insets(-20, 25, -1, 25));
updatePriceToggle();
updateElementsWithDirection();
model.isNextButtonDisabled.setValue(false);
cancelButton.setDisable(false);
model.onInvalidateMarketPriceMargin();
model.onInvalidatePrice();
// To force re-validation of payment account validation
onPaymentAccountsComboBoxSelected();
}
@Override
protected void deactivate() {
super.deactivate();
removeBindings();
}
@Override
public void onClose() {
}
///////////////////////////////////////////////////////////////////////////////////////////
// API
///////////////////////////////////////////////////////////////////////////////////////////
public void applyOpenOffer(OpenOffer openOffer) {
model.applyOpenOffer(openOffer);
initWithData(openOffer.getOffer().getDirection(),
CurrencyUtil.getTradeCurrency(openOffer.getOffer().getCurrencyCode()).get(),
false,
null);
if (!model.isSecurityDepositValid()) {
new Popup().warning(Res.get("editOffer.invalidDeposit"))
.onClose(this::close)
.show();
}
}
///////////////////////////////////////////////////////////////////////////////////////////
// Bindings, Listeners
///////////////////////////////////////////////////////////////////////////////////////////
private void addBindings() {
cloneButton.disableProperty().bind(model.isNextButtonDisabled);
}
private void removeBindings() {
cloneButton.disableProperty().unbind();
}
@Override
protected ObservableList<PaymentAccount> filterPaymentAccounts(ObservableList<PaymentAccount> paymentAccounts) {
return paymentAccounts;
}
///////////////////////////////////////////////////////////////////////////////////////////
// Build UI elements
///////////////////////////////////////////////////////////////////////////////////////////
private void addCloneGroup() {
Tuple4<Button, BusyAnimation, Label, HBox> tuple4 = addButtonBusyAnimationLabelAfterGroup(gridPane, 6, Res.get("cloneOffer.clone"));
HBox hBox = tuple4.fourth;
hBox.setAlignment(Pos.CENTER_LEFT);
GridPane.setHalignment(hBox, HPos.LEFT);
cloneButton = tuple4.first;
cloneButton.setMinHeight(40);
cloneButton.setPadding(new Insets(0, 20, 0, 20));
cloneButton.setGraphicTextGap(10);
busyAnimation = tuple4.second;
spinnerInfoLabel = tuple4.third;
cancelButton = new AutoTooltipButton(Res.get("shared.cancel"));
cancelButton.setDefaultButton(false);
cancelButton.setOnAction(event -> close());
hBox.getChildren().add(cancelButton);
cloneButton.setOnAction(e -> {
cloneButton.requestFocus(); // fix issue #5460 (when enter key used, focus is wrong)
onClone();
});
}
private void onClone() {
if (model.dataModel.hasConflictingClone()) {
new Popup().warning(Res.get("cloneOffer.hasConflictingClone"))
.actionButtonText(Res.get("shared.yes"))
.onAction(this::doClone)
.closeButtonText(Res.get("shared.no"))
.show();
} else {
doClone();
}
}
private void doClone() {
if (model.isPriceInRange()) {
model.isNextButtonDisabled.setValue(true);
cancelButton.setDisable(true);
busyAnimation.play();
spinnerInfoLabel.setText(Res.get("cloneOffer.publishOffer"));
model.onCloneOffer(() -> {
UserThread.execute(() -> {
String key = "cloneOfferSuccess";
if (DontShowAgainLookup.showAgain(key)) {
new Popup()
.feedback(Res.get("cloneOffer.success"))
.dontShowAgainId(key)
.show();
}
spinnerInfoLabel.setText("");
busyAnimation.stop();
close();
});
},
errorMessage -> {
UserThread.execute(() -> {
log.error(errorMessage);
spinnerInfoLabel.setText("");
busyAnimation.stop();
model.isNextButtonDisabled.setValue(false);
cancelButton.setDisable(false);
new Popup().warning(errorMessage).show();
});
});
}
}
///////////////////////////////////////////////////////////////////////////////////////////
// Utils
///////////////////////////////////////////////////////////////////////////////////////////
private void updateElementsWithDirection() {
ImageView iconView = new ImageView();
iconView.setId(model.isShownAsSellOffer() ? "image-sell-white" : "image-buy-white");
cloneButton.setGraphic(iconView);
cloneButton.setId(model.isShownAsSellOffer() ? "sell-button-big" : "buy-button-big");
}
}

View file

@ -0,0 +1,120 @@
/*
* This file is part of Bisq.
*
* Bisq is free software: you can redistribute it and/or modify it
* under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or (at
* your option) any later version.
*
* Bisq is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public
* License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Bisq. If not, see <http://www.gnu.org/licenses/>.
*/
package haveno.desktop.main.portfolio.cloneoffer;
import haveno.desktop.Navigation;
import haveno.desktop.main.offer.MutableOfferViewModel;
import haveno.desktop.main.offer.OfferViewUtil;
import haveno.core.account.witness.AccountAgeWitnessService;
import haveno.core.offer.OfferUtil;
import haveno.core.offer.OpenOffer;
import haveno.core.payment.validation.FiatVolumeValidator;
import haveno.core.payment.validation.SecurityDepositValidator;
import haveno.core.payment.validation.XmrValidator;
import haveno.core.provider.price.PriceFeedService;
import haveno.core.user.Preferences;
import haveno.core.util.FormattingUtils;
import haveno.core.util.PriceUtil;
import haveno.core.util.coin.CoinFormatter;
import haveno.core.util.validation.AmountValidator4Decimals;
import haveno.core.util.validation.AmountValidator8Decimals;
import haveno.common.handlers.ErrorMessageHandler;
import haveno.common.handlers.ResultHandler;
import com.google.inject.Inject;
import com.google.inject.name.Named;
class CloneOfferViewModel extends MutableOfferViewModel<CloneOfferDataModel> {
@Inject
public CloneOfferViewModel(CloneOfferDataModel dataModel,
FiatVolumeValidator fiatVolumeValidator,
AmountValidator4Decimals priceValidator4Decimals,
AmountValidator8Decimals priceValidator8Decimals,
XmrValidator xmrValidator,
SecurityDepositValidator securityDepositValidator,
PriceFeedService priceFeedService,
AccountAgeWitnessService accountAgeWitnessService,
Navigation navigation,
Preferences preferences,
@Named(FormattingUtils.BTC_FORMATTER_KEY) CoinFormatter btcFormatter,
OfferUtil offerUtil) {
super(dataModel,
fiatVolumeValidator,
priceValidator4Decimals,
priceValidator8Decimals,
xmrValidator,
securityDepositValidator,
priceFeedService,
accountAgeWitnessService,
navigation,
preferences,
btcFormatter,
offerUtil);
syncMinAmountWithAmount = false;
}
@Override
public void activate() {
super.activate();
dataModel.populateData();
long triggerPriceAsLong = dataModel.getTriggerPrice();
dataModel.setTriggerPrice(triggerPriceAsLong);
if (triggerPriceAsLong > 0) {
triggerPrice.set(PriceUtil.formatMarketPrice(triggerPriceAsLong, dataModel.getCurrencyCode()));
} else {
triggerPrice.set("");
}
onTriggerPriceTextFieldChanged();
}
public void applyOpenOffer(OpenOffer openOffer) {
dataModel.reset();
dataModel.applyOpenOffer(openOffer);
}
public void onCloneOffer(ResultHandler resultHandler, ErrorMessageHandler errorMessageHandler) {
dataModel.onCloneOffer(resultHandler, errorMessageHandler);
}
public void onInvalidateMarketPriceMargin() {
marketPriceMargin.set(FormattingUtils.formatToPercent(dataModel.getMarketPriceMarginPct()));
}
public void onInvalidatePrice() {
price.set(FormattingUtils.formatPrice(null));
price.set(FormattingUtils.formatPrice(dataModel.getPrice().get()));
}
public boolean isSecurityDepositValid() {
return securityDepositValidator.validate(securityDeposit.get()).isValid;
}
@Override
public void triggerFocusOutOnAmountFields() {
// do not update BTC Amount or minAmount here
// issue 2798: "after a few edits of offer the BTC amount has increased"
}
public boolean isShownAsSellOffer() {
return OfferViewUtil.isShownAsSellOffer(getTradeCurrency(), dataModel.getDirection());
}
}

View file

@ -444,7 +444,7 @@ public class ClosedTradesView extends ActivatableViewAndModel<VBox, ClosedTrades
if (item != null && !empty && isMyOfferAsMaker(item.getTradable().getOffer().getOfferPayload())) {
if (button == null) {
button = FormBuilder.getRegularIconButton(MaterialDesignIcon.CONTENT_COPY);
button.setTooltip(new Tooltip(Res.get("shared.duplicateOffer")));
button.setTooltip(new Tooltip(Res.get("portfolio.context.offerLikeThis")));
setGraphic(button);
}
button.setOnAction(event -> onDuplicateOffer(item.getTradable().getOffer()));

View file

@ -35,13 +35,10 @@ import haveno.core.user.Preferences;
import haveno.core.user.User;
import haveno.core.util.FormattingUtils;
import haveno.core.util.coin.CoinFormatter;
import haveno.core.util.coin.CoinUtil;
import haveno.core.xmr.wallet.Restrictions;
import haveno.core.xmr.wallet.XmrWalletService;
import haveno.desktop.Navigation;
import haveno.desktop.main.offer.MutableOfferDataModel;
import haveno.network.p2p.P2PService;
import java.math.BigInteger;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
@ -101,14 +98,6 @@ class DuplicateOfferDataModel extends MutableOfferDataModel {
if (openOffer != null) setTriggerPrice(openOffer.getTriggerPrice());
}
private double getSecurityAsPercent(Offer offer) {
BigInteger offerSellerSecurityDeposit = getBoundedSecurityDeposit(offer.getMaxSellerSecurityDeposit());
double offerSellerSecurityDepositAsPercent = CoinUtil.getAsPercentPerXmr(offerSellerSecurityDeposit,
offer.getAmount());
return Math.min(offerSellerSecurityDepositAsPercent,
Restrictions.getMaxSecurityDepositAsPercent());
}
@Override
protected Set<PaymentAccount> getUserPaymentAccounts() {
return Objects.requireNonNull(user.getPaymentAccounts()).stream()

View file

@ -70,6 +70,7 @@ public class DuplicateOfferView extends MutableOfferView<DuplicateOfferViewModel
public void initWithData(OfferPayload offerPayload) {
initWithData(offerPayload.getDirection(),
CurrencyUtil.getTradeCurrency(offerPayload.getCurrencyCode()).get(),
true,
null);
model.initWithData(offerPayload);
}

View file

@ -21,7 +21,6 @@ package haveno.desktop.main.portfolio.editoffer;
import com.google.inject.Inject;
import com.google.inject.name.Named;
import haveno.common.UserThread;
import haveno.common.handlers.ErrorMessageHandler;
import haveno.common.handlers.ResultHandler;
import haveno.core.account.witness.AccountAgeWitnessService;
@ -56,6 +55,7 @@ class EditOfferDataModel extends MutableOfferDataModel {
private final CorePersistenceProtoResolver corePersistenceProtoResolver;
private OpenOffer openOffer;
private OpenOffer.State initialState;
private Offer editedOffer;
@Inject
EditOfferDataModel(CreateOfferService createOfferService,
@ -100,7 +100,7 @@ class EditOfferDataModel extends MutableOfferDataModel {
securityDepositPct.set(0);
paymentAccounts.clear();
paymentAccount = null;
marketPriceMargin = 0;
marketPriceMarginPct = 0;
}
public void applyOpenOffer(OpenOffer openOffer) {
@ -142,10 +142,9 @@ class EditOfferDataModel extends MutableOfferDataModel {
extraInfo.set(offer.getOfferExtraInfo());
}
@Override
public boolean initWithData(OfferDirection direction, TradeCurrency tradeCurrency) {
try {
return super.initWithData(direction, tradeCurrency);
return super.initWithData(direction, tradeCurrency, false);
} catch (NullPointerException e) {
if (e.getMessage().contains("tradeCurrency")) {
throw new IllegalArgumentException("Offers of removed assets cannot be edited. You can only cancel it.", e);
@ -225,15 +224,16 @@ class EditOfferDataModel extends MutableOfferDataModel {
offerPayload.getReserveTxKeyImages(),
newOfferPayload.getExtraInfo());
final Offer editedOffer = new Offer(editedPayload);
editedOffer = new Offer(editedPayload);
editedOffer.setPriceFeedService(priceFeedService);
editedOffer.setState(Offer.State.AVAILABLE);
openOfferManager.editOpenOfferPublish(editedOffer, triggerPrice, initialState, () -> {
resultHandler.handleResult(); // process result before nullifying state
openOffer = null;
UserThread.execute(() -> resultHandler.handleResult());
editedOffer = null;
}, (errorMsg) -> {
UserThread.execute(() -> errorMessageHandler.handleErrorMessage(errorMsg));
errorMessageHandler.handleErrorMessage(errorMsg);
});
}
@ -243,6 +243,15 @@ class EditOfferDataModel extends MutableOfferDataModel {
}, errorMessageHandler);
}
public boolean hasConflictingClone() {
Optional<OpenOffer> editedOpenOffer = openOfferManager.getOpenOffer(openOffer.getId());
if (!editedOpenOffer.isPresent()) {
log.warn("Edited open offer is no longer present");
return false;
}
return openOfferManager.hasConflictingClone(editedOpenOffer.get());
}
@Override
protected Set<PaymentAccount> getUserPaymentAccounts() {
throw new RuntimeException("Edit offer not supported with XMR");

View file

@ -19,6 +19,8 @@ package haveno.desktop.main.portfolio.editoffer;
import com.google.inject.Inject;
import com.google.inject.name.Named;
import haveno.common.UserThread;
import haveno.common.util.Tuple4;
import haveno.core.locale.CurrencyUtil;
import haveno.core.locale.Res;
@ -140,6 +142,7 @@ public class EditOfferView extends MutableOfferView<EditOfferViewModel> {
initWithData(openOffer.getOffer().getDirection(),
CurrencyUtil.getTradeCurrency(openOffer.getOffer().getCurrencyCode()).get(),
false,
null);
model.onStartEditOffer(errorMessage -> {
@ -208,23 +211,31 @@ public class EditOfferView extends MutableOfferView<EditOfferViewModel> {
// edit offer
model.onPublishOffer(() -> {
String key = "editOfferSuccess";
if (DontShowAgainLookup.showAgain(key)) {
new Popup()
.feedback(Res.get("editOffer.success"))
.dontShowAgainId(key)
.show();
if (model.dataModel.hasConflictingClone()) {
new Popup().warning(Res.get("editOffer.hasConflictingClone")).show();
} else {
String key = "editOfferSuccess";
if (DontShowAgainLookup.showAgain(key)) {
new Popup()
.feedback(Res.get("editOffer.success"))
.dontShowAgainId(key)
.show();
}
}
spinnerInfoLabel.setText("");
busyAnimation.stop();
close();
UserThread.execute(() -> {
spinnerInfoLabel.setText("");
busyAnimation.stop();
close();
});
}, (message) -> {
log.error(message);
spinnerInfoLabel.setText("");
busyAnimation.stop();
model.isNextButtonDisabled.setValue(false);
cancelButton.setDisable(false);
new Popup().warning(Res.get("editOffer.failed", message)).show();
UserThread.execute(() -> {
log.error(message);
spinnerInfoLabel.setText("");
busyAnimation.stop();
model.isNextButtonDisabled.setValue(false);
cancelButton.setDisable(false);
new Popup().warning(Res.get("editOffer.failed", message)).show();
});
});
}
});

View file

@ -44,7 +44,7 @@ class EditOfferViewModel extends MutableOfferViewModel<EditOfferDataModel> {
FiatVolumeValidator fiatVolumeValidator,
AmountValidator4Decimals priceValidator4Decimals,
AmountValidator8Decimals priceValidator8Decimals,
XmrValidator btcValidator,
XmrValidator xmrValidator,
SecurityDepositValidator securityDepositValidator,
PriceFeedService priceFeedService,
AccountAgeWitnessService accountAgeWitnessService,
@ -56,7 +56,7 @@ class EditOfferViewModel extends MutableOfferViewModel<EditOfferDataModel> {
fiatVolumeValidator,
priceValidator4Decimals,
priceValidator8Decimals,
btcValidator,
xmrValidator,
securityDepositValidator,
priceFeedService,
accountAgeWitnessService,

View file

@ -39,4 +39,8 @@ class OpenOfferListItem {
public Offer getOffer() {
return openOffer.getOffer();
}
public String getGroupId() {
return openOffer.getGroupId();
}
}

View file

@ -42,7 +42,8 @@
</HBox>
<TableView fx:id="tableView" VBox.vgrow="ALWAYS">
<columns>
<TableColumn fx:id="offerIdColumn" minWidth="110" maxWidth="120"/>
<TableColumn fx:id="offerIdColumn" minWidth="100" maxWidth="120"/>
<TableColumn fx:id="groupIdColumn" minWidth="70"/>
<TableColumn fx:id="dateColumn" minWidth="170"/>
<TableColumn fx:id="marketColumn" minWidth="75"/>
<TableColumn fx:id="priceColumn" minWidth="100"/>
@ -50,11 +51,13 @@
<TableColumn fx:id="triggerPriceColumn" minWidth="90"/>
<TableColumn fx:id="amountColumn" minWidth="110"/>
<TableColumn fx:id="volumeColumn" minWidth="110"/>
<TableColumn fx:id="paymentMethodColumn" minWidth="120" maxWidth="170"/>
<TableColumn fx:id="paymentMethodColumn" minWidth="110" maxWidth="170"/>
<TableColumn fx:id="directionColumn" minWidth="70"/>
<TableColumn fx:id="deactivateItemColumn" minWidth="60" maxWidth="60" sortable="false"/>
<TableColumn fx:id="editItemColumn" minWidth="30" maxWidth="30" sortable="false"/>
<TableColumn fx:id="triggerIconColumn" minWidth="30" maxWidth="30" sortable="false"/>
<TableColumn fx:id="duplicateItemColumn" minWidth="30" maxWidth="30" sortable="false"/>
<TableColumn fx:id="cloneItemColumn" minWidth="30" maxWidth="30" sortable="false"/>
<TableColumn fx:id="removeItemColumn" minWidth="30" maxWidth="30" sortable="false"/>
</columns>
</TableView>

View file

@ -22,8 +22,8 @@ import com.googlecode.jcsv.writer.CSVEntryConverter;
import de.jensd.fx.glyphs.materialdesignicons.MaterialDesignIcon;
import haveno.core.locale.Res;
import haveno.core.offer.Offer;
import haveno.core.offer.OfferPayload;
import haveno.core.offer.OpenOffer;
import haveno.core.offer.OpenOfferManager;
import haveno.core.user.DontShowAgainLookup;
import haveno.desktop.Navigation;
import haveno.desktop.common.view.ActivatableViewAndModel;
@ -40,8 +40,9 @@ import haveno.desktop.main.funds.withdrawal.WithdrawalView;
import haveno.desktop.main.overlays.popups.Popup;
import haveno.desktop.main.overlays.windows.OfferDetailsWindow;
import haveno.desktop.main.portfolio.PortfolioView;
import haveno.desktop.main.portfolio.duplicateoffer.DuplicateOfferView;
import haveno.desktop.main.portfolio.presentation.PortfolioUtil;
import static haveno.desktop.util.FormBuilder.getRegularIconButton;
import haveno.desktop.util.FormBuilder;
import haveno.desktop.util.GUIUtil;
import java.util.Comparator;
import java.util.HashMap;
@ -51,13 +52,11 @@ import java.util.stream.Collectors;
import javafx.beans.binding.Bindings;
import javafx.beans.property.ReadOnlyObjectWrapper;
import javafx.beans.value.ChangeListener;
import javafx.collections.ObservableList;
import javafx.collections.ListChangeListener;
import javafx.collections.transformation.FilteredList;
import javafx.collections.transformation.SortedList;
import javafx.fxml.FXML;
import javafx.geometry.Insets;
import javafx.scene.Node;
import javafx.scene.Parent;
import javafx.scene.control.Button;
import javafx.scene.control.ContextMenu;
import javafx.scene.control.Label;
@ -73,6 +72,7 @@ import javafx.scene.layout.Pane;
import javafx.scene.layout.Priority;
import javafx.scene.layout.Region;
import javafx.scene.layout.VBox;
import javafx.scene.text.Text;
import javafx.stage.Stage;
import javafx.util.Callback;
import org.jetbrains.annotations.NotNull;
@ -80,12 +80,39 @@ import org.jetbrains.annotations.NotNull;
@FxmlView
public class OpenOffersView extends ActivatableViewAndModel<VBox, OpenOffersViewModel> {
private enum ColumnNames {
OFFER_ID(Res.get("shared.offerId")),
GROUP_ID(Res.get("openOffer.header.groupId")),
DATE(Res.get("shared.dateTime")),
MARKET(Res.get("shared.market")),
PRICE(Res.get("shared.price")),
DEVIATION(Res.get("shared.deviation")),
TRIGGER_PRICE(Res.get("openOffer.header.triggerPrice")),
AMOUNT(Res.get("shared.XMRMinMax")),
VOLUME(Res.get("shared.amountMinMax")),
PAYMENT_METHOD(Res.get("shared.paymentMethod")),
DIRECTION(Res.get("shared.offerType")),
STATUS(Res.get("shared.state"));
private final String text;
ColumnNames(String text) {
this.text = text;
}
@Override
public String toString() {
return text;
}
}
@FXML
TableView<OpenOfferListItem> tableView;
@FXML
TableColumn<OpenOfferListItem, OpenOfferListItem> priceColumn, deviationColumn, amountColumn, volumeColumn,
marketColumn, directionColumn, dateColumn, offerIdColumn, deactivateItemColumn,
removeItemColumn, editItemColumn, triggerPriceColumn, triggerIconColumn, paymentMethodColumn;
marketColumn, directionColumn, dateColumn, offerIdColumn, deactivateItemColumn, groupIdColumn,
removeItemColumn, editItemColumn, triggerPriceColumn, triggerIconColumn, paymentMethodColumn, duplicateItemColumn,
cloneItemColumn;
@FXML
HBox searchBox;
@FXML
@ -108,37 +135,48 @@ public class OpenOffersView extends ActivatableViewAndModel<VBox, OpenOffersView
private SortedList<OpenOfferListItem> sortedList;
private FilteredList<OpenOfferListItem> filteredList;
private ChangeListener<String> filterTextFieldListener;
private PortfolioView.OpenOfferActionHandler openOfferActionHandler;
private final OpenOfferManager openOfferManager;
private PortfolioView.EditOpenOfferHandler editOpenOfferHandler;
private PortfolioView.CloneOpenOfferHandler cloneOpenOfferHandler;
private ChangeListener<Number> widthListener;
private ListChangeListener<OpenOfferListItem> sortedListChangedListener;
private Map<String, ChangeListener<OpenOffer.State>> offerStateChangeListeners = new HashMap<String, ChangeListener<OpenOffer.State>>();
@Inject
public OpenOffersView(OpenOffersViewModel model, Navigation navigation, OfferDetailsWindow offerDetailsWindow) {
public OpenOffersView(OpenOffersViewModel model,
OpenOfferManager openOfferManager,
Navigation navigation,
OfferDetailsWindow offerDetailsWindow) {
super(model);
this.navigation = navigation;
this.offerDetailsWindow = offerDetailsWindow;
this.openOfferManager = openOfferManager;
}
@Override
public void initialize() {
widthListener = (observable, oldValue, newValue) -> onWidthChange((double) newValue);
paymentMethodColumn.setGraphic(new AutoTooltipLabel(Res.get("shared.paymentMethod")));
priceColumn.setGraphic(new AutoTooltipLabel(Res.get("shared.price")));
deviationColumn.setGraphic(new AutoTooltipTableColumn<>(Res.get("shared.deviation"),
groupIdColumn.setGraphic(new AutoTooltipLabel(ColumnNames.GROUP_ID.toString()));
paymentMethodColumn.setGraphic(new AutoTooltipLabel(ColumnNames.PAYMENT_METHOD.toString()));
priceColumn.setGraphic(new AutoTooltipLabel(ColumnNames.PRICE.toString()));
deviationColumn.setGraphic(new AutoTooltipTableColumn<>(ColumnNames.DEVIATION.toString(),
Res.get("portfolio.closedTrades.deviation.help")).getGraphic());
amountColumn.setGraphic(new AutoTooltipLabel(Res.get("shared.XMRMinMax")));
volumeColumn.setGraphic(new AutoTooltipLabel(Res.get("shared.amountMinMax")));
marketColumn.setGraphic(new AutoTooltipLabel(Res.get("shared.market")));
directionColumn.setGraphic(new AutoTooltipLabel(Res.get("shared.offerType")));
dateColumn.setGraphic(new AutoTooltipLabel(Res.get("shared.dateTime")));
offerIdColumn.setGraphic(new AutoTooltipLabel(Res.get("shared.offerId")));
triggerPriceColumn.setGraphic(new AutoTooltipLabel(Res.get("openOffer.header.triggerPrice")));
deactivateItemColumn.setGraphic(new AutoTooltipLabel(Res.get("shared.enabled")));
triggerPriceColumn.setGraphic(new AutoTooltipLabel(ColumnNames.TRIGGER_PRICE.toString()));
amountColumn.setGraphic(new AutoTooltipLabel(ColumnNames.AMOUNT.toString()));
volumeColumn.setGraphic(new AutoTooltipLabel(ColumnNames.VOLUME.toString()));
marketColumn.setGraphic(new AutoTooltipLabel(ColumnNames.MARKET.toString()));
directionColumn.setGraphic(new AutoTooltipLabel(ColumnNames.DIRECTION.toString()));
dateColumn.setGraphic(new AutoTooltipLabel(ColumnNames.DATE.toString()));
offerIdColumn.setGraphic(new AutoTooltipLabel(ColumnNames.OFFER_ID.toString()));
deactivateItemColumn.setGraphic(new AutoTooltipLabel(ColumnNames.STATUS.toString()));
editItemColumn.setGraphic(new AutoTooltipLabel(""));
duplicateItemColumn.setText("");
cloneItemColumn.setText("");
removeItemColumn.setGraphic(new AutoTooltipLabel(""));
setOfferIdColumnCellFactory();
setGroupIdCellFactory();
setDirectionColumnCellFactory();
setMarketColumnCellFactory();
setPriceColumnCellFactory();
@ -151,12 +189,15 @@ public class OpenOffersView extends ActivatableViewAndModel<VBox, OpenOffersView
setEditColumnCellFactory();
setTriggerIconColumnCellFactory();
setTriggerPriceColumnCellFactory();
setDuplicateColumnCellFactory();
setCloneColumnCellFactory();
setRemoveColumnCellFactory();
tableView.setColumnResizePolicy(TableView.CONSTRAINED_RESIZE_POLICY);
tableView.setPlaceholder(new AutoTooltipLabel(Res.get("table.placeholder.noItems", Res.get("shared.openOffers"))));
offerIdColumn.setComparator(Comparator.comparing(o -> o.getOffer().getId()));
groupIdColumn.setComparator(Comparator.comparing(o -> o.getOpenOffer().getReserveTxHash()));
directionColumn.setComparator(Comparator.comparing(o -> o.getOffer().getDirection()));
marketColumn.setComparator(Comparator.comparing(model::getMarketLabel));
amountColumn.setComparator(Comparator.comparing(o -> o.getOffer().getAmount()));
@ -168,23 +209,21 @@ public class OpenOffersView extends ActivatableViewAndModel<VBox, OpenOffersView
dateColumn.setComparator(Comparator.comparing(o -> o.getOffer().getDate()));
paymentMethodColumn.setComparator(Comparator.comparing(o -> Res.get(o.getOffer().getPaymentMethod().getId())));
dateColumn.setSortType(TableColumn.SortType.DESCENDING);
dateColumn.setSortType(TableColumn.SortType.ASCENDING);
tableView.getSortOrder().add(dateColumn);
tableView.setRowFactory(
tableView -> {
final TableRow<OpenOfferListItem> row = new TableRow<>();
final ContextMenu rowMenu = new ContextMenu();
MenuItem editItem = new MenuItem(Res.get("portfolio.context.offerLikeThis"));
editItem.setOnAction((event) -> {
try {
OfferPayload offerPayload = row.getItem().getOffer().getOfferPayload();
navigation.navigateToWithData(offerPayload, MainView.class, PortfolioView.class, DuplicateOfferView.class);
} catch (NullPointerException e) {
log.warn("Unable to get offerPayload - {}", e.toString());
}
});
rowMenu.getItems().add(editItem);
MenuItem duplicateOfferMenuItem = new MenuItem(Res.get("portfolio.context.offerLikeThis"));
duplicateOfferMenuItem.setOnAction((event) -> onDuplicateOffer(row.getItem()));
rowMenu.getItems().add(duplicateOfferMenuItem);
MenuItem cloneOfferMenuItem = new MenuItem(Res.get("offerbook.cloneOffer"));
cloneOfferMenuItem.setOnAction((event) -> onCloneOffer(row.getItem()));
rowMenu.getItems().add(cloneOfferMenuItem);
row.contextMenuProperty().bind(
Bindings.when(Bindings.isNotNull(row.itemProperty()))
.then(rowMenu)
@ -207,6 +246,15 @@ public class OpenOffersView extends ActivatableViewAndModel<VBox, OpenOffersView
HBox.setHgrow(footerSpacer, Priority.ALWAYS);
HBox.setMargin(exportButton, new Insets(0, 10, 0, 0));
exportButton.updateText(Res.get("shared.exportCSV"));
sortedListChangedListener = c -> {
c.next();
if (c.wasAdded() || c.wasRemoved()) {
updateNumberOfOffers();
updateGroupIdColumnVisibility();
updateTriggerColumnVisibility();
}
};
}
@Override
@ -214,8 +262,11 @@ public class OpenOffersView extends ActivatableViewAndModel<VBox, OpenOffersView
filteredList = new FilteredList<>(model.getList());
sortedList = new SortedList<>(filteredList);
sortedList.comparatorProperty().bind(tableView.comparatorProperty());
sortedList.addListener(sortedListChangedListener);
tableView.setItems(sortedList);
updateGroupIdColumnVisibility();
updateTriggerColumnVisibility();
updateSelectToggleButtonState();
selectToggleButton.setOnAction(event -> {
@ -231,37 +282,27 @@ public class OpenOffersView extends ActivatableViewAndModel<VBox, OpenOffersView
numItems.setText(Res.get("shared.numItemsLabel", sortedList.size()));
exportButton.setOnAction(event -> {
ObservableList<TableColumn<OpenOfferListItem, ?>> tableColumns = tableView.getColumns();
int reportColumns = tableColumns.size() - 3; // CSV report excludes the last columns (icons)
CSVEntryConverter<OpenOfferListItem> headerConverter = item -> {
String[] columns = new String[reportColumns];
for (int i = 0; i < columns.length; i++) {
Node graphic = tableColumns.get(i).getGraphic();
if (graphic instanceof AutoTooltipLabel) {
columns[i] = ((AutoTooltipLabel) graphic).getText();
} else if (graphic instanceof HBox) {
// Deviation has a Hbox with AutoTooltipLabel as first child in header
columns[i] = ((AutoTooltipLabel) ((Parent) graphic).getChildrenUnmodifiable().get(0)).getText();
} else {
// Not expected
columns[i] = "N/A";
}
String[] columns = new String[ColumnNames.values().length];
for (ColumnNames m : ColumnNames.values()) {
columns[m.ordinal()] = m.toString();
}
return columns;
};
CSVEntryConverter<OpenOfferListItem> contentConverter = item -> {
String[] columns = new String[reportColumns];
columns[0] = model.getOfferId(item);
columns[1] = model.getDate(item);
columns[2] = model.getMarketLabel(item);
columns[3] = model.getPrice(item);
columns[4] = model.getPriceDeviation(item);
columns[5] = model.getTriggerPrice(item);
columns[6] = model.getAmount(item);
columns[7] = model.getVolume(item);
columns[8] = model.getPaymentMethod(item);
columns[9] = model.getDirectionLabel(item);
columns[10] = String.valueOf(!item.getOpenOffer().isDeactivated());
String[] columns = new String[ColumnNames.values().length];
columns[ColumnNames.OFFER_ID.ordinal()] = model.getOfferId(item);
columns[ColumnNames.GROUP_ID.ordinal()] = openOfferManager.hasClonedOffer(item.getOffer().getId()) ? getShortenedGroupId(item.getGroupId()) : "";
columns[ColumnNames.DATE.ordinal()] = model.getDate(item);
columns[ColumnNames.MARKET.ordinal()] = model.getMarketLabel(item);
columns[ColumnNames.PRICE.ordinal()] = model.getPrice(item);
columns[ColumnNames.DEVIATION.ordinal()] = model.getPriceDeviation(item);
columns[ColumnNames.TRIGGER_PRICE.ordinal()] = model.getTriggerPrice(item);
columns[ColumnNames.AMOUNT.ordinal()] = model.getAmount(item);
columns[ColumnNames.VOLUME.ordinal()] = model.getVolume(item);
columns[ColumnNames.PAYMENT_METHOD.ordinal()] = model.getPaymentMethod(item);
columns[ColumnNames.DIRECTION.ordinal()] = model.getDirectionLabel(item);
columns[ColumnNames.STATUS.ordinal()] = String.valueOf(!item.getOpenOffer().isDeactivated());
return columns;
};
@ -280,9 +321,24 @@ public class OpenOffersView extends ActivatableViewAndModel<VBox, OpenOffersView
onWidthChange(root.getWidth());
}
private void updateNumberOfOffers() {
numItems.setText(Res.get("shared.numItemsLabel", sortedList.size()));
}
private void updateGroupIdColumnVisibility() {
groupIdColumn.setVisible(openOfferManager.hasClonedOffers());
}
private void updateTriggerColumnVisibility() {
triggerIconColumn.setVisible(model.dataModel.getList().stream()
.mapToLong(item -> item.getOpenOffer().getTriggerPrice())
.sum() > 0);
}
@Override
protected void deactivate() {
sortedList.comparatorProperty().unbind();
sortedList.removeListener(sortedListChangedListener);
exportButton.setOnAction(null);
filterTextField.textProperty().removeListener(filterTextFieldListener);
@ -352,7 +408,7 @@ public class OpenOffersView extends ActivatableViewAndModel<VBox, OpenOffersView
}
private void onWidthChange(double width) {
triggerPriceColumn.setVisible(width > 1200);
triggerPriceColumn.setVisible(width > 1300);
}
private void onDeactivateOpenOffer(OpenOffer openOffer) {
@ -361,7 +417,7 @@ public class OpenOffersView extends ActivatableViewAndModel<VBox, OpenOffersView
() -> log.debug("Deactivate offer was successful"),
(message) -> {
log.error(message);
new Popup().warning(Res.get("offerbook.deactivateOffer.failed", message)).show();
new Popup().warning(message).show();
});
updateSelectToggleButtonState();
}
@ -397,12 +453,18 @@ public class OpenOffersView extends ActivatableViewAndModel<VBox, OpenOffersView
}
private void doRemoveOpenOffer(OpenOffer openOffer) {
boolean hasClonedOffer = openOfferManager.hasClonedOffer(openOffer.getId());
model.onRemoveOpenOffer(openOffer,
() -> {
log.debug("Remove offer was successful");
tableView.refresh();
// We do not show the popup if it's a cloned offer with shared maker reserve tx
if (hasClonedOffer) {
return;
}
String key = "WithdrawFundsAfterRemoveOfferInfo";
if (DontShowAgainLookup.showAgain(key)) {
new Popup().instruction(Res.get("offerbook.withdrawFundsHint", Res.get("navigation.funds.availableForWithdrawal")))
@ -420,10 +482,46 @@ public class OpenOffersView extends ActivatableViewAndModel<VBox, OpenOffersView
private void onEditOpenOffer(OpenOffer openOffer) {
if (model.isBootstrappedOrShowPopup()) {
openOfferActionHandler.onEditOpenOffer(openOffer);
editOpenOfferHandler.onEditOpenOffer(openOffer);
}
}
private void onDuplicateOffer(OpenOfferListItem item) {
if (item == null || item.getOffer().getOfferPayload() == null) {
return;
}
if (model.isBootstrappedOrShowPopup()) {
PortfolioUtil.duplicateOffer(navigation, item.getOffer().getOfferPayload());
}
}
private void onCloneOffer(OpenOfferListItem item) {
if (item == null) {
return;
}
if (model.isBootstrappedOrShowPopup()) {
String key = "clonedOfferInfo";
if (DontShowAgainLookup.showAgain(key)) {
new Popup().headLine(Res.get("offerbook.clonedOffer.headline"))
.instruction(Res.get("offerbook.clonedOffer.info"))
.useIUnderstandButton()
.dontShowAgainId(key)
.onClose(() -> doCloneOffer(item))
.show();
} else {
doCloneOffer(item);
}
}
}
private void doCloneOffer(OpenOfferListItem item) {
OpenOffer openOffer = item.getOpenOffer();
if (openOffer == null || openOffer.getOffer() == null || openOffer.getOffer().getOfferPayload() == null) {
return;
}
cloneOpenOfferHandler.onCloneOpenOffer(openOffer);
}
private void setOfferIdColumnCellFactory() {
offerIdColumn.setCellValueFactory((openOfferListItem) -> new ReadOnlyObjectWrapper<>(openOfferListItem.getValue()));
offerIdColumn.getStyleClass().addAll("number-column", "first-column");
@ -434,21 +532,28 @@ public class OpenOffersView extends ActivatableViewAndModel<VBox, OpenOffersView
public TableCell<OpenOfferListItem, OpenOfferListItem> call(TableColumn<OpenOfferListItem,
OpenOfferListItem> column) {
return new TableCell<>() {
private HyperlinkWithIcon field;
private HyperlinkWithIcon hyperlinkWithIcon;
@Override
public void updateItem(final OpenOfferListItem item, boolean empty) {
super.updateItem(item, empty);
if (item != null && !empty) {
field = new HyperlinkWithIcon(model.getOfferId(item));
field.setOnAction(event -> offerDetailsWindow.show(item.getOffer()));
field.setTooltip(new Tooltip(Res.get("tooltip.openPopupForDetails")));
setGraphic(field);
hyperlinkWithIcon = new HyperlinkWithIcon(item.getOffer().getShortId());
if (model.isDeactivated(item)) {
// getStyleClass().add("offer-disabled"); does not work with hyperlinkWithIcon;-(
hyperlinkWithIcon.setStyle("-fx-text-fill: -bs-color-gray-3;");
hyperlinkWithIcon.getIcon().setOpacity(0.2);
}
hyperlinkWithIcon.setOnAction(event -> {
offerDetailsWindow.show(item.getOffer());
});
hyperlinkWithIcon.setTooltip(new Tooltip(Res.get("tooltip.openPopupForDetails")));
setGraphic(hyperlinkWithIcon);
} else {
setGraphic(null);
if (field != null)
field.setOnAction(null);
if (hyperlinkWithIcon != null)
hyperlinkWithIcon.setOnAction(null);
}
}
};
@ -456,6 +561,55 @@ public class OpenOffersView extends ActivatableViewAndModel<VBox, OpenOffersView
});
}
private void setGroupIdCellFactory() {
groupIdColumn.setCellValueFactory((offerListItem) -> new ReadOnlyObjectWrapper<>(offerListItem.getValue()));
groupIdColumn.setCellFactory(
new Callback<>() {
@Override
public TableCell<OpenOfferListItem, OpenOfferListItem> call(
TableColumn<OpenOfferListItem, OpenOfferListItem> column) {
return new TableCell<>() {
@Override
public void updateItem(final OpenOfferListItem item, boolean empty) {
super.updateItem(item, empty);
getStyleClass().removeAll("offer-disabled");
if (item != null) {
Label label;
Text icon;
if (openOfferManager.hasClonedOffer(item.getOpenOffer().getId())) {
label = new Label(getShortenedGroupId(item.getOpenOffer().getGroupId()));
icon = FormBuilder.getRegularIconForLabel(MaterialDesignIcon.LINK, label, "icon");
icon.setVisible(true);
setTooltip(new Tooltip(Res.get("offerbook.clonedOffer.tooltip", item.getOpenOffer().getReserveTxHash())));
} else {
label = new Label("");
icon = FormBuilder.getRegularIconForLabel(MaterialDesignIcon.LINK_OFF, label, "icon");
icon.setVisible(false);
setTooltip(new Tooltip(Res.get("offerbook.nonClonedOffer.tooltip", item.getOpenOffer().getReserveTxHash())));
}
if (model.isDeactivated(item)) {
getStyleClass().add("offer-disabled");
icon.setOpacity(0.2);
}
setGraphic(label);
} else {
setGraphic(null);
}
}
};
}
});
}
private String getShortenedGroupId(String groupId) {
if (groupId.length() > 5) {
return groupId.substring(0, 5);
}
return groupId;
}
private void setDateColumnCellFactory() {
dateColumn.setCellValueFactory((openOfferListItem) -> new ReadOnlyObjectWrapper<>(openOfferListItem.getValue()));
dateColumn.setCellFactory(
@ -779,6 +933,74 @@ public class OpenOffersView extends ActivatableViewAndModel<VBox, OpenOffersView
});
}
private void setDuplicateColumnCellFactory() {
duplicateItemColumn.getStyleClass().add("avatar-column");
duplicateItemColumn.setCellValueFactory((offerListItem) -> new ReadOnlyObjectWrapper<>(offerListItem.getValue()));
duplicateItemColumn.setCellFactory(
new Callback<>() {
@Override
public TableCell<OpenOfferListItem, OpenOfferListItem> call(TableColumn<OpenOfferListItem, OpenOfferListItem> column) {
return new TableCell<>() {
Button button;
@Override
public void updateItem(final OpenOfferListItem item, boolean empty) {
super.updateItem(item, empty);
if (item != null && !empty) {
if (button == null) {
button = getRegularIconButton(MaterialDesignIcon.CONTENT_COPY);
button.setTooltip(new Tooltip(Res.get("portfolio.context.offerLikeThis")));
setGraphic(button);
}
button.setOnAction(event -> onDuplicateOffer(item));
} else {
setGraphic(null);
if (button != null) {
button.setOnAction(null);
button = null;
}
}
}
};
}
});
}
private void setCloneColumnCellFactory() {
cloneItemColumn.getStyleClass().add("avatar-column");
cloneItemColumn.setCellValueFactory((offerListItem) -> new ReadOnlyObjectWrapper<>(offerListItem.getValue()));
cloneItemColumn.setCellFactory(
new Callback<>() {
@Override
public TableCell<OpenOfferListItem, OpenOfferListItem> call(TableColumn<OpenOfferListItem, OpenOfferListItem> column) {
return new TableCell<>() {
Button button;
@Override
public void updateItem(final OpenOfferListItem item, boolean empty) {
super.updateItem(item, empty);
if (item != null && !empty) {
if (button == null) {
button = getRegularIconButton(MaterialDesignIcon.BOX_SHADOW);
button.setTooltip(new Tooltip(Res.get("offerbook.cloneOffer")));
setGraphic(button);
}
button.setOnAction(event -> onCloneOffer(item));
} else {
setGraphic(null);
if (button != null) {
button.setOnAction(null);
button = null;
}
}
}
};
}
});
}
private void setTriggerIconColumnCellFactory() {
triggerIconColumn.setCellValueFactory((offerListItem) -> new ReadOnlyObjectWrapper<>(offerListItem.getValue()));
triggerIconColumn.setCellFactory(
@ -854,8 +1076,12 @@ public class OpenOffersView extends ActivatableViewAndModel<VBox, OpenOffersView
});
}
public void setOpenOfferActionHandler(PortfolioView.OpenOfferActionHandler openOfferActionHandler) {
this.openOfferActionHandler = openOfferActionHandler;
public void setEditOpenOfferHandler(PortfolioView.EditOpenOfferHandler editOpenOfferHandler) {
this.editOpenOfferHandler = editOpenOfferHandler;
}
public void setCloneOpenOfferHandler(PortfolioView.CloneOpenOfferHandler cloneOpenOfferHandler) {
this.cloneOpenOfferHandler = cloneOpenOfferHandler;
}
}

View file

@ -84,6 +84,10 @@ class OpenOffersViewModel extends ActivatableWithDataModel<OpenOffersDataModel>
return item.getOffer().getShortId();
}
String getGroupId(OpenOfferListItem item) {
return item.getGroupId();
}
String getAmount(OpenOfferListItem item) {
return (item != null) ? DisplayUtils.formatAmount(item.getOffer(), btcFormatter) : "";
}

View file

@ -2340,8 +2340,8 @@ public class FormBuilder {
return getRegularIconForLabel(icon, label, null);
}
public static Text getRegularIconForLabel(GlyphIcons icon, Label label, String style) {
return getIconForLabel(icon, "1.231em", label, style);
public static Text getRegularIconForLabel(GlyphIcons icon, Label label, String styleClass) {
return getIconForLabel(icon, "1.231em", label, styleClass);
}
public static Text getIcon(GlyphIcons icon) {

View file

@ -88,7 +88,7 @@ public class CreateOfferDataModelTest {
when(user.getPaymentAccounts()).thenReturn(paymentAccounts);
when(preferences.getSelectedPaymentAccountForCreateOffer()).thenReturn(revolutAccount);
model.initWithData(OfferDirection.BUY, new TraditionalCurrency("USD"));
model.initWithData(OfferDirection.BUY, new TraditionalCurrency("USD"), true);
assertEquals("USD", model.getTradeCurrencyCode().get());
}
@ -109,7 +109,7 @@ public class CreateOfferDataModelTest {
when(user.findFirstPaymentAccountWithCurrency(new TraditionalCurrency("USD"))).thenReturn(zelleAccount);
when(preferences.getSelectedPaymentAccountForCreateOffer()).thenReturn(revolutAccount);
model.initWithData(OfferDirection.BUY, new TraditionalCurrency("USD"));
model.initWithData(OfferDirection.BUY, new TraditionalCurrency("USD"), true);
assertEquals("USD", model.getTradeCurrencyCode().get());
}
}

View file

@ -117,7 +117,7 @@ public class CreateOfferViewModelTest {
coinFormatter,
tradeStats,
null);
dataModel.initWithData(OfferDirection.BUY, new CryptoCurrency("XMR", "monero"));
dataModel.initWithData(OfferDirection.BUY, new CryptoCurrency("XMR", "monero"), true);
dataModel.activate();
model = new CreateOfferViewModel(dataModel,