Add avatar controls

This commit is contained in:
2026-03-11 14:21:57 -06:00
parent 3bfdbf7584
commit d52542cae0
7 changed files with 264 additions and 0 deletions

View File

@@ -8,7 +8,11 @@ import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Duration;
import java.util.UUID;
public class ApiClient {
private static final ApiClient INSTANCE = new ApiClient();
@@ -83,6 +87,36 @@ public class ApiClient {
return handleResponse(response, responseClass);
}
public <T> T postMultipart(String path, String partName, Path filePath, Class<T> responseClass) throws Exception {
String boundary = "----PetShopDesktop" + UUID.randomUUID();
String mimeType = Files.probeContentType(filePath);
if (mimeType == null || mimeType.isBlank()) {
mimeType = "application/octet-stream";
}
byte[] fileBytes = Files.readAllBytes(filePath);
String fileName = filePath.getFileName().toString();
byte[] prefix = ("--" + boundary + "\r\n"
+ "Content-Disposition: form-data; name=\"" + partName + "\"; filename=\"" + fileName + "\"\r\n"
+ "Content-Type: " + mimeType + "\r\n\r\n").getBytes(StandardCharsets.UTF_8);
byte[] suffix = ("\r\n--" + boundary + "--\r\n").getBytes(StandardCharsets.UTF_8);
byte[] body = new byte[prefix.length + fileBytes.length + suffix.length];
System.arraycopy(prefix, 0, body, 0, prefix.length);
System.arraycopy(fileBytes, 0, body, prefix.length, fileBytes.length);
System.arraycopy(suffix, 0, body, prefix.length + fileBytes.length, suffix.length);
HttpRequest.Builder builder = HttpRequest.newBuilder()
.uri(URI.create(baseUrl + path))
.header("Content-Type", "multipart/form-data; boundary=" + boundary)
.POST(HttpRequest.BodyPublishers.ofByteArray(body))
.timeout(Duration.ofSeconds(30));
addAuthHeader(builder);
HttpResponse<String> response = httpClient.send(builder.build(), HttpResponse.BodyHandlers.ofString());
return handleResponse(response, responseClass);
}
public <T> T put(String path, Object requestBody, Class<T> responseClass) throws Exception {
String jsonBody = objectMapper.writeValueAsString(requestBody);

View File

@@ -0,0 +1,25 @@
package org.example.petshopdesktop.api.dto.auth;
public class AvatarUploadResponse {
private String avatarUrl;
private String message;
public AvatarUploadResponse() {
}
public String getAvatarUrl() {
return avatarUrl;
}
public void setAvatarUrl(String avatarUrl) {
this.avatarUrl = avatarUrl;
}
public String getMessage() {
return message;
}
public void setMessage(String message) {
this.message = message;
}
}

View File

@@ -0,0 +1,32 @@
package org.example.petshopdesktop.api.endpoints;
import org.example.petshopdesktop.api.ApiClient;
import org.example.petshopdesktop.api.dto.auth.AvatarUploadResponse;
import org.example.petshopdesktop.api.dto.auth.UserInfoResponse;
import java.nio.file.Path;
public class AuthApi {
private static final AuthApi INSTANCE = new AuthApi();
private final ApiClient apiClient;
private AuthApi() {
this.apiClient = ApiClient.getInstance();
}
public static AuthApi getInstance() {
return INSTANCE;
}
public UserInfoResponse getCurrentUser() throws Exception {
return apiClient.get("/api/v1/auth/me", UserInfoResponse.class);
}
public AvatarUploadResponse uploadAvatar(Path filePath) throws Exception {
return apiClient.postMultipart("/api/v1/auth/me/avatar", "avatar", filePath, AvatarUploadResponse.class);
}
public void deleteAvatar() throws Exception {
apiClient.delete("/api/v1/auth/me/avatar");
}
}

View File

@@ -10,6 +10,7 @@ public class UserSession {
private Role role;
private String jwtToken;
private Long storeId;
private String avatarUrl;
private UserSession() {}
@@ -37,6 +38,7 @@ public class UserSession {
this.role = null;
this.jwtToken = null;
this.storeId = null;
this.avatarUrl = null;
}
public Long getUserId() {
@@ -71,6 +73,18 @@ public class UserSession {
this.storeId = storeId;
}
public String getAvatarUrl() {
return avatarUrl;
}
public void setAvatarUrl(String avatarUrl) {
this.avatarUrl = avatarUrl;
}
public void setEmployeeName(String employeeName) {
this.employeeName = employeeName;
}
public boolean isLoggedIn() {
return username != null && role != null;
}

View File

@@ -81,6 +81,8 @@ public class LoginController {
}
UserSession.getInstance().login(userInfo.getId(), username, role, token);
UserSession.getInstance().setStoreId(userInfo.getStoreId());
UserSession.getInstance().setEmployeeName(userInfo.getFullName() == null || userInfo.getFullName().isBlank() ? username : userInfo.getFullName());
UserSession.getInstance().setAvatarUrl(userInfo.getAvatarUrl());
openMainLayout();

View File

@@ -1,5 +1,6 @@
package org.example.petshopdesktop.controllers;
import javafx.application.Platform;
import javafx.event.ActionEvent;
import javafx.fxml.FXML;
import javafx.fxml.FXMLLoader;
@@ -9,10 +10,19 @@ import javafx.scene.control.Alert;
import javafx.scene.control.Button;
import javafx.scene.control.Label;
import javafx.scene.control.Separator;
import javafx.scene.image.Image;
import javafx.scene.input.MouseEvent;
import javafx.scene.layout.StackPane;
import javafx.scene.paint.Color;
import javafx.scene.paint.ImagePattern;
import javafx.scene.shape.Circle;
import javafx.stage.FileChooser;
import javafx.stage.Stage;
import org.example.petshopdesktop.api.ApiConfig;
import org.example.petshopdesktop.api.ChatRealtimeClient;
import org.example.petshopdesktop.api.dto.auth.AvatarUploadResponse;
import org.example.petshopdesktop.api.dto.auth.UserInfoResponse;
import org.example.petshopdesktop.api.endpoints.AuthApi;
import org.example.petshopdesktop.auth.UserSession;
import org.example.petshopdesktop.ui.SvgWebViewFactory;
import org.example.petshopdesktop.util.ActivityLogger;
@@ -79,6 +89,15 @@ public class MainLayoutController {
@FXML
private StackPane logoContainer;
@FXML
private StackPane avatarPreview;
@FXML
private Button btnChangeAvatar;
@FXML
private Button btnRemoveAvatar;
@FXML
private Label lblUsername;
@@ -184,6 +203,42 @@ public class MainLayoutController {
}
}
@FXML
void btnChangeAvatarClicked(ActionEvent event) {
FileChooser chooser = new FileChooser();
chooser.setTitle("Choose Profile Picture");
chooser.getExtensionFilters().addAll(
new FileChooser.ExtensionFilter("Image Files", "*.png", "*.jpg", "*.jpeg", "*.gif")
);
java.io.File file = chooser.showOpenDialog(btnChangeAvatar.getScene().getWindow());
if (file == null) {
return;
}
try {
AvatarUploadResponse response = AuthApi.getInstance().uploadAvatar(file.toPath());
UserSession.getInstance().setAvatarUrl(response.getAvatarUrl());
renderAvatar(UserSession.getInstance().getEmployeeName(), response.getAvatarUrl());
btnRemoveAvatar.setDisable(response.getAvatarUrl() == null || response.getAvatarUrl().isBlank());
} catch (Exception e) {
ActivityLogger.getInstance().logException("MainLayoutController.btnChangeAvatarClicked", e, "Uploading avatar");
showAvatarError(e.getMessage() != null ? e.getMessage() : "Could not upload profile picture.");
}
}
@FXML
void btnRemoveAvatarClicked(ActionEvent event) {
try {
AuthApi.getInstance().deleteAvatar();
UserSession.getInstance().setAvatarUrl(null);
renderAvatar(UserSession.getInstance().getEmployeeName(), null);
btnRemoveAvatar.setDisable(true);
} catch (Exception e) {
ActivityLogger.getInstance().logException("MainLayoutController.btnRemoveAvatarClicked", e, "Deleting avatar");
showAvatarError(e.getMessage() != null ? e.getMessage() : "Could not remove profile picture.");
}
}
@FXML
void btnLogoutClicked(ActionEvent event) {
ChatRealtimeClient.getInstance().disconnect();
@@ -208,6 +263,9 @@ public class MainLayoutController {
@FXML
public void initialize() {
logoContainer.getChildren().setAll(SvgWebViewFactory.build("/org/example/petshopdesktop/images/leons-pet-store-badge-light.svg", 94));
renderAvatar(UserSession.getInstance().getEmployeeName(), UserSession.getInstance().getAvatarUrl());
btnRemoveAvatar.setDisable(UserSession.getInstance().getAvatarUrl() == null || UserSession.getInstance().getAvatarUrl().isBlank());
refreshProfileHeader();
applyRBAC();
UserSession session = UserSession.getInstance();
@@ -220,6 +278,77 @@ public class MainLayoutController {
}
}
private void refreshProfileHeader() {
new Thread(() -> {
try {
UserInfoResponse userInfo = AuthApi.getInstance().getCurrentUser();
String displayName = userInfo.getFullName() == null || userInfo.getFullName().isBlank()
? UserSession.getInstance().getUsername()
: userInfo.getFullName();
Platform.runLater(() -> {
UserSession.getInstance().setEmployeeName(displayName);
UserSession.getInstance().setAvatarUrl(userInfo.getAvatarUrl());
lblUsername.setText(displayName);
renderAvatar(displayName, userInfo.getAvatarUrl());
btnRemoveAvatar.setDisable(userInfo.getAvatarUrl() == null || userInfo.getAvatarUrl().isBlank());
});
} catch (Exception e) {
Platform.runLater(() -> renderAvatar(UserSession.getInstance().getEmployeeName(), UserSession.getInstance().getAvatarUrl()));
}
}).start();
}
private void renderAvatar(String displayName, String avatarUrl) {
Circle border = new Circle(29);
border.setFill(Color.web("#dbe4ee"));
Circle circle = new Circle(26);
Label initials = new Label(initials(displayName));
initials.setStyle("-fx-text-fill: white; -fx-font-weight: bold; -fx-font-size: 16px;");
if (avatarUrl != null && !avatarUrl.isBlank()) {
try {
String resolvedUrl = avatarUrl.startsWith("http") ? avatarUrl : ApiConfig.getInstance().getBaseUrl() + avatarUrl;
Image image = new Image(resolvedUrl, 52, 52, true, true, true);
if (!image.isError()) {
circle.setFill(new ImagePattern(image));
initials.setVisible(false);
} else {
circle.setFill(Color.web("#4ECDC4"));
initials.setVisible(true);
}
} catch (Exception e) {
circle.setFill(Color.web("#4ECDC4"));
initials.setVisible(true);
}
} else {
circle.setFill(Color.web("#4ECDC4"));
initials.setVisible(true);
}
avatarPreview.getChildren().setAll(border, circle, initials);
}
private String initials(String displayName) {
if (displayName == null || displayName.isBlank()) {
return "?";
}
String[] parts = displayName.trim().split("\\s+");
if (parts.length == 1) {
return parts[0].substring(0, 1).toUpperCase();
}
return (parts[0].substring(0, 1) + parts[parts.length - 1].substring(0, 1)).toUpperCase();
}
private void showAvatarError(String message) {
Alert alert = new Alert(Alert.AlertType.ERROR);
alert.setTitle("Profile Picture");
alert.setHeaderText(null);
alert.setContentText(message);
alert.showAndWait();
}
private void applyRBAC() {
UserSession session = UserSession.getInstance();

View File

@@ -44,6 +44,34 @@
<Insets bottom="4.0" />
</padding>
</Label>
<HBox alignment="CENTER_LEFT" spacing="10.0">
<children>
<StackPane fx:id="avatarPreview" maxHeight="58.0" maxWidth="58.0" minHeight="58.0" minWidth="58.0" prefHeight="58.0" prefWidth="58.0" />
<VBox spacing="6.0">
<children>
<Button fx:id="btnChangeAvatar" mnemonicParsing="false" onAction="#btnChangeAvatarClicked" style="-fx-background-color: rgba(255,255,255,0.12); -fx-background-radius: 8; -fx-cursor: hand; -fx-focus-color: transparent; -fx-faint-focus-color: transparent;" text="Change Photo" textFill="#e2e8f0">
<font>
<Font name="System" size="11.0" />
</font>
<padding>
<Insets bottom="6.0" left="10.0" right="10.0" top="6.0" />
</padding>
</Button>
<Button fx:id="btnRemoveAvatar" mnemonicParsing="false" onAction="#btnRemoveAvatarClicked" style="-fx-background-color: transparent; -fx-border-color: rgba(226,232,240,0.35); -fx-border-radius: 8; -fx-background-radius: 8; -fx-cursor: hand; -fx-focus-color: transparent; -fx-faint-focus-color: transparent;" text="Remove" textFill="#cbd5e1">
<font>
<Font name="System" size="11.0" />
</font>
<padding>
<Insets bottom="5.0" left="10.0" right="10.0" top="5.0" />
</padding>
</Button>
</children>
</VBox>
</children>
<VBox.margin>
<Insets bottom="8.0" top="4.0" />
</VBox.margin>
</HBox>
<Separator prefWidth="200.0" style="-fx-background-color: #444444; -fx-opacity: 0.35;" />
</children>
</VBox>