Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 76 additions & 2 deletions src/main/java/cli/UserInterface.java
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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"));
}
}
Expand Down Expand Up @@ -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.
*
Expand Down
17 changes: 16 additions & 1 deletion src/main/java/model/Player.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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.
*
Expand Down
11 changes: 11 additions & 0 deletions src/main/java/model/persistence/ProfileDirectories.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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.
*
Expand Down
124 changes: 124 additions & 0 deletions src/main/java/model/persistence/ProfileImageService.java
Original file line number Diff line number Diff line change
@@ -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;
}
}
15 changes: 14 additions & 1 deletion src/main/java/model/persistence/UserAccountRecord.java
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
23 changes: 23 additions & 0 deletions src/main/java/model/persistence/UserAccountRepository.java
Original file line number Diff line number Diff line change
Expand Up @@ -81,4 +81,27 @@ public List<String> 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<UserAccountRecord> listAccounts() {
Path root = profileDirectories.profilesRoot();
if (!Files.exists(root)) {
return List.of();
}
try (Stream<Path> 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);
}
}
}
Loading
Loading