From 9af3c761f4ee7be542390c2cd094262b9eb08a80 Mon Sep 17 00:00:00 2001 From: martin Date: Fri, 22 May 2026 19:17:00 +0200 Subject: [PATCH] Feat: Adding buy and sell buttons on stocks tab --- .../millions/controller/GameController.java | 64 +++- src/main/java/millions/view/GameView.java | 289 ++++++++++++++++-- 2 files changed, 326 insertions(+), 27 deletions(-) diff --git a/src/main/java/millions/controller/GameController.java b/src/main/java/millions/controller/GameController.java index a151510..5f4a04c 100644 --- a/src/main/java/millions/controller/GameController.java +++ b/src/main/java/millions/controller/GameController.java @@ -1,18 +1,22 @@ package millions.controller; - import java.math.BigDecimal; +import java.math.RoundingMode; import java.nio.file.Path; +import java.util.ArrayList; import java.util.Comparator; import java.util.List; +import java.math.RoundingMode; import java.util.stream.Collectors; - import millions.controller.fileIO.CSV.CSVFileHandler; import millions.controller.fileIO.InvalidFormatException; import millions.controller.fileIO.UncheckedFileNotFoundException; import millions.model.Exchange; import millions.model.Player; +import millions.model.Share; import millions.model.Stock; +import millions.model.Transaction; +import millions.model.calculators.PurchaseCalculator; /** Controls game initialization. */ public class GameController { @@ -35,9 +39,9 @@ public void startGame( player = new Player(name, startingMoney); - } catch(UncheckedFileNotFoundException e) { + } catch (UncheckedFileNotFoundException e) { throw new UncheckedFileNotFoundException(e.getMessage()); - } catch(InvalidFormatException e) { + } catch (InvalidFormatException e) { throw new InvalidFormatException(e.getMessage()); } } @@ -80,4 +84,56 @@ public List searchStocks(String searchTerm) { public Stock getStock(String symbol) { return exchange.getStock(symbol); } + + public Transaction buyStock(String symbol, BigDecimal quantity) { + if (quantity == null || quantity.compareTo(BigDecimal.ZERO) <= 0) { + throw new IllegalArgumentException("Quantity must be positive"); + } + return exchange.buy(symbol, player, quantity); + } + + public Transaction sellShare(Share share) { + if (share == null) { + throw new IllegalArgumentException("Share cannot be null"); + } + return exchange.sell(share, player); + } + + public List getOwnedShares(String symbol) { + return new ArrayList<>(player.getPortfolio().getShares(symbol)); + } + + public void advanceWeek() { + exchange.advance(); + } + + public BigDecimal getOwnedQuantity(String symbol) { + return player.getPortfolio().getShares(symbol).stream() + .map(Share::getQuantity) + .reduce(BigDecimal.ZERO, BigDecimal::add); + } + + public int getMaxBuyableQuantity(String symbol) { + Stock stock = getStock(symbol); + if (stock == null || player == null) { + return 0; + } + + BigDecimal money = player.getMoney(); + BigDecimal price = stock.getSalesPrice(); + if (price.compareTo(BigDecimal.ZERO) <= 0) { + return 0; + } + + int upperBound = money.divide(price, 0, RoundingMode.FLOOR).intValue(); + // Loop so we take care of comission/tax stuff + for (int quantity = upperBound; quantity >= 1; quantity--) { + Share share = new Share(stock, quantity, price); + BigDecimal total = new PurchaseCalculator(share).calculateTotal(); + if (total.compareTo(money) <= 0) { + return quantity; + } + } + return 0; + } } diff --git a/src/main/java/millions/view/GameView.java b/src/main/java/millions/view/GameView.java index 58763d4..51169d1 100644 --- a/src/main/java/millions/view/GameView.java +++ b/src/main/java/millions/view/GameView.java @@ -2,15 +2,21 @@ import java.math.BigDecimal; import java.util.List; +import javafx.geometry.Insets; +import javafx.geometry.Pos; import javafx.scene.chart.LineChart; import javafx.scene.chart.NumberAxis; import javafx.scene.chart.XYChart; +import javafx.scene.control.Button; +import javafx.scene.control.ComboBox; import javafx.scene.control.Label; import javafx.scene.control.ListCell; import javafx.scene.control.ListView; +import javafx.scene.control.Slider; import javafx.scene.control.Tab; import javafx.scene.control.TabPane; import javafx.scene.control.TextField; +import javafx.scene.control.TextFormatter; import javafx.scene.layout.BorderPane; import javafx.scene.layout.HBox; import javafx.scene.layout.VBox; @@ -19,6 +25,7 @@ import millions.model.ExchangeListener; import millions.model.Player; import millions.model.PlayerListener; +import millions.model.Share; import millions.model.Stock; import millions.model.Transaction; @@ -35,15 +42,26 @@ public class GameView extends BorderPane implements PlayerListener, ExchangeList private final TextField searchField = new TextField(); private final ListView stocksList = new ListView<>(); private final Label selectedStockLabel = new Label("Select a stock to see chart"); + private final Label ownedQuantityLabel = new Label("Owned: 0"); + private final Label actionStatusLabel = new Label(); + private final TextField quantityField = new TextField("1"); + private final Slider quantitySlider = new Slider(1, 1, 1); + private final ComboBox ownedSharesBox = new ComboBox<>(); private final NumberAxis xAxis = new NumberAxis(); private final NumberAxis yAxis = new NumberAxis(); private final LineChart stockChart = new LineChart<>(xAxis, yAxis); + private final Button buyButton = new Button("Buy"); + private final Button sellButton = new Button("Sell"); + private final Button advanceButton = new Button("Advance week"); + private boolean updatingQuantityControls; public GameView(GameController controller) { this.controller = controller; setTop(createHeader()); setCenter(createTabs()); configureStocksList(); + configureButtons(); + configureQuantityControls(); refreshAll(); } @@ -60,8 +78,8 @@ private TabPane createTabs() { TabPane tabPane = new TabPane(); tabPane.setTabClosingPolicy(TabPane.TabClosingPolicy.UNAVAILABLE); tabPane.getTabs().add(createStocksTab()); - tabPane.getTabs().add(createPortfolioTab()); - tabPane.getTabs().add(createTransactionsTab()); + tabPane.getTabs().add(new Tab("Portfolio", new VBox())); + tabPane.getTabs().add(new Tab("Transactions", new VBox())); return tabPane; } @@ -84,18 +102,72 @@ private Tab createStocksTab() { VBox rightPane = new VBox(10, selectedStockLabel, stockChart); + quantityField.setPrefWidth(70); + quantityField.setTextFormatter( + new TextFormatter<>( + change -> change.getControlNewText().matches("-?\\d*") ? change : null)); + quantitySlider.setPrefWidth(200); + + ownedSharesBox.setPrefWidth(260); + ownedSharesBox.setCellFactory( + comboBox -> + new ListCell<>() { + @Override + protected void updateItem(Share share, boolean empty) { + super.updateItem(share, empty); + if (empty || share == null) { + setText(null); + setStyle(""); + } else { + setText(formatOwnedShare(share)); + setStyle(getProfitStyle(share)); + } + } + }); + ownedSharesBox.setButtonCell( + new ListCell<>() { + @Override + protected void updateItem(Share share, boolean empty) { + super.updateItem(share, empty); + if (empty || share == null) { + setText("Select lot"); + setStyle(""); + } else { + setText(formatOwnedShare(share)); + setStyle(getProfitStyle(share)); + } + } + }); + + HBox sellBox = new HBox(8, sellButton, new VBox(4, new Label("Owned Shares"), ownedSharesBox)); + + HBox actionBar = + new HBox( + 10, + ownedQuantityLabel, + quantityField, + quantitySlider, + buyButton, + sellBox, + advanceButton); HBox content = new HBox(12, leftPane, rightPane); - return new Tab("Stocks", content); + VBox outer = new VBox(12, content, actionBar, actionStatusLabel); + return new Tab("Stocks", outer); } - private Tab createPortfolioTab() { - VBox content = new VBox(); - return new Tab("Portfolio", content); + private void configureButtons() { + buyButton.setOnAction(event -> buySelectedStock()); + sellButton.setOnAction(event -> sellSelectedShare()); + advanceButton.setOnAction(event -> advanceWeek()); } - private Tab createTransactionsTab() { - VBox content = new VBox(); - return new Tab("Transactions", content); + private void configureQuantityControls() { + quantityField + .textProperty() + .addListener((obs, oldValue, newValue) -> syncQuantityFromField(newValue)); + quantitySlider + .valueProperty() + .addListener((obs, oldValue, newValue) -> syncQuantityFromSlider(newValue.intValue())); } private void configureStocksList() { @@ -140,8 +212,19 @@ private void refreshPlayerInfo() { } private void refreshStocks() { + Stock selected = stocksList.getSelectionModel().getSelectedItem(); List items = controller.searchStocks(searchField.getText()); stocksList.getItems().setAll(items); + + if (selected != null && items.contains(selected)) { + stocksList.getSelectionModel().select(selected); + showStockChart(selected); + } else if (!items.isEmpty()) { + stocksList.getSelectionModel().selectFirst(); + showStockChart(items.getFirst()); + } else { + showStockChart(null); + } } private void showStockChart(Stock stock) { @@ -149,19 +232,12 @@ private void showStockChart(Stock stock) { if (stock == null) { selectedStockLabel.setText("Select a stock to see chart"); + refreshQuantityControls(null); return; } - selectedStockLabel.setText( - stock.getSymbol() - + " - " - + stock.getCompany() - + " | Current: " - + stock.getSalesPrice() - + " | High: " - + stock.getHighestPrice() - + " | Low: " - + stock.getLowestPrice()); + selectedStockLabel.setText(stock.getSymbol() + " - " + stock.getCompany()); + refreshQuantityControls(stock); XYChart.Series series = new XYChart.Series<>(); List prices = stock.getHistoricalPrices(); @@ -172,6 +248,173 @@ private void showStockChart(Stock stock) { stockChart.getData().add(series); } + private void refreshQuantityControls(Stock stock) { + if (stock == null) { + ownedQuantityLabel.setText("Owned: 0"); + quantityField.setDisable(true); + quantitySlider.setDisable(true); + buyButton.setDisable(true); + sellButton.setDisable(true); + ownedSharesBox.getItems().clear(); + return; + } + + int maxBuyable = controller.getMaxBuyableQuantity(stock.getSymbol()); + int current = clampQuantity(getQuantityValue(), Math.max(1, maxBuyable)); + + updatingQuantityControls = true; + quantityField.setText(String.valueOf(current)); + quantitySlider.setMin(1); + quantitySlider.setMax(Math.max(1, maxBuyable)); + quantitySlider.setValue(current); + quantityField.setDisable(false); + quantitySlider.setDisable(false); + buyButton.setDisable(maxBuyable <= 0); + sellButton.setDisable(false); + updatingQuantityControls = false; + + ownedQuantityLabel.setText("Owned: " + controller.getOwnedQuantity(stock.getSymbol())); + ownedSharesBox.getItems().setAll(controller.getOwnedShares(stock.getSymbol())); + if (!ownedSharesBox.getItems().isEmpty()) { + ownedSharesBox.getSelectionModel().selectFirst(); + } + } + + private void buySelectedStock() { + Stock selectedStock = stocksList.getSelectionModel().getSelectedItem(); + if (selectedStock == null) { + setActionStatus("Select a stock first.", false); + return; + } + + try { + Transaction transaction = + controller.buyStock(selectedStock.getSymbol(), BigDecimal.valueOf(getQuantityValue())); + setActionStatus("Bought " + selectedStock.getSymbol(), true); + refreshAll(); + showStockChart(selectedStock); + } catch (RuntimeException ex) { + setActionStatus(ex.getMessage(), false); + } + } + + private void sellSelectedShare() { + Stock selectedStock = stocksList.getSelectionModel().getSelectedItem(); + Share selectedShare = ownedSharesBox.getSelectionModel().getSelectedItem(); + if (selectedStock == null) { + setActionStatus("Select a stock first.", false); + return; + } + if (selectedShare == null) { + setActionStatus("Select a share lot.", false); + return; + } + + try { + Transaction transaction = controller.sellShare(selectedShare); + setActionStatus("Sold " + selectedStock.getSymbol(), true); + refreshAll(); + showStockChart(selectedStock); + } catch (RuntimeException ex) { + setActionStatus(ex.getMessage(), false); + } + } + + private void advanceWeek() { + controller.advanceWeek(); + refreshAll(); + showStockChart(stocksList.getSelectionModel().getSelectedItem()); + } + + private void setActionStatus(String message, boolean success) { + actionStatusLabel.setText(message); + actionStatusLabel.setStyle(success ? "-fx-text-fill: green;" : "-fx-text-fill: red;"); + } + + private int getQuantityValue() { + String text = quantityField.getText(); + if (text == null || text.isBlank()) { + return (int) Math.round(quantitySlider.getValue()); + } + try { + return Integer.parseInt(text.trim()); + } catch (NumberFormatException e) { + return 1; + } + } + + private void syncQuantityFromField(String newValue) { + if (updatingQuantityControls) { + return; + } + if (newValue == null || newValue.isBlank() || newValue.equals("-")) { + return; + } + + updatingQuantityControls = true; + int clamped = clampQuantity(parseQuantity(newValue), getCurrentMaxBuyable()); + quantityField.setText(String.valueOf(clamped)); + quantitySlider.setValue(clamped); + updatingQuantityControls = false; + } + + private void syncQuantityFromSlider(int newValue) { + if (updatingQuantityControls) { + return; + } + + updatingQuantityControls = true; + int clamped = clampQuantity(newValue, getCurrentMaxBuyable()); + quantityField.setText(String.valueOf(clamped)); + quantitySlider.setValue(clamped); + updatingQuantityControls = false; + } + + private int parseQuantity(String text) { + try { + return Integer.parseInt(text.trim()); + } catch (NumberFormatException e) { + // Just make anything it can't parse to 1 + return 1; + } + } + + private int clampQuantity(int quantity, int maxBuyable) { + int upperBound = Math.max(1, maxBuyable); + if (quantity < 1) { + return 1; + } + if (quantity > upperBound) { + return upperBound; + } + return quantity; + } + + private int getCurrentMaxBuyable() { + Stock selected = stocksList.getSelectionModel().getSelectedItem(); + if (selected == null) { + return 1; + } + return Math.max(1, controller.getMaxBuyableQuantity(selected.getSymbol())); + } + + private String formatOwnedShare(Share share) { + BigDecimal profit = getShareProfit(share); + String sign = profit.compareTo(BigDecimal.ZERO) >= 0 ? "+" : ""; + return share.getQuantity() + "|" + share.getPurchasePrice() + "|" + sign + profit; + } + + private String getProfitStyle(Share share) { + return getShareProfit(share).compareTo(BigDecimal.ZERO) >= 0 + ? "-fx-text-fill: green;" + : "-fx-text-fill: red;"; + } + + private BigDecimal getShareProfit(Share share) { + BigDecimal currentPrice = share.getStock().getSalesPrice(); + return currentPrice.subtract(share.getPurchasePrice()).multiply(share.getQuantity()); + } + private String formatStock(Stock stock) { return stock.getSymbol() + " - " + stock.getCompany() + " (" + stock.getSalesPrice() + ")"; } @@ -179,17 +422,17 @@ private String formatStock(Stock stock) { // Listener callbacks update the shared header and the stocks tab. @Override public void onMoneyChanged(BigDecimal newBalance) { - refreshPlayerInfo(); + refreshAll(); } @Override public void onPortfolioChanged() { - refreshPlayerInfo(); + refreshAll(); } @Override public void onStatusChanged(String newStatus) { - refreshPlayerInfo(); + refreshAll(); } @Override @@ -199,6 +442,6 @@ public void onWeekAdvanced(int newWeek) { @Override public void onTransactionCompleted(Transaction transaction) { - refreshPlayerInfo(); + refreshAll(); } }