diff --git a/src/main/java/cli/UserInterface.java b/src/main/java/cli/UserInterface.java index 2258c0c..f9585fd 100644 --- a/src/main/java/cli/UserInterface.java +++ b/src/main/java/cli/UserInterface.java @@ -67,6 +67,9 @@ public class UserInterface { private static final int LIST_FUNDS = 16; private static final int SEARCH_FUNDS = 17; private static final int VIEW_FUND_DETAILS = 18; + private static final int PROFILE_DISPLAY_NAME = 19; + private static final int PROFILE_AVATAR_PATH = 20; + private static final int PROFILE_DELETE = 21; private static final int INVALID_MENU_CHOICE = -1; private static final Scanner input = new Scanner(System.in); @@ -101,7 +104,8 @@ private static void init() { new GameStateRepository(PROFILES_ROOT), new PinHashingService(), UserInterface::loadMarketData, - EXCHANGE_NAME); + EXCHANGE_NAME, + PROFILES_ROOT); player = null; exchange = null; } @@ -146,6 +150,9 @@ private static int showMenu() { System.out.println(I18n.get("menu.option.funds.list")); System.out.println(I18n.get("menu.option.funds.search")); System.out.println(I18n.get("menu.option.funds.view")); + System.out.println(I18n.get("menu.option.profile.name")); + System.out.println(I18n.get("menu.option.profile.avatar")); + System.out.println(I18n.get("menu.option.profile.delete")); System.out.println(I18n.get("menu.footer")); System.out.println(I18n.get("menu.prompt")); try { @@ -181,6 +188,9 @@ private static void triggerChoice() { case LIST_FUNDS -> listFunds(); case SEARCH_FUNDS -> searchFunds(); case VIEW_FUND_DETAILS -> viewFundDetails(); + case PROFILE_DISPLAY_NAME -> editProfileDisplayName(); + case PROFILE_AVATAR_PATH -> setProfileAvatarFromPath(); + case PROFILE_DELETE -> deleteMyProfile(); default -> System.out.println(I18n.get("invalid.input")); } } @@ -1052,10 +1062,74 @@ private static void persistActiveSession() { */ private static String activeUserSummary() { return sessionService.getActiveSession() - .map(session -> I18n.format("session.active.current", session.username(), session.exchange().getDay())) + .map(session -> I18n.format( + "session.active.current", + session.player().getName(), + session.username(), + session.exchange().getDay())) .orElseGet(() -> I18n.get("session.active.none")); } + private static void editProfileDisplayName() { + if (isPlayerMissing()) { + return; + } + input.nextLine(); + System.out.println(I18n.get("prompt.profile.displayName")); + String line = input.nextLine(); + try { + sessionService.updateDisplayName(line); + System.out.println(I18n.get("profile.name.saved")); + } catch (IllegalArgumentException exception) { + System.out.println(exception.getMessage()); + } catch (IllegalStateException exception) { + System.out.println(I18n.get("require.player")); + } + } + + private static void setProfileAvatarFromPath() { + if (isPlayerMissing()) { + return; + } + input.nextLine(); + System.out.println(I18n.get("prompt.profile.avatarPath")); + String line = input.nextLine().trim(); + if (line.isEmpty()) { + System.out.println(I18n.get("profile.avatar.emptyPath")); + return; + } + try { + sessionService.saveAvatarFromFile(Path.of(line)); + System.out.println(I18n.get("profile.avatar.saved")); + } catch (RuntimeException exception) { + System.out.println( + exception.getMessage() != null ? exception.getMessage() : I18n.get("profile.avatar.failed")); + } + } + + private static void deleteMyProfile() { + if (isPlayerMissing()) { + return; + } + input.nextLine(); + System.out.println(I18n.get("prompt.profile.deleteConfirm")); + System.out.println(I18n.get("prompt.pin")); + String pinLine = input.nextLine(); + char[] pin = pinLine.toCharArray(); + try { + sessionService.deleteActiveProfile(pin); + clearActiveSession(); + System.out.println(I18n.get("profile.deleted")); + } catch (AuthenticationException exception) { + System.out.println(I18n.get("auth.invalidCredentials")); + } catch (RuntimeException exception) { + System.out.println( + exception.getMessage() != null ? exception.getMessage() : I18n.get("profile.delete.failed")); + } finally { + java.util.Arrays.fill(pin, '0'); + } + } + /** * Maps registration and PIN validation failures to translated CLI messages. * diff --git a/src/main/java/model/Player.java b/src/main/java/model/Player.java index 9215d55..15494d1 100644 --- a/src/main/java/model/Player.java +++ b/src/main/java/model/Player.java @@ -19,7 +19,7 @@ */ public class Player { - private final String name; + private String name; private final BigDecimal startingMoney; private final Portfolio portfolio; private final TransactionArchive transactionArchive; @@ -98,6 +98,21 @@ public String getName() { return name; } + /** + * Updates the display name shown in the UI. Persists via game-state save. + * + * @param name non-blank trimmed name, at most 48 characters + * @throws IllegalArgumentException if the name is invalid + */ + public void setName(String name) { + checkNotNull(name, "name"); + String trimmed = name.trim(); + if (trimmed.isEmpty() || trimmed.length() > 48) { + throw new IllegalArgumentException("Display name must be 1-48 characters."); + } + this.name = trimmed; + } + /** * Gets the starting money of the player. * diff --git a/src/main/java/model/persistence/ProfileDirectories.java b/src/main/java/model/persistence/ProfileDirectories.java index fa8697b..059233e 100644 --- a/src/main/java/model/persistence/ProfileDirectories.java +++ b/src/main/java/model/persistence/ProfileDirectories.java @@ -12,6 +12,7 @@ public final class ProfileDirectories { private static final Pattern USERNAME_PATTERN = Pattern.compile("[A-Za-z0-9_-]{3,32}"); private static final String ACCOUNT_FILE_NAME = "account.json"; private static final String GAME_STATE_FILE_NAME = "game-state.json"; + private static final String AVATAR_FILE_NAME = "avatar.png"; private final Path profilesRoot; @@ -78,6 +79,16 @@ public Path gameStateFile(String username) { return profileDirectory(username).resolve(GAME_STATE_FILE_NAME); } + /** + * Returns the path for the profile avatar image (PNG, written on upload). + * + * @param username raw or canonical username + * @return avatar file path within the profile directory + */ + public Path avatarFile(String username) { + return profileDirectory(username).resolve(AVATAR_FILE_NAME); + } + /** * Returns the root directory containing all profiles. * diff --git a/src/main/java/model/persistence/ProfileImageService.java b/src/main/java/model/persistence/ProfileImageService.java new file mode 100644 index 0000000..3123eda --- /dev/null +++ b/src/main/java/model/persistence/ProfileImageService.java @@ -0,0 +1,124 @@ +package model.persistence; + +import java.awt.Graphics2D; +import java.awt.Image; +import java.awt.image.BufferedImage; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import javax.imageio.ImageIO; + +/** + * Copies and validates profile avatar images into the profile directory as PNG. + */ +public final class ProfileImageService { + + /** Maximum source file size (bytes) before decode. */ + public static final long MAX_FILE_BYTES = 2 * 1024 * 1024; + + /** Maximum width or height stored (larger images are scaled down). */ + public static final int MAX_DIMENSION = 512; + + private final ProfileDirectories profileDirectories; + + public ProfileImageService(Path profilesRoot) { + this.profileDirectories = new ProfileDirectories(profilesRoot); + } + + /** + * Reads an image file, validates it, scales if needed, and writes {@code avatar.png} for the + * profile. + * + * @param sourceImage path to PNG or JPEG + * @param normalizedUsername profile key + * @throws IllegalArgumentException if the file is missing, too large, or not a supported image + */ + public void saveAvatarFromFile(Path sourceImage, String normalizedUsername) { + if (sourceImage == null || !Files.isRegularFile(sourceImage)) { + throw new IllegalArgumentException("Image file not found."); + } + long size; + try { + size = Files.size(sourceImage); + } catch (IOException exception) { + throw new IllegalArgumentException("Could not read image file.", exception); + } + if (size > MAX_FILE_BYTES) { + throw new IllegalArgumentException("Image must be at most 2 MB."); + } + BufferedImage decoded; + try { + decoded = ImageIO.read(sourceImage.toFile()); + } catch (IOException exception) { + throw new IllegalArgumentException("Could not read image data.", exception); + } + if (decoded == null) { + throw new IllegalArgumentException("Unsupported or corrupt image (use PNG or JPEG)."); + } + BufferedImage scaled = scaleDown(decoded); + Path dest = profileDirectories.avatarFile(normalizedUsername); + try { + Files.createDirectories(dest.getParent()); + if (!ImageIO.write(scaled, "png", dest.toFile())) { + throw new IOException("PNG encoder not available."); + } + } catch (IOException exception) { + throw new PersistenceException("Could not write avatar file.", exception); + } + } + + /** + * Removes the avatar file for a profile if present. + * + * @param normalizedUsername profile key + */ + public void deleteAvatar(String normalizedUsername) { + Path dest = profileDirectories.avatarFile(normalizedUsername); + try { + Files.deleteIfExists(dest); + } catch (IOException exception) { + throw new PersistenceException("Could not remove avatar file.", exception); + } + } + + /** + * Returns the path to the avatar file for a profile (may not exist). + */ + public Path avatarPath(String normalizedUsername) { + return profileDirectories.avatarFile(normalizedUsername); + } + + private static BufferedImage scaleDown(BufferedImage source) { + int w = source.getWidth(); + int h = source.getHeight(); + if (w <= MAX_DIMENSION && h <= MAX_DIMENSION) { + return toArgb(source); + } + double scale = Math.min((double) MAX_DIMENSION / w, (double) MAX_DIMENSION / h); + int nw = Math.max(1, (int) Math.round(w * scale)); + int nh = Math.max(1, (int) Math.round(h * scale)); + Image scaled = source.getScaledInstance(nw, nh, Image.SCALE_SMOOTH); + BufferedImage out = new BufferedImage(nw, nh, BufferedImage.TYPE_INT_ARGB); + Graphics2D g = out.createGraphics(); + try { + g.drawImage(scaled, 0, 0, null); + } finally { + g.dispose(); + } + return out; + } + + private static BufferedImage toArgb(BufferedImage source) { + if (source.getType() == BufferedImage.TYPE_INT_ARGB) { + return source; + } + BufferedImage copy = new BufferedImage(source.getWidth(), source.getHeight(), BufferedImage.TYPE_INT_ARGB); + Graphics2D g = copy.createGraphics(); + try { + g.drawImage(source, 0, 0, null); + } finally { + g.dispose(); + } + return copy; + } +} diff --git a/src/main/java/model/persistence/UserAccountRecord.java b/src/main/java/model/persistence/UserAccountRecord.java index 7b9a659..166ffa9 100644 --- a/src/main/java/model/persistence/UserAccountRecord.java +++ b/src/main/java/model/persistence/UserAccountRecord.java @@ -7,11 +7,24 @@ * @param normalizedUsername canonical case-insensitive username key * @param saltBase64 random salt used for PIN hashing * @param pinHashBase64 PBKDF2 hash of the PIN + * @param displayName optional profile display name; when null, UI uses {@code username} */ public record UserAccountRecord( String username, String normalizedUsername, String saltBase64, - String pinHashBase64 + String pinHashBase64, + String displayName ) { + + /** + * Account record without a custom display name (legacy and default). + */ + public UserAccountRecord( + String username, + String normalizedUsername, + String saltBase64, + String pinHashBase64) { + this(username, normalizedUsername, saltBase64, pinHashBase64, null); + } } diff --git a/src/main/java/model/persistence/UserAccountRepository.java b/src/main/java/model/persistence/UserAccountRepository.java index fc0c22b..d5dcea9 100644 --- a/src/main/java/model/persistence/UserAccountRepository.java +++ b/src/main/java/model/persistence/UserAccountRepository.java @@ -81,4 +81,27 @@ public List listUsernames() { throw new PersistenceException("Could not list user profiles in " + root, exception); } } + + /** + * Loads all account records sorted by username. + * + * @return account metadata for every profile on disk + */ + public List listAccounts() { + Path root = profileDirectories.profilesRoot(); + if (!Files.exists(root)) { + return List.of(); + } + try (Stream children = Files.list(root)) { + return children + .filter(Files::isDirectory) + .map(path -> path.resolve("account.json")) + .filter(Files::exists) + .map(path -> jsonStorage.read(path, UserAccountRecord.class)) + .sorted(Comparator.comparing(UserAccountRecord::username)) + .toList(); + } catch (IOException exception) { + throw new PersistenceException("Could not list user profiles in " + root, exception); + } + } } diff --git a/src/main/java/model/session/LocalLeaderboardService.java b/src/main/java/model/session/LocalLeaderboardService.java new file mode 100644 index 0000000..d19d19d --- /dev/null +++ b/src/main/java/model/session/LocalLeaderboardService.java @@ -0,0 +1,86 @@ +package model.session; + +import java.math.BigDecimal; +import java.nio.file.Files; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Optional; +import java.util.function.Supplier; +import model.Exchange; +import model.Player; +import model.persistence.GameStateMapper; +import model.persistence.GameStateRepository; +import model.persistence.GameStateSnapshot; +import model.persistence.MarketData; +import model.persistence.ProfileImageService; +import model.persistence.UserAccountRecord; +import model.persistence.UserAccountRepository; + +/** + * Ranks local profiles by net worth using each user's saved game state. + */ +public final class LocalLeaderboardService { + + /** + * One row in the local leaderboard. + * + * @param normalizedUsername profile directory key + * @param displayName name shown in UI + * @param netWorth total net worth from saved state + * @param hasAvatar whether an avatar image file exists + */ + public record LeaderboardRow( + String normalizedUsername, + String displayName, + BigDecimal netWorth, + boolean hasAvatar + ) { + } + + private final UserAccountRepository userAccountRepository; + private final GameStateRepository gameStateRepository; + private final GameStateMapper gameStateMapper; + private final Supplier marketDataSupplier; + private final ProfileImageService profileImageService; + + public LocalLeaderboardService( + UserAccountRepository userAccountRepository, + GameStateRepository gameStateRepository, + GameStateMapper gameStateMapper, + Supplier marketDataSupplier, + ProfileImageService profileImageService) { + this.userAccountRepository = userAccountRepository; + this.gameStateRepository = gameStateRepository; + this.gameStateMapper = gameStateMapper; + this.marketDataSupplier = marketDataSupplier; + this.profileImageService = profileImageService; + } + + /** + * Loads all profiles with saved game state, sorted by net worth descending. + */ + public List loadRows() { + MarketData marketData = marketDataSupplier.get(); + if (marketData == null || marketData.isEmpty()) { + throw new IllegalStateException("Could not load market data for leaderboard."); + } + List rows = new ArrayList<>(); + for (UserAccountRecord account : userAccountRepository.listAccounts()) { + Optional snapshot = gameStateRepository.load(account.normalizedUsername()); + if (snapshot.isEmpty()) { + continue; + } + Exchange exchange = gameStateMapper.restoreExchange(snapshot.get().exchange(), marketData); + Player player = gameStateMapper.restorePlayer(snapshot.get().player(), exchange); + BigDecimal netWorth = player.getNetWorth(); + String label = account.displayName() != null && !account.displayName().isBlank() + ? account.displayName().trim() + : account.username(); + boolean hasAvatar = Files.isRegularFile(profileImageService.avatarPath(account.normalizedUsername())); + rows.add(new LeaderboardRow(account.normalizedUsername(), label, netWorth, hasAvatar)); + } + rows.sort(Comparator.comparing(LeaderboardRow::netWorth).reversed()); + return rows; + } +} diff --git a/src/main/java/model/session/ProfileInUseException.java b/src/main/java/model/session/ProfileInUseException.java new file mode 100644 index 0000000..efbb485 --- /dev/null +++ b/src/main/java/model/session/ProfileInUseException.java @@ -0,0 +1,11 @@ +package model.session; + +/** + * Thrown when a profile cannot be deleted because it is the active session. + */ +public final class ProfileInUseException extends RuntimeException { + + public ProfileInUseException(String message) { + super(message); + } +} diff --git a/src/main/java/model/session/SessionService.java b/src/main/java/model/session/SessionService.java index 4bc8960..feb51fe 100644 --- a/src/main/java/model/session/SessionService.java +++ b/src/main/java/model/session/SessionService.java @@ -1,17 +1,24 @@ package model.session; +import java.io.IOException; import java.math.BigDecimal; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Comparator; import java.util.List; import java.util.Optional; import java.util.function.Supplier; +import java.util.stream.Stream; import model.Exchange; import model.Player; import model.persistence.GameStateMapper; import model.persistence.GameStateRepository; import model.persistence.GameStateSnapshot; import model.persistence.MarketData; +import model.persistence.PersistenceException; import model.persistence.PinHashingService; import model.persistence.ProfileDirectories; +import model.persistence.ProfileImageService; import model.persistence.UserAccountRecord; import model.persistence.UserAccountRepository; @@ -25,6 +32,8 @@ public final class SessionService { private final PinHashingService pinHashingService; private final Supplier marketDataSupplier; private final GameStateMapper gameStateMapper; + private final ProfileImageService profileImageService; + private final Path profilesRoot; private ActiveSession activeSession; @@ -36,18 +45,22 @@ public final class SessionService { * @param pinHashingService PIN hashing helper * @param marketDataSupplier supplier that returns fresh bundled market data * @param exchangeName default exchange name used for new profiles + * @param profilesRoot base directory containing all profile folders (same path as repositories) */ public SessionService( UserAccountRepository userAccountRepository, GameStateRepository gameStateRepository, PinHashingService pinHashingService, Supplier marketDataSupplier, - String exchangeName) { + String exchangeName, + Path profilesRoot) { this.userAccountRepository = userAccountRepository; this.gameStateRepository = gameStateRepository; this.pinHashingService = pinHashingService; this.marketDataSupplier = marketDataSupplier; this.gameStateMapper = new GameStateMapper(exchangeName); + this.profileImageService = new ProfileImageService(profilesRoot); + this.profilesRoot = profilesRoot; } /** @@ -109,6 +122,7 @@ public ActiveSession login(String username, char[] pin) { .orElseThrow(() -> new IllegalStateException("Saved game state not found for " + account.username() + ".")); Exchange exchange = gameStateMapper.restoreExchange(snapshot.exchange(), loadMarketData()); Player player = gameStateMapper.restorePlayer(snapshot.player(), exchange); + applyAccountDisplayName(account, player); activeSession = new ActiveSession(account.username(), account.normalizedUsername(), player, exchange); return activeSession; } @@ -166,6 +180,142 @@ public List listRegisteredUsers() { return userAccountRepository.listUsernames(); } + /** + * Updates the display name for the active profile and persists account + game state. + * + * @param displayName new name, or blank to reset to login username + */ + public void updateDisplayName(String displayName) { + ActiveSession session = requireActiveSession(); + UserAccountRecord account = userAccountRepository.findByUsername(session.username()) + .orElseThrow(() -> new IllegalStateException("Account not found.")); + String trimmed = displayName == null ? "" : displayName.trim(); + String effective; + String stored; + if (trimmed.isEmpty()) { + effective = account.username(); + stored = null; + } else { + effective = trimmed; + stored = trimmed.equals(account.username()) ? null : trimmed; + } + session.player().setName(effective); + UserAccountRecord updated = new UserAccountRecord( + account.username(), + account.normalizedUsername(), + account.saltBase64(), + account.pinHashBase64(), + stored); + userAccountRepository.save(updated); + saveActiveSession(); + } + + /** + * Copies an image file into the active profile as the avatar. + * + * @param sourceImage path to PNG or JPEG + */ + public void saveAvatarFromFile(Path sourceImage) { + ActiveSession session = requireActiveSession(); + profileImageService.saveAvatarFromFile(sourceImage, session.normalizedUsername()); + } + + /** Removes the avatar image for the active profile. */ + public void clearAvatar() { + ActiveSession session = requireActiveSession(); + profileImageService.deleteAvatar(session.normalizedUsername()); + } + + /** + * Deletes the active profile after PIN verification and clears the session. + * + * @param pin PIN for the current user + */ + public void deleteActiveProfile(char[] pin) { + ActiveSession session = requireActiveSession(); + UserAccountRecord account = userAccountRepository.findByUsername(session.username()) + .orElseThrow(() -> new IllegalStateException("Account not found.")); + validatePin(pin); + if (!pinHashingService.verifyPin(pin, account.saltBase64(), account.pinHashBase64())) { + throw new AuthenticationException("Invalid PIN."); + } + String normalized = session.normalizedUsername(); + activeSession = null; + deleteProfileDirectory(profilesRoot.resolve(normalized)); + } + + /** + * Deletes a profile after PIN verification. The profile must not be the active session. + * + * @param username profile login name + * @param pin PIN + * @throws ProfileInUseException when that user is logged in + */ + public void deleteProfile(String username, char[] pin) { + validateLoginInput(username, pin); + String normalized = ProfileDirectories.normalizeUsername(username); + UserAccountRecord account = userAccountRepository.findByUsername(username) + .orElseThrow(() -> new AuthenticationException("Invalid username or PIN.")); + if (!pinHashingService.verifyPin(pin, account.saltBase64(), account.pinHashBase64())) { + throw new AuthenticationException("Invalid username or PIN."); + } + if (activeSession != null && activeSession.normalizedUsername().equals(normalized)) { + throw new ProfileInUseException("Log out before deleting this profile."); + } + Path dir = profilesRoot.resolve(normalized); + deleteProfileDirectory(dir); + } + + /** + * Resolves the avatar file path for a normalized username (file may be absent). + */ + public Path avatarPath(String normalizedUsername) { + return profileImageService.avatarPath(normalizedUsername); + } + + /** + * Builds a leaderboard view over all local profiles. + */ + public LocalLeaderboardService leaderboardService() { + return new LocalLeaderboardService( + userAccountRepository, + gameStateRepository, + gameStateMapper, + marketDataSupplier, + profileImageService); + } + + private ActiveSession requireActiveSession() { + if (activeSession == null) { + throw new IllegalStateException("No active session."); + } + return activeSession; + } + + private static void applyAccountDisplayName(UserAccountRecord account, Player player) { + if (account.displayName() != null && !account.displayName().isBlank()) { + try { + player.setName(account.displayName().trim()); + } catch (IllegalArgumentException ignored) { + // keep snapshot name when stored value is invalid + } + } + } + + private static void deleteProfileDirectory(Path dir) { + if (!Files.exists(dir)) { + return; + } + try (Stream walk = Files.walk(dir)) { + List paths = walk.sorted(Comparator.reverseOrder()).toList(); + for (Path path : paths) { + Files.deleteIfExists(path); + } + } catch (IOException exception) { + throw new PersistenceException("Could not delete profile directory: " + dir, exception); + } + } + private MarketData loadMarketData() { MarketData marketData = marketDataSupplier.get(); if (marketData == null || marketData.isEmpty()) { diff --git a/src/main/java/model/session/SessionServiceFactory.java b/src/main/java/model/session/SessionServiceFactory.java index ad240b1..2c58674 100644 --- a/src/main/java/model/session/SessionServiceFactory.java +++ b/src/main/java/model/session/SessionServiceFactory.java @@ -41,6 +41,7 @@ public static SessionService createLocalProfileSessionService( new GameStateRepository(profilesRoot), new PinHashingService(), marketDataSupplier, - exchangeName); + exchangeName, + profilesRoot); } } diff --git a/src/main/java/view/GuiAppShell.java b/src/main/java/view/GuiAppShell.java index 332800f..c31b6fd 100644 --- a/src/main/java/view/GuiAppShell.java +++ b/src/main/java/view/GuiAppShell.java @@ -129,9 +129,14 @@ private void showWorkspace(ActiveSession session) { disposeWorkspace(); workspaceView = workspaceFactory.create( session, + sessionService, this::logoutActiveUser, this::beginSwitchUserFlow, - sessionService::saveActiveSession); + sessionService::saveActiveSession, + () -> { + disposeWorkspace(); + showAuthView(false); + }); authPane = null; setCenter(workspaceView); } diff --git a/src/main/java/view/LeaderboardPanel.java b/src/main/java/view/LeaderboardPanel.java new file mode 100644 index 0000000..d837635 --- /dev/null +++ b/src/main/java/view/LeaderboardPanel.java @@ -0,0 +1,116 @@ +package view; + +import java.io.IOException; +import java.io.InputStream; +import java.math.RoundingMode; +import java.nio.file.Files; +import java.util.List; +import javafx.beans.property.ReadOnlyObjectWrapper; +import javafx.beans.property.SimpleObjectProperty; +import javafx.beans.property.SimpleStringProperty; +import javafx.collections.FXCollections; +import javafx.collections.ObservableList; +import javafx.geometry.Insets; +import javafx.geometry.Pos; +import javafx.scene.control.Label; +import javafx.scene.control.TableCell; +import javafx.scene.control.TableColumn; +import javafx.scene.control.TableView; +import javafx.scene.image.Image; +import javafx.scene.image.ImageView; +import javafx.scene.layout.BorderPane; +import javafx.scene.text.Font; +import javafx.scene.text.FontWeight; +import javafx.scene.text.Text; +import model.session.LocalLeaderboardService.LeaderboardRow; +import model.session.SessionService; + +/** + * Local leaderboard ranked by net worth with avatars. + */ +public class LeaderboardPanel extends BorderPane { + + private final SessionService sessionService; + private final TableView table = new TableView<>(); + private final ObservableList rows = FXCollections.observableArrayList(); + + public LeaderboardPanel(SessionService sessionService) { + this.sessionService = sessionService; + setPadding(new Insets(16)); + + Text heading = new Text("Leaderboard"); + heading.setFont(Font.font("System", FontWeight.BOLD, 22)); + + BorderPane.setAlignment(heading, Pos.CENTER_LEFT); + setTop(heading); + + TableColumn rankCol = new TableColumn<>("Rank"); + rankCol.setPrefWidth(56); + rankCol.setCellValueFactory(c -> { + int i = rows.indexOf(c.getValue()); + return new ReadOnlyObjectWrapper<>(i >= 0 ? i + 1 : 0); + }); + + TableColumn avatarCol = new TableColumn<>(" "); + avatarCol.setPrefWidth(56); + avatarCol.setCellValueFactory(c -> new SimpleObjectProperty<>(c.getValue())); + avatarCol.setCellFactory(col -> new TableCell<>() { + private final ImageView imageView = new ImageView(); + + { + imageView.setFitWidth(40); + imageView.setFitHeight(40); + imageView.setPreserveRatio(true); + setGraphic(imageView); + } + + @Override + protected void updateItem(LeaderboardRow row, boolean empty) { + super.updateItem(row, empty); + if (empty || row == null) { + imageView.setImage(null); + return; + } + imageView.setImage(null); + if (!row.hasAvatar()) { + return; + } + var path = sessionService.avatarPath(row.normalizedUsername()); + if (!Files.isRegularFile(path)) { + return; + } + try (InputStream in = Files.newInputStream(path)) { + imageView.setImage(new Image(in, 40, 40, true, true)); + } catch (IOException exception) { + imageView.setImage(null); + } + } + }); + + TableColumn nameCol = new TableColumn<>("Player"); + nameCol.setCellValueFactory(c -> new SimpleStringProperty(c.getValue().displayName())); + nameCol.setPrefWidth(200); + + TableColumn worthCol = new TableColumn<>("Net worth"); + worthCol.setCellValueFactory( + c -> new SimpleStringProperty( + c.getValue().netWorth().setScale(2, RoundingMode.HALF_UP).toPlainString())); + worthCol.setPrefWidth(140); + + table.getColumns().setAll(List.of(rankCol, avatarCol, nameCol, worthCol)); + table.setItems(rows); + table.setPlaceholder(new Label("No saved profiles yet.")); + table.setColumnResizePolicy(TableView.CONSTRAINED_RESIZE_POLICY_FLEX_LAST_COLUMN); + setCenter(table); + + refresh(); + } + + /** + * Reloads rankings from disk. + */ + public void refresh() { + rows.setAll(sessionService.leaderboardService().loadRows()); + table.refresh(); + } +} diff --git a/src/main/java/view/PlayerPortfolioPanel.java b/src/main/java/view/PlayerPortfolioPanel.java index 48889ce..add8ae0 100644 --- a/src/main/java/view/PlayerPortfolioPanel.java +++ b/src/main/java/view/PlayerPortfolioPanel.java @@ -2,8 +2,12 @@ import static model.utils.Validator.checkNotNull; +import java.io.IOException; +import java.io.InputStream; import java.math.BigDecimal; import java.math.RoundingMode; +import java.nio.file.Files; +import java.nio.file.Path; import java.util.List; import javafx.beans.property.SimpleStringProperty; import javafx.collections.FXCollections; @@ -14,6 +18,8 @@ import javafx.scene.control.Label; import javafx.scene.control.TableColumn; import javafx.scene.control.TableView; +import javafx.scene.image.Image; +import javafx.scene.image.ImageView; import javafx.scene.layout.BorderPane; import javafx.scene.layout.GridPane; import javafx.scene.layout.HBox; @@ -43,6 +49,7 @@ public class PlayerPortfolioPanel extends BorderPane { private final Exchange exchange; private final Player player; + private final Path avatarPath; private final PortfolioPerformanceService performanceService = new PortfolioPerformanceService(); private final Label playerLabel = new Label(); @@ -59,18 +66,22 @@ public class PlayerPortfolioPanel extends BorderPane { private final TableView holdingsTable = new TableView<>(); private final ObservableList holdings = FXCollections.observableArrayList(); + private final ImageView avatarView = new ImageView(); /** * Builds a player summary panel backed by the given exchange and player. * * @param exchange exchange supplying market and trading-day state * @param player player whose summary and holdings should be shown + * @param avatarPath path to profile avatar image (may not exist yet) */ - public PlayerPortfolioPanel(Exchange exchange, Player player) { + public PlayerPortfolioPanel(Exchange exchange, Player player, Path avatarPath) { checkNotNull(exchange, "Exchange"); checkNotNull(player, "Player"); + checkNotNull(avatarPath, "avatarPath"); this.exchange = exchange; this.player = player; + this.avatarPath = avatarPath; setPadding(new Insets(16)); @@ -81,7 +92,12 @@ public PlayerPortfolioPanel(Exchange exchange, Player player) { refreshButton.setOnAction(_ -> refresh()); styleButton(refreshButton); - HBox topRow = new HBox(16, heading, refreshButton); + avatarView.setFitWidth(56); + avatarView.setFitHeight(56); + avatarView.setPreserveRatio(true); + avatarView.setSmooth(true); + + HBox topRow = new HBox(16, avatarView, heading, refreshButton); topRow.setAlignment(Pos.CENTER_LEFT); GridPane summaryGrid = new GridPane(); @@ -166,6 +182,7 @@ private VBox buildMetricsBox() { * Refreshes the labels, holdings list, and side-by-side metrics from the live model state. */ public void refresh() { + loadAvatarThumbnail(); playerLabel.setText(player.getName()); tradingDayLabel.setText(Integer.toString(exchange.getDay())); balanceLabel.setText(player.getMoney().setScale(2, RoundingMode.HALF_UP).toPlainString()); @@ -182,6 +199,18 @@ public void refresh() { holdingsTable.refresh(); } + private void loadAvatarThumbnail() { + avatarView.setImage(null); + if (!Files.isRegularFile(avatarPath)) { + return; + } + try (InputStream in = Files.newInputStream(avatarPath)) { + avatarView.setImage(new Image(in, 56, 56, true, true)); + } catch (IOException exception) { + avatarView.setImage(null); + } + } + /** * Returns the visible player label text. * diff --git a/src/main/java/view/ProfileEditorDialog.java b/src/main/java/view/ProfileEditorDialog.java new file mode 100644 index 0000000..7e69389 --- /dev/null +++ b/src/main/java/view/ProfileEditorDialog.java @@ -0,0 +1,188 @@ +package view; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import javafx.geometry.Insets; +import javafx.geometry.Pos; +import javafx.scene.Scene; +import javafx.scene.control.Button; +import javafx.scene.control.Label; +import javafx.scene.control.PasswordField; +import javafx.scene.control.TextField; +import javafx.scene.image.Image; +import javafx.scene.image.ImageView; +import javafx.scene.layout.GridPane; +import javafx.scene.layout.HBox; +import javafx.scene.layout.VBox; +import javafx.stage.FileChooser; +import javafx.stage.Modality; +import javafx.stage.Stage; +import javafx.stage.Window; +import model.session.ActiveSession; +import model.session.AuthenticationException; +import model.session.SessionService; + +/** + * Modal editor for display name, avatar image, and profile deletion. + */ +public final class ProfileEditorDialog { + + private ProfileEditorDialog() { + } + + /** + * Shows the profile editor for the active session. + * + * @param owner parent window + * @param sessionService session service + * @param onSaved invoked after a successful save (e.g. refresh UI) + * @param onAccountDeleted invoked after the current profile was deleted + */ + public static void show( + Window owner, + SessionService sessionService, + Runnable onSaved, + Runnable onAccountDeleted) { + ActiveSession session = sessionService.getActiveSession().orElseThrow(); + Stage stage = new Stage(); + stage.initOwner(owner); + stage.initModality(Modality.WINDOW_MODAL); + stage.setTitle("Profile"); + + TextField nameField = new TextField(session.player().getName()); + nameField.setPromptText("Display name"); + + ImageView preview = new ImageView(); + preview.setFitWidth(96); + preview.setFitHeight(96); + preview.setPreserveRatio(true); + preview.setSmooth(true); + preview.setStyle("-fx-border-color: #d7dce5; -fx-border-radius: 8;"); + + Path[] pendingImage = {null}; + boolean[] removeAvatar = {false}; + + Runnable reloadPreview = () -> { + preview.setImage(null); + try { + if (pendingImage[0] != null && Files.isRegularFile(pendingImage[0])) { + try (InputStream in = Files.newInputStream(pendingImage[0])) { + preview.setImage(new Image(in, 96, 96, true, true)); + } + return; + } + if (removeAvatar[0]) { + return; + } + Path avatarPath = sessionService.avatarPath(session.normalizedUsername()); + if (Files.isRegularFile(avatarPath)) { + try (InputStream in = Files.newInputStream(avatarPath)) { + preview.setImage(new Image(in, 96, 96, true, true)); + } + } + } catch (IOException exception) { + preview.setImage(null); + } + }; + reloadPreview.run(); + + Label status = new Label(); + status.setStyle("-fx-text-fill: #c62828;"); + + Button chooseImage = new Button("Choose image…"); + chooseImage.setOnAction(_ -> { + FileChooser chooser = new FileChooser(); + chooser.setTitle("Profile image"); + chooser.getExtensionFilters().add( + new FileChooser.ExtensionFilter("Images", "*.png", "*.jpg", "*.jpeg")); + java.io.File file = chooser.showOpenDialog(stage); + if (file != null) { + pendingImage[0] = file.toPath(); + removeAvatar[0] = false; + reloadPreview.run(); + status.setText(""); + } + }); + + Button removeImage = new Button("Remove image"); + removeImage.setOnAction(_ -> { + pendingImage[0] = null; + removeAvatar[0] = true; + preview.setImage(null); + status.setText(""); + }); + + Button save = new Button("Save"); + save.setDefaultButton(true); + save.setOnAction(_ -> { + status.setText(""); + try { + sessionService.updateDisplayName(nameField.getText()); + if (removeAvatar[0]) { + sessionService.clearAvatar(); + } else if (pendingImage[0] != null) { + sessionService.saveAvatarFromFile(pendingImage[0]); + } + onSaved.run(); + stage.close(); + } catch (IllegalArgumentException exception) { + status.setText(exception.getMessage()); + } catch (RuntimeException exception) { + status.setText(exception.getMessage() != null ? exception.getMessage() : "Could not save profile."); + } + }); + + Button cancel = new Button("Cancel"); + cancel.setCancelButton(true); + cancel.setOnAction(_ -> stage.close()); + + Label danger = new Label("Delete this profile"); + danger.setStyle("-fx-font-weight: bold;"); + + PasswordField deletePin = new PasswordField(); + deletePin.setPromptText("PIN to confirm delete"); + + Button delete = new Button("Delete profile"); + delete.setStyle("-fx-text-fill: #b71c1c;"); + delete.setOnAction(_ -> { + status.setText(""); + char[] pin = deletePin.getText().toCharArray(); + try { + sessionService.deleteActiveProfile(pin); + java.util.Arrays.fill(pin, '0'); + stage.close(); + onAccountDeleted.run(); + } catch (AuthenticationException exception) { + java.util.Arrays.fill(pin, '0'); + status.setText("Invalid PIN."); + } catch (RuntimeException exception) { + java.util.Arrays.fill(pin, '0'); + status.setText( + exception.getMessage() != null ? exception.getMessage() : "Could not delete profile."); + } + }); + + GridPane form = new GridPane(); + form.setHgap(10); + form.setVgap(8); + form.addRow(0, new Label("Display name"), nameField); + form.addRow(1, new Label("Photo"), preview); + + HBox imageActions = new HBox(8, chooseImage, removeImage); + VBox root = new VBox(14, + form, + imageActions, + status, + new HBox(10, save, cancel), + danger, + deletePin, + delete); + root.setPadding(new Insets(16)); + root.setAlignment(Pos.TOP_LEFT); + + stage.setScene(new Scene(root, 420, 460)); + stage.showAndWait(); + } +} diff --git a/src/main/java/view/SessionWorkspaceFactory.java b/src/main/java/view/SessionWorkspaceFactory.java index 00621e0..3ccc1c2 100644 --- a/src/main/java/view/SessionWorkspaceFactory.java +++ b/src/main/java/view/SessionWorkspaceFactory.java @@ -1,9 +1,11 @@ package view; +import java.nio.file.Path; import java.util.List; import java.util.stream.Collectors; import model.Stock; import model.session.ActiveSession; +import model.session.SessionService; import view.components.notification.NotificationService; import view.components.toast.ToastMode; @@ -18,34 +20,43 @@ public class SessionWorkspaceFactory { * Creates a new session workspace with fresh views and notifications. * * @param session active session supplying player and exchange state + * @param sessionService session service for avatars and leaderboard * @param logoutAction callback invoked when the user logs out * @param switchUserAction callback invoked when the user wants to switch profiles * @param persistAction callback invoked after a successful model mutation + * @param onProfileAccountDeleted callback after the active profile was deleted from the editor * @return fresh workspace bound to the supplied session */ public SessionWorkspaceView create( ActiveSession session, + SessionService sessionService, Runnable logoutAction, Runnable switchUserAction, - Runnable persistAction) { + Runnable persistAction, + Runnable onProfileAccountDeleted) { NotificationService notifications = new NotificationService(); NotificationsPanel notificationsPanel = new NotificationsPanel(notifications); - PlayerPortfolioPanel playerPanel = new PlayerPortfolioPanel(session.exchange(), session.player()); + Path avatarPath = sessionService.avatarPath(session.normalizedUsername()); + PlayerPortfolioPanel playerPanel = new PlayerPortfolioPanel(session.exchange(), session.player(), avatarPath); StocksListPanel stocksPanel = new StocksListPanel(session.exchange()); FundsListPanel fundsPanel = new FundsListPanel(session.exchange()); + LeaderboardPanel leaderboardPanel = new LeaderboardPanel(sessionService); showLoadedNotifications(notifications, session); return new SessionWorkspaceView( session, + sessionService, notifications, notificationsPanel, playerPanel, stocksPanel, fundsPanel, + leaderboardPanel, logoutAction, switchUserAction, - persistAction); + persistAction, + onProfileAccountDeleted); } private void showLoadedNotifications(NotificationService notifications, ActiveSession session) { diff --git a/src/main/java/view/SessionWorkspaceView.java b/src/main/java/view/SessionWorkspaceView.java index 3ac7436..3bfca1d 100644 --- a/src/main/java/view/SessionWorkspaceView.java +++ b/src/main/java/view/SessionWorkspaceView.java @@ -1,11 +1,16 @@ package view; +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; import javafx.geometry.Insets; import javafx.geometry.Pos; import javafx.scene.control.Button; import javafx.scene.control.Label; import javafx.scene.control.Tab; import javafx.scene.control.TabPane; +import javafx.scene.image.Image; +import javafx.scene.image.ImageView; import javafx.scene.layout.BorderPane; import javafx.scene.layout.HBox; import javafx.scene.layout.StackPane; @@ -14,6 +19,7 @@ import javafx.scene.text.FontWeight; import javafx.scene.text.Text; import model.session.ActiveSession; +import model.session.SessionService; import view.components.notification.NotificationService; import view.components.notification.ToastTray; @@ -23,43 +29,54 @@ public class SessionWorkspaceView extends StackPane { private final ActiveSession session; + private final SessionService sessionService; private final NotificationService notifications; private final PlayerPortfolioPanel playerPanel; private final StocksListPanel stocksPanel; private final FundsListPanel fundsPanel; private final NotificationsPanel notificationsPanel; private final RegularSavingsPanel savingsPanel; + private final LeaderboardPanel leaderboardPanel; private final Label sessionSummaryLabel = new Label(); + private final ImageView headerAvatar = new ImageView(); /** * Builds the logged-in workspace for one active session. * * @param session active session supplying the current player and exchange + * @param sessionService session service for profile and leaderboard actions * @param notifications session-scoped notification service * @param notificationsPanel notifications tab bound to the notification service * @param playerPanel player summary tab * @param stocksPanel stocks listing tab * @param fundsPanel funds listing tab + * @param leaderboardPanel local leaderboard tab * @param logoutAction callback invoked when the user logs out * @param switchUserAction callback invoked when the user wants to switch profiles * @param persistAction callback invoked after a successful model mutation + * @param onProfileAccountDeleted callback after the current profile was deleted from the editor */ public SessionWorkspaceView( ActiveSession session, + SessionService sessionService, NotificationService notifications, NotificationsPanel notificationsPanel, PlayerPortfolioPanel playerPanel, StocksListPanel stocksPanel, FundsListPanel fundsPanel, + LeaderboardPanel leaderboardPanel, Runnable logoutAction, Runnable switchUserAction, - Runnable persistAction) { + Runnable persistAction, + Runnable onProfileAccountDeleted) { this.session = session; + this.sessionService = sessionService; this.notifications = notifications; this.playerPanel = playerPanel; this.stocksPanel = stocksPanel; this.fundsPanel = fundsPanel; this.notificationsPanel = notificationsPanel; + this.leaderboardPanel = leaderboardPanel; this.savingsPanel = new RegularSavingsPanel( session.exchange(), session.player(), @@ -76,20 +93,35 @@ public SessionWorkspaceView( Text heading = new Text("Millions"); heading.setFont(Font.font("System", FontWeight.BOLD, 26)); + headerAvatar.setFitWidth(40); + headerAvatar.setFitHeight(40); + headerAvatar.setPreserveRatio(true); + headerAvatar.setSmooth(true); + + Button profileButton = new Button("Profile"); Button refreshButton = new Button("Refresh all"); Button switchUserButton = new Button("Switch user"); Button logoutButton = new Button("Log out"); + styleButton(profileButton); styleButton(refreshButton); styleButton(switchUserButton); styleButton(logoutButton); + profileButton.setOnAction(_ -> ProfileEditorDialog.show( + getScene().getWindow(), + sessionService, + () -> { + refreshAll(); + persistAction.run(); + }, + onProfileAccountDeleted)); refreshButton.setOnAction(_ -> refreshAll()); switchUserButton.setOnAction(_ -> switchUserAction.run()); logoutButton.setOnAction(_ -> logoutAction.run()); - HBox actions = new HBox(10, refreshButton, switchUserButton, logoutButton); + HBox actions = new HBox(10, profileButton, refreshButton, switchUserButton, logoutButton); actions.setAlignment(Pos.CENTER_RIGHT); - HBox topRow = new HBox(16, heading, sessionSummaryLabel, actions); + HBox topRow = new HBox(16, heading, headerAvatar, sessionSummaryLabel, actions); topRow.setAlignment(Pos.CENTER_LEFT); HBox.setMargin(actions, new Insets(0, 0, 0, 16)); topRow.setFillHeight(true); @@ -113,10 +145,30 @@ public SessionWorkspaceView( */ public void refreshAll() { sessionSummaryLabel.setText( - "Logged in as " + session.username() + " | Trading day " + session.exchange().getDay()); + "Logged in as " + + session.player().getName() + + " (" + + session.username() + + ") | Trading day " + + session.exchange().getDay()); + loadHeaderAvatar(); playerPanel.refresh(); stocksPanel.refresh(); fundsPanel.refresh(); + leaderboardPanel.refresh(); + } + + private void loadHeaderAvatar() { + headerAvatar.setImage(null); + var path = sessionService.avatarPath(session.normalizedUsername()); + if (!Files.isRegularFile(path)) { + return; + } + try (InputStream in = Files.newInputStream(path)) { + headerAvatar.setImage(new Image(in, 40, 40, true, true)); + } catch (IOException exception) { + headerAvatar.setImage(null); + } } /** @@ -145,9 +197,9 @@ public PlayerPortfolioPanel getPlayerPanel() { } /** - * Returns the currently displayed username. + * Returns the login username (not the display name). * - * @return active session username + * @return active session login name */ public String getDisplayedUsername() { return session.username(); @@ -159,12 +211,14 @@ private TabPane createTabs() { Tab stocksTab = new Tab("Stocks", stocksPanel); Tab fundsTab = new Tab("Funds", fundsPanel); Tab savingsTab = new Tab("Savings", savingsPanel); + Tab leaderboardTab = new Tab("Leaderboard", leaderboardPanel); notificationsTab.setClosable(false); playerTab.setClosable(false); stocksTab.setClosable(false); fundsTab.setClosable(false); savingsTab.setClosable(false); + leaderboardTab.setClosable(false); playerTab.selectedProperty().addListener((obs, oldValue, selected) -> { if (Boolean.TRUE.equals(selected)) { @@ -181,8 +235,13 @@ private TabPane createTabs() { fundsPanel.refresh(); } }); + leaderboardTab.selectedProperty().addListener((obs, oldValue, selected) -> { + if (Boolean.TRUE.equals(selected)) { + leaderboardPanel.refresh(); + } + }); - return new TabPane(notificationsTab, playerTab, stocksTab, fundsTab, savingsTab); + return new TabPane(notificationsTab, playerTab, stocksTab, fundsTab, savingsTab, leaderboardTab); } private static void styleButton(Button button) { diff --git a/src/main/java/view/ToastDemoApp.java b/src/main/java/view/ToastDemoApp.java index 399eec3..7064818 100644 --- a/src/main/java/view/ToastDemoApp.java +++ b/src/main/java/view/ToastDemoApp.java @@ -1,6 +1,7 @@ package view; import java.math.BigDecimal; +import java.nio.file.Path; import java.util.List; import java.util.UUID; import java.util.stream.Collectors; @@ -87,7 +88,8 @@ public void start(Stage stage) { Player demoPlayer = new Player("k", new BigDecimal("5000")); showLoadedNotifications(notifications, demoExchange, stocks, demoPlayer); - PlayerPortfolioPanel playerPanel = new PlayerPortfolioPanel(demoExchange, demoPlayer); + PlayerPortfolioPanel playerPanel = + new PlayerPortfolioPanel(demoExchange, demoPlayer, Path.of("/nonexistent/millions-demo-avatar.png")); StocksListPanel stocksPanel = new StocksListPanel(demoExchange); FundsListPanel fundsPanel = new FundsListPanel(demoExchange); playerTab = new Tab("Player", playerPanel); diff --git a/src/main/resources/messages.properties b/src/main/resources/messages.properties index 9989556..235c296 100644 --- a/src/main/resources/messages.properties +++ b/src/main/resources/messages.properties @@ -5,7 +5,7 @@ app.welcome.body=Hello and welcome to Millions. Manage your portfolio and trade menu.header=------------ MENU ----------- session.active.none=-> No user logged in. -session.active.current=-> Active user: {0} | Day {1} +session.active.current=-> Active: {0} (@{1}) | Day {2} menu.option.exit=0. Exit the application menu.option.register=1. Register new user menu.option.login=2. Log in @@ -25,8 +25,11 @@ menu.option.savings.edit=15. Edit regular savings plan menu.option.funds.list=16. List all funds menu.option.funds.search=17. Search funds menu.option.funds.view=18. View fund details +menu.option.profile.name=19. Edit display name +menu.option.profile.avatar=20. Set profile image (file path) +menu.option.profile.delete=21. Delete my profile menu.footer=----------------------------- -menu.prompt=What would you like to do? (Enter a number between 0 and 18). +menu.prompt=What would you like to do? (Enter a number between 0 and 21). invalid.input=-> Invalid input, please try again. validation.startingMoney.nonNegative=Starting money must be non-negative. @@ -52,6 +55,18 @@ auth.duplicateUsername=-> That username is already taken. auth.invalidUsername=-> Username must be 3-32 characters using letters, numbers, underscores, or hyphens. auth.invalidPin=-> PIN must be 4 to 8 digits. +prompt.profile.displayName=Enter display name (blank resets to login name): +profile.name.saved=-> Display name saved. + +prompt.profile.avatarPath=Enter full path to a PNG or JPEG image file: +profile.avatar.emptyPath=-> No path entered. +profile.avatar.saved=-> Profile image updated. +profile.avatar.failed=-> Could not set profile image. + +prompt.profile.deleteConfirm=This will permanently delete your profile and saved game. Type your PIN to confirm. +profile.deleted=-> Profile deleted. +profile.delete.failed=-> Could not delete profile. + balance.current=-> Your current balance: {0} portfolio.empty=-> Your portfolio is empty. diff --git a/src/main/resources/messages_nb.properties b/src/main/resources/messages_nb.properties index f386cdc..206dd75 100644 --- a/src/main/resources/messages_nb.properties +++ b/src/main/resources/messages_nb.properties @@ -5,7 +5,7 @@ app.welcome.body=Velkommen til Millions. Forvalt porteføljen din og handle aksj menu.header=------------ MENY ----------- session.active.none=-> Ingen bruker er logget inn. -session.active.current=-> Aktiv bruker: {0} | Dag {1} +session.active.current=-> Aktiv: {0} (@{1}) | Dag {2} menu.option.exit=0. Avslutt programmet menu.option.register=1. Registrer ny bruker menu.option.login=2. Logg inn @@ -25,8 +25,11 @@ menu.option.savings.edit=15. Rediger spareplan menu.option.funds.list=16. List alle fond menu.option.funds.search=17. Søk i fond menu.option.funds.view=18. Vis fondsdetaljer +menu.option.profile.name=19. Rediger visningsnavn +menu.option.profile.avatar=20. Sett profilbilde (filsti) +menu.option.profile.delete=21. Slett min profil menu.footer=----------------------------- -menu.prompt=Hva vil du gjøre? (Skriv et tall mellom 0 og 18). +menu.prompt=Hva vil du gjøre? (Skriv et tall mellom 0 og 21). invalid.input=-> Ugyldig valg, prøv igjen. validation.startingMoney.nonNegative=Startkapital kan ikke være negativ. @@ -52,6 +55,18 @@ auth.duplicateUsername=-> Dette brukernavnet er allerede tatt. auth.invalidUsername=-> Brukernavnet må være 3-32 tegn og bare bruke bokstaver, tall, understrek eller bindestrek. auth.invalidPin=-> PIN-koden må være 4 til 8 sifre. +prompt.profile.displayName=Skriv inn visningsnavn (tomt = tilbakestill til brukernavn): +profile.name.saved=-> Visningsnavn lagret. + +prompt.profile.avatarPath=Skriv inn full sti til PNG- eller JPEG-fil: +profile.avatar.emptyPath=-> Ingen sti angitt. +profile.avatar.saved=-> Profilbilde oppdatert. +profile.avatar.failed=-> Kunne ikke sette profilbilde. + +prompt.profile.deleteConfirm=Dette sletter profilen og lagret spill permanent. Skriv PIN-kode for å bekrefte. +profile.deleted=-> Profil slettet. +profile.delete.failed=-> Kunne ikke slette profilen. + balance.current=-> Din saldo: {0} portfolio.empty=-> Porteføljen din er tom. diff --git a/src/test/java/model/PlayerTest.java b/src/test/java/model/PlayerTest.java index 2f90622..bb0db48 100644 --- a/src/test/java/model/PlayerTest.java +++ b/src/test/java/model/PlayerTest.java @@ -2,6 +2,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import java.math.BigDecimal; import org.junit.jupiter.api.BeforeEach; @@ -71,4 +72,15 @@ void getNetWorth() { player.withdrawMoney(new BigDecimal("100.00")); assertEquals(new BigDecimal("900.00"), player.getNetWorth()); } + + @Test + void setName_updatesDisplayName() { + player.setName("Alicia"); + assertEquals("Alicia", player.getName()); + } + + @Test + void setName_rejectsBlank() { + assertThrows(IllegalArgumentException.class, () -> player.setName(" ")); + } } diff --git a/src/test/java/model/session/SessionServiceTest.java b/src/test/java/model/session/SessionServiceTest.java index dc6b703..fd9e117 100644 --- a/src/test/java/model/session/SessionServiceTest.java +++ b/src/test/java/model/session/SessionServiceTest.java @@ -1,10 +1,12 @@ package model.session; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import java.math.BigDecimal; +import java.nio.file.Files; import java.nio.file.Path; import java.util.List; import model.Stock; @@ -13,7 +15,9 @@ import model.persistence.GameStateRepository; import model.persistence.MarketData; import model.persistence.PinHashingService; +import model.persistence.ProfileDirectories; import model.persistence.UserAccountRepository; +import model.persistence.UserAccountRecord; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; @@ -71,13 +75,46 @@ void switchingUsers_restoresCorrectSavedStateWithoutLeakage() { assertEquals(new BigDecimal("2025.00"), reloadedBob.player().getMoney()); } + @Test + void updateDisplayName_persistsAcrossLogin() { + SessionService sessionService = createSessionService(); + sessionService.register("Alice", "1234".toCharArray(), new BigDecimal("1000.00")); + sessionService.updateDisplayName("Allie"); + sessionService.logout(); + + ActiveSession back = sessionService.login("Alice", "1234".toCharArray()); + assertEquals("Allie", back.player().getName()); + UserAccountRecord account = new UserAccountRepository(tempDir).findByUsername("Alice").orElseThrow(); + assertEquals("Allie", account.displayName()); + } + + @Test + void deleteActiveProfile_removesFiles() throws Exception { + SessionService sessionService = createSessionService(); + sessionService.register("Zed", "1234".toCharArray(), new BigDecimal("100.00")); + Path profileDir = tempDir.resolve(ProfileDirectories.normalizeUsername("Zed")); + assertTrue(Files.isDirectory(profileDir)); + sessionService.deleteActiveProfile("1234".toCharArray()); + assertFalse(Files.exists(profileDir)); + } + + @Test + void deleteProfile_throwsWhenTargetIsActiveSession() { + SessionService sessionService = createSessionService(); + sessionService.register("Alice", "1234".toCharArray(), new BigDecimal("1000.00")); + assertThrows( + ProfileInUseException.class, + () -> sessionService.deleteProfile("Alice", "1234".toCharArray())); + } + private SessionService createSessionService() { return new SessionService( new UserAccountRepository(tempDir), new GameStateRepository(tempDir), new PinHashingService(), SessionServiceTest::sampleMarketData, - "NYSE"); + "NYSE", + tempDir); } private static MarketData sampleMarketData() { diff --git a/src/test/java/view/GuiAppShellTest.java b/src/test/java/view/GuiAppShellTest.java index 0df2979..192fec9 100644 --- a/src/test/java/view/GuiAppShellTest.java +++ b/src/test/java/view/GuiAppShellTest.java @@ -126,7 +126,8 @@ private SessionService createSessionService() { new GameStateRepository(tempDir), new PinHashingService(), GuiAppShellTest::sampleMarketData, - "NYSE"); + "NYSE", + tempDir); } private static MarketData sampleMarketData() { diff --git a/src/test/java/view/PlayerPortfolioPanelTest.java b/src/test/java/view/PlayerPortfolioPanelTest.java index c210ec2..85b501a 100644 --- a/src/test/java/view/PlayerPortfolioPanelTest.java +++ b/src/test/java/view/PlayerPortfolioPanelTest.java @@ -4,6 +4,7 @@ import static org.junit.jupiter.api.Assertions.assertTrue; import java.math.BigDecimal; +import java.nio.file.Path; import java.util.List; import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; @@ -37,7 +38,8 @@ void emptyPlayerShowsNoTradesAndNoHoldings() throws Exception { Exchange exchange = new Exchange("NYSE", List.of(apple)); Player player = new Player("k", new BigDecimal("5000.00")); - PlayerPortfolioPanel panel = runOnFxThread(() -> new PlayerPortfolioPanel(exchange, player)); + PlayerPortfolioPanel panel = + runOnFxThread(() -> new PlayerPortfolioPanel(exchange, player, Path.of("/no/avatar.png"))); assertEquals("k", panel.getDisplayedPlayerName()); assertEquals("5000.00", panel.getDisplayedBalance()); @@ -51,7 +53,8 @@ void refreshAfterTradeAndAdvanceUpdatesHoldingsAndMetrics() throws Exception { Stock apple = stockWithPrices("AAPL", "Apple Inc.", "100.00"); Exchange exchange = new Exchange("NYSE", List.of(apple)); Player player = new Player("k", new BigDecimal("5000.00")); - PlayerPortfolioPanel panel = runOnFxThread(() -> new PlayerPortfolioPanel(exchange, player)); + PlayerPortfolioPanel panel = + runOnFxThread(() -> new PlayerPortfolioPanel(exchange, player, Path.of("/no/avatar.png"))); exchange.buy("AAPL", BigDecimal.ONE, player); exchange.advance();