From 82d35feb9ad905bc4a8532f87faaa6147c9e2405 Mon Sep 17 00:00:00 2001 From: Harkamal Randhawa Date: Wed, 11 Mar 2026 14:21:57 -0600 Subject: [PATCH] Add avatar controls --- .gitignore | 1 + .../example/petshopdesktop/api/ApiClient.java | 34 +++++ .../api/dto/auth/AvatarUploadResponse.java | 25 ++++ .../petshopdesktop/api/endpoints/AuthApi.java | 32 +++++ .../petshopdesktop/auth/UserSession.java | 14 ++ .../controllers/LoginController.java | 2 + .../controllers/MainLayoutController.java | 129 ++++++++++++++++++ .../petshopdesktop/main-layout-view.fxml | 28 ++++ 8 files changed, 265 insertions(+) create mode 100644 src/main/java/org/example/petshopdesktop/api/dto/auth/AvatarUploadResponse.java create mode 100644 src/main/java/org/example/petshopdesktop/api/endpoints/AuthApi.java diff --git a/.gitignore b/.gitignore index d0c951ec..c5df67b2 100644 --- a/.gitignore +++ b/.gitignore @@ -47,3 +47,4 @@ log.txt *.class *.out *.err +tmp/ diff --git a/src/main/java/org/example/petshopdesktop/api/ApiClient.java b/src/main/java/org/example/petshopdesktop/api/ApiClient.java index a2e0e279..c0fbd874 100644 --- a/src/main/java/org/example/petshopdesktop/api/ApiClient.java +++ b/src/main/java/org/example/petshopdesktop/api/ApiClient.java @@ -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 postMultipart(String path, String partName, Path filePath, Class 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 response = httpClient.send(builder.build(), HttpResponse.BodyHandlers.ofString()); + return handleResponse(response, responseClass); + } + public T put(String path, Object requestBody, Class responseClass) throws Exception { String jsonBody = objectMapper.writeValueAsString(requestBody); diff --git a/src/main/java/org/example/petshopdesktop/api/dto/auth/AvatarUploadResponse.java b/src/main/java/org/example/petshopdesktop/api/dto/auth/AvatarUploadResponse.java new file mode 100644 index 00000000..24dadcca --- /dev/null +++ b/src/main/java/org/example/petshopdesktop/api/dto/auth/AvatarUploadResponse.java @@ -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; + } +} diff --git a/src/main/java/org/example/petshopdesktop/api/endpoints/AuthApi.java b/src/main/java/org/example/petshopdesktop/api/endpoints/AuthApi.java new file mode 100644 index 00000000..a273a738 --- /dev/null +++ b/src/main/java/org/example/petshopdesktop/api/endpoints/AuthApi.java @@ -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"); + } +} diff --git a/src/main/java/org/example/petshopdesktop/auth/UserSession.java b/src/main/java/org/example/petshopdesktop/auth/UserSession.java index 162ad845..a578d0e4 100644 --- a/src/main/java/org/example/petshopdesktop/auth/UserSession.java +++ b/src/main/java/org/example/petshopdesktop/auth/UserSession.java @@ -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; } diff --git a/src/main/java/org/example/petshopdesktop/controllers/LoginController.java b/src/main/java/org/example/petshopdesktop/controllers/LoginController.java index c353f0cf..70c70e12 100644 --- a/src/main/java/org/example/petshopdesktop/controllers/LoginController.java +++ b/src/main/java/org/example/petshopdesktop/controllers/LoginController.java @@ -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(); diff --git a/src/main/java/org/example/petshopdesktop/controllers/MainLayoutController.java b/src/main/java/org/example/petshopdesktop/controllers/MainLayoutController.java index a4b24d8a..b262be0b 100644 --- a/src/main/java/org/example/petshopdesktop/controllers/MainLayoutController.java +++ b/src/main/java/org/example/petshopdesktop/controllers/MainLayoutController.java @@ -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(); diff --git a/src/main/resources/org/example/petshopdesktop/main-layout-view.fxml b/src/main/resources/org/example/petshopdesktop/main-layout-view.fxml index 9daa91e1..4e1cee6f 100644 --- a/src/main/resources/org/example/petshopdesktop/main-layout-view.fxml +++ b/src/main/resources/org/example/petshopdesktop/main-layout-view.fxml @@ -44,6 +44,34 @@ + + + + + + + + + + + + + +