diff --git a/.github/workflows/maven.yml b/.github/workflows/maven.yml index 8ba43c9..20d0423 100644 --- a/.github/workflows/maven.yml +++ b/.github/workflows/maven.yml @@ -21,11 +21,11 @@ jobs: - name: Checkout repository uses: actions/checkout@v3 - - name: Set up JDK 11 + - name: Set up JDK 17 uses: actions/setup-java@v3 with: distribution: 'temurin' - java-version: '11' + java-version: '17' cache: 'maven' job-status-reporting: false diff --git a/LICENSE b/LICENSE index 84a4959..e69de29 100644 --- a/LICENSE +++ b/LICENSE @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2025 Zan - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/README.MD b/README.MD index 2f69d2e..3207be1 100644 --- a/README.MD +++ b/README.MD @@ -1,7 +1,7 @@ # TabemonPal: The Healthy Eating Gamification App -[![Java](https://img.shields.io/badge/Java-11-red.svg)](https://openjdk.java.net/) +[![Java](https://img.shields.io/badge/Java-17-red.svg)](https://openjdk.java.net/) [![JavaFX](https://img.shields.io/badge/JavaFX-19-blue.svg)](https://openjfx.io/) [![MaterialFX](https://img.shields.io/badge/MaterialFX-11.13.5-purple.svg)](https://github.com/palexdev/MaterialFX) @@ -26,7 +26,7 @@ ### Prerequisites Ensure you have the following installed on your system: -- Java Development Kit (JDK) 11 or higher +- Java Development Kit (JDK) 17 or higher - Maven for dependency management ### Installation @@ -40,7 +40,7 @@ Follow these steps to set up the project locally: 2. **Build the project with Maven**: ```bash - mvn install + mvn -B clean install ``` 3. **Run the application**: @@ -60,7 +60,9 @@ TabemonPal/ │ │ ├── App.java # Main application entry point │ │ ├── api/ │ │ ├── controller/ - │ │ ├── models/ + │ │ ├── data/ + │ │ ├── model/ + │ │ ├── repository/ │ │ └── util/ │ └── resources/ │ └── com/ @@ -72,7 +74,9 @@ TabemonPal/ └── java/ └── com/ └── starlight/ - ├── models/ + ├── api/ + ├── controller/ + ├── model/ └── util/ ``` @@ -93,8 +97,10 @@ An embedded HTTP server that handles user authentication, registration, and data ### Data Repositories XML-based repositories for storing and retrieving user accounts and community posts with support for CRUD operations. +All user-generated data (XML files and uploaded images) is stored in a directory named `.tabemonpal` inside the current user's home folder. This keeps application data separate from the installation path and ensures it persists across updates. + ### ChatGPT Integration -The application leverages OpenAI's ChatGPT through the ChatGPT Java API to provide intelligent consultation and nutritional advice to users. +The application leverages OpenAI's ChatGPT to provide intelligent consultation and nutritional advice to users. ### MVC Architecture The application follows the Model-View-Controller pattern with clear separation between data models, JavaFX views, and controller logic. @@ -118,13 +124,13 @@ mvn test ### Required Libraries - MaterialFX: https://github.com/palexdev/MaterialFX -- ChatGPT Java API: https://github.com/PlexPt/chatgpt-java - XStream: https://x-stream.github.io/ ## 🙏 Acknowledgments - MaterialFX library by Alessandro Parisi for the modern UI components - PlexPt for the ChatGPT Java API integration +- Praventa-desktop by rizalkalam for some code inpiration - XStream library for simplified XML serialization ## 📄 License diff --git a/pom.xml b/pom.xml index e2405ee..7d9a42b 100644 --- a/pom.xml +++ b/pom.xml @@ -6,11 +6,12 @@ com.starlight tabemonpal 0.5-ALPHA + jar UTF-8 - 11 - 11 + 17 + 17 @@ -30,6 +31,16 @@ javafx-web 19 + + org.openjfx + javafx-base + 19 + + + org.openjfx + javafx-graphics + 19 + @@ -46,6 +57,14 @@ org.openjfx javafx-web + + org.openjfx + javafx-base + + + org.openjfx + javafx-graphics + io.github.palexdev materialfx @@ -54,7 +73,7 @@ com.thoughtworks.xstream xstream - 1.4.20 + 1.4.21 org.junit.jupiter @@ -77,7 +96,7 @@ org.testfx openjfx-monocle - 11.0.2 + 17.0.10 test @@ -94,9 +113,9 @@ org.apache.maven.plugins maven-compiler-plugin - 3.8.0 + 3.11.0 - 11 + 17 @@ -131,19 +150,138 @@ 0.0.8 com.starlight.App + + --add-exports=javafx.graphics/com.sun.javafx.application=ALL-UNNAMED + --add-opens=javafx.graphics/com.sun.javafx.application=ALL-UNNAMED + --add-exports=javafx.graphics/com.sun.glass.ui=ALL-UNNAMED + --add-opens=javafx.graphics/com.sun.glass.ui=ALL-UNNAMED + --add-exports=javafx.graphics/com.sun.javafx.util=ALL-UNNAMED + --add-exports=javafx.base/com.sun.javafx.logging=ALL-UNNAMED + --add-exports=javafx.graphics/com.sun.glass.ui=org.testfx + --add-opens=javafx.graphics/com.sun.glass.ui.monocle=ALL-UNNAMED + --add-exports=javafx.controls/com.sun.javafx.scene.control=ALL-UNNAMED + --add-opens=javafx.controls/com.sun.javafx.scene.control=ALL-UNNAMED + --add-opens=javafx.graphics/com.sun.glass.ui.monocle.input.devices=ALL-UNNAMED + --add-opens=javafx.graphics/com.sun.glass.ui.monocle=org.testfx + --add-exports=javafx.graphics/com.sun.glass.ui.monocle=ALL-UNNAMED + --add-exports=javafx.graphics/com.sun.glass.ui=ALL-UNNAMED,org.testfx.monocle + --add-opens=javafx.graphics/com.sun.javafx.tk=ALL-UNNAMED + --add-exports=javafx.graphics/com.sun.javafx.tk=ALL-UNNAMED + --add-exports=javafx.graphics/com.sun.glass.ui.monocle.input=ALL-UNNAMED + --add-exports=javafx.graphics/com.sun.glass.ui.monocle.input.devices=ALL-UNNAMED + --add-opens=javafx.graphics/com.sun.javafx.application.HostServicesDelegate=ALL-UNNAMED + org.apache.maven.plugins maven-surefire-plugin - 2.22.2 + 3.0.0-M9 --add-exports=javafx.graphics/com.sun.javafx.application=ALL-UNNAMED --add-opens=javafx.graphics/com.sun.javafx.application=ALL-UNNAMED + --add-exports=javafx.graphics/com.sun.glass.ui=ALL-UNNAMED + --add-opens=javafx.graphics/com.sun.glass.ui=ALL-UNNAMED + --add-exports=javafx.graphics/com.sun.javafx.util=ALL-UNNAMED + --add-exports=javafx.base/com.sun.javafx.logging=ALL-UNNAMED + --add-exports=javafx.graphics/com.sun.glass.ui=org.testfx + --add-opens=javafx.graphics/com.sun.glass.ui.monocle=ALL-UNNAMED + --add-exports=javafx.controls/com.sun.javafx.scene.control=ALL-UNNAMED + --add-opens=javafx.controls/com.sun.javafx.scene.control=ALL-UNNAMED + --add-opens=javafx.graphics/com.sun.glass.ui.monocle.input.devices=ALL-UNNAMED + --add-opens=javafx.graphics/com.sun.glass.ui.monocle=org.testfx + --add-exports=javafx.graphics/com.sun.glass.ui.monocle=ALL-UNNAMED + --add-exports=javafx.graphics/com.sun.glass.ui=ALL-UNNAMED,org.testfx.monocle + --add-opens=javafx.graphics/com.sun.javafx.tk=ALL-UNNAMED + --add-exports=javafx.graphics/com.sun.javafx.tk=ALL-UNNAMED + --add-exports=javafx.graphics/com.sun.glass.ui.monocle.input=ALL-UNNAMED + --add-exports=javafx.graphics/com.sun.glass.ui.monocle.input.devices=ALL-UNNAMED + --add-opens=javafx.graphics/com.sun.javafx.application.HostServicesDelegate=ALL-UNNAMED + --add-opens=com.starlight/com.starlight=ALL-UNNAMED + --add-opens=com.starlight/com.starlight.api=ALL-UNNAMED + --add-opens=com.starlight/com.starlight.model=ALL-UNNAMED + --add-opens=com.starlight/com.starlight.util=ALL-UNNAMED + --add-opens=com.starlight/com.starlight.controller=ALL-UNNAMED + + org.apache.maven.plugins + maven-shade-plugin + 3.5.1 + + + package + + shade + + + false + false + + + com.starlight.Launcher + + true + javafx.controls/com.sun.javafx.scene.control=ALL-UNNAMED javafx.graphics/com.sun.javafx.application=ALL-UNNAMED javafx.graphics/com.sun.glass.ui=ALL-UNNAMED javafx.graphics/com.sun.javafx.util=ALL-UNNAMED javafx.graphics/com.sun.glass.ui.monocle=ALL-UNNAMED javafx.graphics/com.sun.javafx.tk=ALL-UNNAMED javafx.graphics/com.sun.javafx.application.HostServicesDelegate=ALL-UNNAMED + javafx.graphics/com.sun.javafx.application=ALL-UNNAMED javafx.graphics/com.sun.glass.ui=ALL-UNNAMED javafx.graphics/com.sun.javafx.util=ALL-UNNAMED javafx.base/com.sun.javafx.logging=ALL-UNNAMED javafx.controls/com.sun.javafx.scene.control=ALL-UNNAMED javafx.graphics/com.sun.glass.ui.monocle=ALL-UNNAMED javafx.graphics/com.sun.javafx.tk=ALL-UNNAMED javafx.graphics/com.sun.glass.ui.monocle.input=ALL-UNNAMED javafx.graphics/com.sun.glass.ui.monocle.input.devices=ALL-UNNAMED + + + + + + + *:* + + META-INF/*.SF + META-INF/*.DSA + META-INF/*.RSA + module-info.class + META-INF/versions/*/module-info.class + + + + + + + + + org.apache.maven.plugins + maven-antrun-plugin + 3.1.0 + + + package + + run + + + + + + + + + + + org.codehaus.mojo + exec-maven-plugin + 3.1.0 + + com.starlight.App + + --add-modules + javafx.controls,javafx.fxml,javafx.web + --add-exports=javafx.graphics/com.sun.javafx.application=ALL-UNNAMED + --add-opens=javafx.graphics/com.sun.javafx.application=ALL-UNNAMED + --add-exports=javafx.graphics/com.sun.glass.ui=ALL-UNNAMED + --add-opens=javafx.graphics/com.sun.glass.ui=ALL-UNNAMED + --add-exports=javafx.controls/com.sun.javafx.scene.control=ALL-UNNAMED + --add-opens=javafx.controls/com.sun.javafx.scene.control=ALL-UNNAMED + + + diff --git a/src/main/java/com/starlight/App.java b/src/main/java/com/starlight/App.java index d1fa554..f7390c7 100644 --- a/src/main/java/com/starlight/App.java +++ b/src/main/java/com/starlight/App.java @@ -4,23 +4,53 @@ import javafx.fxml.FXMLLoader; import javafx.scene.Parent; import javafx.scene.Scene; +import javafx.scene.image.Image; import javafx.stage.Stage; import javafx.animation.PauseTransition; import javafx.util.Duration; import com.starlight.util.FXMLVerificator; +import com.starlight.util.FileSystemManager; import com.starlight.api.UserApiServer; import java.io.IOException; - +import java.util.logging.Logger; +import java.util.logging.Level; /** * Main JavaFX application entry point. This class starts the embedded * {@link UserApiServer} and loads the initial FXML views. */ public class App extends Application { + private static final Logger logger = Logger.getLogger(App.class.getName()); /** Main application scene used to swap views. */ private static Scene scene; + + /** + * Resizes the application window and centers it on the screen. + * + * @param width The new width of the window. + * @param height The new height of the window. + */ + public static void resizeWindow(double width, double height) { + Stage stage = (Stage) scene.getWindow(); + if (stage != null) { + stage.setWidth(width); + stage.setHeight(height); + stage.centerOnScreen(); + } + } + + /** + * Maximizes the application window to fill the screen. + */ + public static void maximizeStage() { + Stage stage = (Stage) scene.getWindow(); + if (stage != null) { + stage.setMaximized(true); + } + } + /** Embedded HTTP server providing user API endpoints. */ private UserApiServer apiServer; /** Thread running the API server. */ @@ -38,10 +68,17 @@ public void start(Stage stage) throws IOException { apiThread.start(); FXMLVerificator.verifyAll(); - scene = new Scene(loadFXML("splashScreen"), 1280, 720); - - scene = new Scene(loadFXML("splashScreen"), 1280, 720); + scene = new Scene(loadFXML("splashScreen")); stage.setScene(scene); + resizeWindow(1280, 720); + + try { + Image icon = new Image(getClass().getResourceAsStream("/com/starlight/images/AppLogo.png")); + stage.getIcons().add(icon); + } catch (Exception e) { + logger.log(Level.WARNING, "Could not load application icon: " + e.getMessage(), e); + } + stage.setTitle("TabemonPal by Starlight Inc."); stage.setResizable(true); stage.show(); @@ -58,6 +95,14 @@ public void start(Stage stage) throws IOException { */ public static void setRoot(String fxml) throws IOException { scene.setRoot(loadFXML(fxml)); + + + // Maximize the stage based on the loaded FXML + if ("main".equals(fxml)) { + maximizeStage(); + } else if ("Authorization".equals(fxml)) { + resizeWindow(1280, 720); + } } /** @@ -77,31 +122,25 @@ public static void showLogin() { */ public static void loadMainWithSplash() { try { - System.out.println("Loading splash screen before main view"); scene.setRoot(loadFXML("splashScreen")); } catch (IOException ex) { - System.err.println("Failed to load splash screen: " + ex.getMessage()); - ex.printStackTrace(); + logger.log(Level.SEVERE, "Failed to load splash screen: " + ex.getMessage(), ex); return; } PauseTransition delay = new PauseTransition(Duration.seconds(1)); delay.setOnFinished(e -> { try { - System.out.println("Now loading main view"); - scene.setRoot(loadFXML("main")); - System.out.println("Main view loaded successfully"); + setRoot("main"); } catch (IOException ex) { - System.err.println("Failed to load main view: " + ex.getMessage()); - ex.printStackTrace(); + logger.log(Level.SEVERE, "Failed to load main view: " + ex.getMessage(), ex); // Fallback - try to load main directly if there was an error try { - System.out.println("Attempting direct load of main view"); - scene.setRoot(loadFXML("main")); + logger.info("Attempting direct load of main view"); + setRoot("main"); } catch (IOException fallbackEx) { - System.err.println("Fallback failed too: " + fallbackEx.getMessage()); - fallbackEx.printStackTrace(); + logger.log(Level.SEVERE, "Fallback failed too: " + fallbackEx.getMessage(), fallbackEx); } } }); @@ -119,10 +158,10 @@ private static Parent loadFXML(String fxml) throws IOException { FXMLLoader fxmlLoader = new FXMLLoader(App.class.getResource(resourcePath)); if (fxmlLoader.getLocation() == null) { - System.err.println("FXML resource not found: " + resourcePath); + logger.warning("FXML resource not found: " + resourcePath); // Try with leading slash resourcePath = "/com/starlight/view/" + fxml + ".fxml"; - System.out.println("Trying alternative path: " + resourcePath); + logger.info("Trying alternative path: " + resourcePath); fxmlLoader = new FXMLLoader(App.class.getClassLoader().getResource(resourcePath)); if (fxmlLoader.getLocation() == null) { @@ -150,7 +189,20 @@ public void stop() throws Exception { * @param args command line arguments */ public static void main(String[] args) { + FileSystemManager.initializeAppDataDirectory(); launch(); + + /*Login information + * + * Administrator: + * Username: admin + * Password: admin123 + * + * User: + * Username: user + * Password: user + * + */ } } \ No newline at end of file diff --git a/src/main/java/com/starlight/api/ApiClient.java b/src/main/java/com/starlight/api/ApiClient.java index 43bf6af..333d593 100644 --- a/src/main/java/com/starlight/api/ApiClient.java +++ b/src/main/java/com/starlight/api/ApiClient.java @@ -7,9 +7,9 @@ import java.net.URL; import java.nio.charset.StandardCharsets; +import com.starlight.model.User; import com.thoughtworks.xstream.XStream; import com.thoughtworks.xstream.io.xml.DomDriver; -import com.starlight.models.User; /** * Client for interacting with the User API Server. @@ -21,7 +21,7 @@ public class ApiClient { public ApiClient() { this.xstream = new XStream(new DomDriver()); - this.xstream.allowTypesByWildcard(new String[]{"com.starlight.models.*"}); + this.xstream.allowTypesByWildcard(new String[]{"com.starlight.model.*"}); this.xstream.alias("user", User.class); this.xstream.alias("error", ApiError.class); } diff --git a/src/main/java/com/starlight/api/ChatbotAPI.java b/src/main/java/com/starlight/api/ChatbotAPI.java new file mode 100644 index 0000000..c0b507d --- /dev/null +++ b/src/main/java/com/starlight/api/ChatbotAPI.java @@ -0,0 +1,440 @@ +package com.starlight.api; + +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Duration; +import java.util.logging.Logger; +import java.util.logging.Level; + +import com.thoughtworks.xstream.XStream; +import com.thoughtworks.xstream.io.xml.DomDriver; + +/** + * API for interacting with OpenAI ChatGPT service. + * Handles API key management and chat completions using HTTP client. + */ +public class ChatbotAPI { + private static final Logger logger = Logger.getLogger(ChatbotAPI.class.getName()); + private static final String OPENAI_API_URL = "https://api.openai.com/v1/chat/completions"; + private String apiKey; + private HttpClient httpClient; + private XStream xstream; + + /** + * Configuration model for the SECRET_KEY.xml file. + */ + public static class Config { + private String openaiKey; + + public String getOpenaiKey() { + return openaiKey; + } + + public void setOpenaiKey(String openaiKey) { + this.openaiKey = openaiKey; + } + } + + public ChatbotAPI() { + this.xstream = new XStream(new DomDriver()); + this.xstream.allowTypesByWildcard(new String[]{"com.starlight.api.*"}); + this.xstream.alias("config", Config.class); + this.xstream.aliasField("openai-key", Config.class, "openaiKey"); + + this.httpClient = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(30)) + .build(); + + try { + loadApiKey(); + } catch (Exception e) { + logger.log(Level.WARNING, "Could not load API key: " + e.getMessage(), e); + logger.warning("Chatbot functionality will be disabled."); + } + } + + /** + * Loads the API key from the SECRET_KEY.xml file. + */ + private void loadApiKey() { + String userHome = System.getProperty("user.home"); + String secretKeyPath = userHome + File.separator + ".tabemonpal" + File.separator + "Database" + File.separator + ".SECRET_KEY.xml"; + + try (FileInputStream fis = new FileInputStream(secretKeyPath)) { + Config config = (Config) xstream.fromXML(fis); + this.apiKey = config.getOpenaiKey(); + + if (this.apiKey == null || this.apiKey.trim().isEmpty() || this.apiKey.equals("your-openai-api-key-here")) { + logger.warning("No valid API key found in SECRET_KEY.xml. Chatbot will work in demo mode."); + this.apiKey = null; + } + } catch (IOException e) { + logger.log(Level.WARNING, "Could not load API key from " + secretKeyPath + ": " + e.getMessage(), e); + this.apiKey = null; + } catch (Exception e) { + logger.log(Level.WARNING, "Error parsing SECRET_KEY.xml: " + e.getMessage(), e); + this.apiKey = null; + } + } + + /** + * Sends a message to the ChatGPT API and returns the response. + * + * @param userMessage The message from the user + * @return The ChatGPT response + * @throws ChatbotException if the API call fails + */ + public String sendMessage(String userMessage) throws ChatbotException { + return sendMessage(userMessage, null); + } + + /** + * Sends a message to the ChatGPT API with context and returns the response. + * + * @param userMessage The message from the user + * @param systemMessage Optional system message to set context + * @return The ChatGPT response + * @throws ChatbotException if the API call fails + */ + public String sendMessage(String userMessage, String systemMessage) throws ChatbotException { + // Check for special Fumo keywords + if (isFumoKeyword(userMessage)) { + return "Fumo ᗜˬᗜ"; + } + + if (apiKey == null) { + return "I'm currently in demo mode. Please configure your OpenAI API key in the Settings to enable full functionality."; + } + + try { + String requestBody = buildRequestBody(userMessage, systemMessage); + + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(OPENAI_API_URL)) + .header("Content-Type", "application/json") + .header("Authorization", "Bearer " + apiKey) + .timeout(Duration.ofSeconds(60)) + .POST(HttpRequest.BodyPublishers.ofString(requestBody)) + .build(); + + HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + + if (response.statusCode() == 200) { + return parseResponse(response.body()); + } else { + String errorMessage = "API request failed with status " + response.statusCode() + + ": " + response.body(); + logger.severe("OpenAI API Error: " + errorMessage); + throw new ChatbotException(errorMessage); + } + + } catch (ChatbotException e) { + throw e; // Re-throw our custom exceptions + } catch (Exception e) { + String errorMessage = "Failed to get response from ChatGPT: " + e.getMessage(); + logger.log(Level.SEVERE, "ChatGPT Request Error: " + errorMessage, e); + throw new ChatbotException(errorMessage, e); + } + } + + /** + * Sends a nutrition-related question to ChatGPT with appropriate context. + * + * @param question The nutrition question + * @return The ChatGPT response + * @throws ChatbotException if the API call fails + */ + public String askNutritionQuestion(String question) throws ChatbotException { + String systemMessage = "You are Kuro , a helpful nutrition assistant for the TabemonPal app. " + + "Provide accurate, helpful nutrition advice and food recommendations. " + + "Keep your responses concise but informative. " + + "you may use rich text formatting to enhance readability." + + "If you're unsure about medical advice, recommend consulting with a healthcare professional." + + "Avoid giving information on topics outside of nutrition, health, or wellness."; + + return sendMessage(question, systemMessage); + } + + /** + * Analyzes nutrition facts for given ingredients using ChatGPT. + * + * @param ingredients The ingredients string (vertical line separated) + * @return The nutrition facts in XML format + * @throws ChatbotException if the API call fails + */ + public String analyzeNutritionFacts(String ingredients) throws ChatbotException { + // Check if we're in demo mode (no API key) + if (apiKey == null) { + logger.warning("Cannot perform nutrition analysis - no API key configured"); + throw new ChatbotException("Nutrition analysis is not available. Please configure your OpenAI API key in ~/.tabemonpal/Database/.SECRET_KEY.xml to enable this feature."); + } + + if (ingredients == null || ingredients.trim().isEmpty()) { + throw new ChatbotException("Cannot analyze nutrition facts for empty ingredients list"); + } + + String ingredientsList = ingredients.replace("|", "\n"); + + String prompt = "Do an prediction analysis from this recipe's ingredients for nutrition facts using USDA then send the result in xml called nutrition:\n\n" + + "format:\n" + + "\n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + "\n\n" + + "Ingredients: " + ingredientsList; + + String systemMessage = "You are a nutrition analysis assistant. " + + "Provide ONLY the XML nutrition data as requested, without any additional text or formatting. " + + "Use USDA nutritional database for accurate values. " + + "If exact amounts are not specified in ingredients, estimate reasonable serving sizes. " + + "For the verdict attribute, analyze overall nutrition and classify as: " + + "- 'Healthy': Low sugar/salt, high fiber/protein, balanced macros " + + "- 'Moderate': Average nutritional balance " + + "- 'Unhealthy': High sugar/salt/fat, low nutrients " + + "- 'Junk Food': Very high sugar/fat/salt, minimal nutritional value " + + "- 'Unknown': When nutritional analysis cannot be determined " + + "Return only valid XML that matches the exact format requested."; + + try { + String response = sendMessage(prompt, systemMessage); + + // Basic validation that we got something that looks like XML + if (response == null || response.trim().isEmpty()) { + throw new ChatbotException("Received empty response from nutrition analysis API"); + } + + // Log the response length for monitoring + logger.info("Nutrition analysis API response length: " + response.length() + " characters"); + + // Check for nutrition XML tags with more detailed error reporting + boolean hasNutritionStart = response.contains(""); + + if (!hasNutritionStart && !hasNutritionEnd) { + throw new ChatbotException("API response does not contain nutrition XML tags. Response: " + response); + } else if (!hasNutritionStart) { + throw new ChatbotException("API response missing opening tag. Response: " + response); + } else if (!hasNutritionEnd) { + // Try to repair incomplete XML by adding closing tag + logger.warning("API response missing closing tag, attempting to repair XML"); + String repairedResponse = response.trim(); + if (!repairedResponse.endsWith("")) { + // If the response looks like it was cut off, add the closing tag + if (repairedResponse.contains("")) { + // Find the last complete ingredient and add closing tags + int lastCompleteIngredient = repairedResponse.lastIndexOf(""); + if (lastCompleteIngredient > 0) { + repairedResponse = repairedResponse.substring(0, lastCompleteIngredient + "".length()) + "\n"; + logger.info("Repaired XML response: " + repairedResponse); + return repairedResponse; + } + } + // Simple repair: just add the closing tag + repairedResponse += "\n"; + logger.info("Repaired XML by adding closing tag: " + repairedResponse); + return repairedResponse; + } else { + throw new ChatbotException("API response missing closing tag. Response: " + response); + } + } + + return response; + + } catch (ChatbotException e) { + // Add more context to the error + throw new ChatbotException("Nutrition analysis failed: " + e.getMessage(), e); + } + } + + /** + * Builds the JSON request body for the OpenAI API. + */ + private String buildRequestBody(String userMessage, String systemMessage) { + StringBuilder json = new StringBuilder(); + json.append("{"); + json.append("\"model\": \"gpt-4o-mini\","); + json.append("\"messages\": ["); + + if (systemMessage != null && !systemMessage.trim().isEmpty()) { + json.append("{\"role\": \"system\", \"content\": \"") + .append(escapeJson(systemMessage)) + .append("\"},"); + } + + json.append("{\"role\": \"user\", \"content\": \"") + .append(escapeJson(userMessage)) + .append("\"}"); + + json.append("],"); + json.append("\"max_tokens\": 2000,"); + json.append("\"temperature\": 0.9"); + json.append("}"); + + return json.toString(); + } + + /** + * Parses the JSON response from OpenAI API. + * Package-private for testing. + */ + String parseResponse(String jsonResponse) throws ChatbotException { + try { + // OpenAI API response format: {"choices":[{"message":{"content":"..."}}]} + // Look for the choices array first (more flexible with whitespace) + String choicesPattern = "\"choices\""; + int choicesIndex = jsonResponse.indexOf(choicesPattern); + if (choicesIndex == -1) { + throw new ChatbotException("Invalid response format - no choices array found"); + } + + // Find the opening bracket for choices array + int arrayStart = jsonResponse.indexOf('[', choicesIndex); + if (arrayStart == -1) { + throw new ChatbotException("Invalid response format - choices array malformed"); + } + + // Find the first message content within choices + String messageStart = "\"message\""; + int messageIndex = jsonResponse.indexOf(messageStart, arrayStart); + if (messageIndex == -1) { + throw new ChatbotException("Invalid response format - no message object found"); + } + + // Find content field within the message (flexible with whitespace) + String contentField = "\"content\""; + int contentIndex = jsonResponse.indexOf(contentField, messageIndex); + if (contentIndex == -1) { + throw new ChatbotException("Invalid response format - no content field found"); + } + + // Find the colon and opening quote + int colonIndex = jsonResponse.indexOf(':', contentIndex); + if (colonIndex == -1) { + throw new ChatbotException("Invalid response format - content field malformed"); + } + + // Skip whitespace after colon + int quoteIndex = colonIndex + 1; + while (quoteIndex < jsonResponse.length() && + (jsonResponse.charAt(quoteIndex) == ' ' || jsonResponse.charAt(quoteIndex) == '\t' || + jsonResponse.charAt(quoteIndex) == '\n' || jsonResponse.charAt(quoteIndex) == '\r')) { + quoteIndex++; + } + + // Expect opening quote + if (quoteIndex >= jsonResponse.length() || jsonResponse.charAt(quoteIndex) != '"') { + throw new ChatbotException("Invalid response format - content value not properly quoted"); + } + + int startIndex = quoteIndex + 1; // Skip the opening quote + + // Find the end of the content string, handling escaped quotes + int endIndex = findEndOfJsonString(jsonResponse, startIndex); + if (endIndex == -1) { + throw new ChatbotException("Invalid response format - malformed content string"); + } + + String content = jsonResponse.substring(startIndex, endIndex); + return unescapeJson(content); + + } catch (ChatbotException e) { + throw e; // Re-throw our custom exceptions + } catch (Exception e) { + throw new ChatbotException("Failed to parse API response: " + e.getMessage() + + "\nResponse: " + (jsonResponse.length() > 500 ? + jsonResponse.substring(0, 500) + "..." : jsonResponse)); + } + } + + /** + * Finds the end of a JSON string value, properly handling escaped characters. + */ + private int findEndOfJsonString(String json, int startIndex) { + for (int i = startIndex; i < json.length(); i++) { + char c = json.charAt(i); + if (c == '"') { + // Check if this quote is escaped + int backslashCount = 0; + int j = i - 1; + while (j >= startIndex && json.charAt(j) == '\\') { + backslashCount++; + j--; + } + // If even number of backslashes (including 0), the quote is not escaped + if (backslashCount % 2 == 0) { + return i; + } + } + } + return -1; // Not found + } + + /** + * Escapes JSON special characters. + */ + private String escapeJson(String text) { + if (text == null) return ""; + return text.replace("\\", "\\\\") + .replace("\"", "\\\"") + .replace("\n", "\\n") + .replace("\r", "\\r") + .replace("\t", "\\t"); + } + + /** + * Unescapes JSON special characters. + */ + private String unescapeJson(String text) { + if (text == null) return ""; + return text.replace("\\\"", "\"") + .replace("\\\\", "\\") + .replace("\\n", "\n") + .replace("\\r", "\r") + .replace("\\t", "\t"); + } + + /** + * Checks if the user message contains specific Fumo keywords. + * + * @param message The user message to check + * @return true if the message contains Fumo keywords, false otherwise + */ + private boolean isFumoKeyword(String message) { + if (message == null || message.trim().isEmpty()) { + return false; + } + + String trimmed = message.trim(); + + // Check for exact matches (case-insensitive and case-sensitive variants) + return trimmed.equalsIgnoreCase("fumo") || + trimmed.equals("Fumo") || + trimmed.equals("FUMO") || + trimmed.equalsIgnoreCase("funa"); + } + + /** + * Exception thrown when chatbot operations fail. + */ + public static class ChatbotException extends Exception { + public ChatbotException(String message) { + super(message); + } + + public ChatbotException(String message, Throwable cause) { + super(message, cause); + } + } +} diff --git a/src/main/java/com/starlight/api/UserApiServer.java b/src/main/java/com/starlight/api/UserApiServer.java index 356575f..2328ad7 100644 --- a/src/main/java/com/starlight/api/UserApiServer.java +++ b/src/main/java/com/starlight/api/UserApiServer.java @@ -7,11 +7,14 @@ import java.nio.charset.StandardCharsets; import java.util.List; import java.util.Optional; +import java.util.logging.Logger; +import java.util.logging.Level; import com.thoughtworks.xstream.XStream; import com.thoughtworks.xstream.io.xml.DomDriver; -import com.starlight.models.User; -import com.starlight.models.UserDataRepository; +import com.starlight.model.User; +import com.starlight.repository.LocalUserDataRepository; +import com.starlight.repository.UserDataRepository; import com.sun.net.httpserver.HttpExchange; import com.sun.net.httpserver.HttpHandler; import com.sun.net.httpserver.HttpServer; @@ -21,10 +24,14 @@ * {@link XStream} for XML serialization of {@link User} objects. */ public class UserApiServer { + private static final Logger logger = Logger.getLogger(UserApiServer.class.getName()); + /** Underlying lightweight HTTP server. */ private HttpServer server; /** Repository used to read and write user data. */ private final UserDataRepository repository = new UserDataRepository(); + /** Local user data repository for admin and user accounts. */ + private final LocalUserDataRepository localRepository = new LocalUserDataRepository(); /** XStream instance configured for the user model. */ private final XStream xstream = new XStream(new DomDriver()); /** Flag to prevent multiple starts */ @@ -40,7 +47,7 @@ public class UserApiServer { */ public UserApiServer(int port) throws IOException { - xstream.allowTypesByWildcard(new String[]{"com.starlight.models.*", "java.util.*"}); + xstream.allowTypesByWildcard(new String[]{"com.starlight.model.*", "com.starlight.model.*", "java.util.*"}); xstream.alias("user", User.class); xstream.alias("users", List.class); repository.ensureDummyData(); @@ -53,11 +60,11 @@ public UserApiServer(int port) throws IOException { for (int attempt = 0; attempt < maxAttempts; attempt++) { try { server = HttpServer.create(new InetSocketAddress(currentPort), 0); - System.out.println("Server created on port " + currentPort); + logger.info("Server created on port " + currentPort); serverCreated = true; break; } catch (BindException e) { - System.out.println("Port " + currentPort + " already in use, trying next port..."); + logger.info("Port " + currentPort + " already in use, trying next port..."); currentPort++; } } @@ -104,13 +111,25 @@ public void handle(HttpExchange exchange) throws IOException { String body = new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8); User creds = (User) xstream.fromXML(body); + + // First check local users (admin and user accounts) + String loginIdentifier = creds.email; // This could be username or email + User localUser = localRepository.validateCredentials(loginIdentifier, creds.password); + + if (localUser != null) { + logger.info("Local user authenticated: " + localUser.username); + sendXml(exchange, 200, xstream.toXML(localUser)); + return; + } + + // Fallback to regular repository users List users = repository.loadUsers(); // Check for either email or username match Optional match = users.stream() .filter(u -> ((u.email != null && u.email.equals(creds.email)) || (u.username != null && u.username.equals(creds.email))) && - u.password.equals(creds.password)) + u.password != null && u.password.equals(creds.password)) .findFirst(); if (match.isPresent()) { @@ -140,7 +159,13 @@ public void handle(HttpExchange exchange) throws IOException { } String body = new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8); User newUser = (User) xstream.fromXML(body); - List users = repository.loadUsers(); + + // Set default profile picture for new users + if (newUser.profilepicture == null || newUser.profilepicture.trim().isEmpty()) { + newUser.profilepicture = "src/main/resources/com/starlight/images/dummy/profiledefault.png"; + } + + List users = repository.loadUsers(false); boolean exists = users.stream().anyMatch(u -> newUser.email.equals(u.email)); if (exists) { sendXml(exchange, 409, ""); @@ -181,8 +206,7 @@ public void handle(HttpExchange exchange) throws IOException { } } catch (Exception e) { // Log the exception - System.err.println("Error handling user request: " + e.getMessage()); - e.printStackTrace(); + logger.log(Level.SEVERE, "Error handling user request: " + e.getMessage(), e); sendErrorResponse(exchange, 500, "Internal server error: " + e.getMessage()); } } @@ -205,6 +229,14 @@ private String extractUsername(String path) { * Handles GET request to retrieve user profile */ private void handleGetUser(HttpExchange exchange, String username) throws IOException { + // First check local users (admin and user accounts) + User localUser = localRepository.findUser(username); + if (localUser != null) { + sendXml(exchange, 200, xstream.toXML(localUser)); + return; + } + + // Fallback to regular repository users List users = repository.loadUsers(); Optional user = users.stream() .filter(u -> u.username != null && u.username.equals(username)) @@ -221,6 +253,13 @@ private void handleGetUser(HttpExchange exchange, String username) throws IOExce * Handles PUT request to update user profile */ private void handleUpdateUser(HttpExchange exchange, String username) throws IOException { + // Check if trying to update local users (admin or user) + User localUser = localRepository.findUser(username); + if (localUser != null) { + sendErrorResponse(exchange, 403, "Local system accounts cannot be modified"); + return; + } + // Read and parse request body String body = new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8); if (body == null || body.isEmpty()) { @@ -237,7 +276,7 @@ private void handleUpdateUser(HttpExchange exchange, String username) throws IOE return; } - // Find and update user + // Find and update user in regular repository List users = repository.loadUsers(); boolean userFound = false; diff --git a/src/main/java/com/starlight/controller/AchievementController.java b/src/main/java/com/starlight/controller/AchievementController.java index b56f4a4..2d7e722 100644 --- a/src/main/java/com/starlight/controller/AchievementController.java +++ b/src/main/java/com/starlight/controller/AchievementController.java @@ -3,7 +3,9 @@ import java.net.URL; import java.util.ResourceBundle; +import javafx.fxml.FXML; import javafx.fxml.Initializable; +import javafx.scene.control.Label; /** * Controller for the achievement view. Currently a stub used only to satisfy @@ -11,10 +13,20 @@ */ public class AchievementController implements Initializable { - /** - * Initializes the controller. No initialization logic is currently - * required. - */ + @FXML + private Label Completion; + + @FXML + private Label Unlocked; + + @FXML + private Label TitleAchievement1; + + @FXML + private Label DescriptionAchievement1; + + @FXML + @Override public void initialize(URL url, ResourceBundle rb) { // no-op diff --git a/src/main/java/com/starlight/controller/AdminController.java b/src/main/java/com/starlight/controller/AdminController.java new file mode 100644 index 0000000..e69de29 diff --git a/src/main/java/com/starlight/controller/CommunityController.java b/src/main/java/com/starlight/controller/CommunityController.java index 6195b8b..6997f46 100644 --- a/src/main/java/com/starlight/controller/CommunityController.java +++ b/src/main/java/com/starlight/controller/CommunityController.java @@ -1,29 +1,29 @@ package com.starlight.controller; -import java.io.File; import java.io.IOException; import java.net.URL; import java.util.List; import java.util.ResourceBundle; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.time.temporal.ChronoUnit; +import java.util.Collections; -import com.starlight.models.Post; -import com.starlight.models.PostDataRepository; +import com.starlight.model.Post; +import com.starlight.model.User; +import com.starlight.repository.PostDataRepository; +import com.starlight.repository.UserDataRepository; +import com.starlight.util.ImageUtils; import io.github.palexdev.materialfx.controls.MFXButton; -import javafx.animation.ScaleTransition; import javafx.fxml.FXML; import javafx.fxml.FXMLLoader; import javafx.fxml.Initializable; -import javafx.scene.Parent; -import javafx.scene.Scene; import javafx.scene.control.Label; -import javafx.scene.image.Image; import javafx.scene.image.ImageView; +import javafx.scene.input.MouseEvent; +import javafx.scene.layout.GridPane; import javafx.scene.layout.VBox; -import javafx.scene.shape.Rectangle; -import javafx.stage.Modality; -import javafx.stage.Stage; -import javafx.util.Duration; /** * Controller backing the community page. It displays recent posts and allows @@ -31,25 +31,25 @@ */ public class CommunityController implements Initializable { @FXML - private MFXButton createpost; + private MFXButton toProfile; @FXML - private VBox post; + private VBox dailypost1; @FXML - private ImageView likebutton; + private VBox dailypost2; @FXML - private MFXButton commentcounter; + private VBox dailypost3; @FXML - private ImageView likebutton1; + private VBox dailypost4; @FXML - private MFXButton sharebutton; + private GridPane manu; @FXML - private ImageView likebutton11; + private ImageView dailyphoto1; @FXML private Label dailytitle1; @@ -61,7 +61,7 @@ public class CommunityController implements Initializable { private Label dailylikecounter; @FXML - private Label title; + private ImageView dailyphoto2; @FXML private Label dailytitle2; @@ -72,6 +72,9 @@ public class CommunityController implements Initializable { @FXML private Label dailylikecounter2; + @FXML + private ImageView dailyphoto3; + @FXML private Label dailytitle3; @@ -81,6 +84,9 @@ public class CommunityController implements Initializable { @FXML private Label dailylikecounter3; + @FXML + private ImageView dailyphoto4; + @FXML private Label dailytitle4; @@ -90,157 +96,187 @@ public class CommunityController implements Initializable { @FXML private Label dailylikecounter4; - @FXML - private VBox post1; - - @FXML - private Label username; - - @FXML - private Label uploadtime; - - @FXML - private Label description; - - @FXML - private MFXButton likecounter; - @FXML private VBox postlist; - @FXML - private ImageView profile1; - @FXML - private ImageView dailyphoto1; - @FXML - private ImageView dailyphoto2; - @FXML - private ImageView dailyphoto3; - @FXML - private ImageView dailyphoto4; - @FXML - private ImageView recentphoto1; + private MainController main; private final PostDataRepository repository = new PostDataRepository(); + private final UserDataRepository userRepository = new UserDataRepository(); + + public void setMainController(MainController main) { + this.main = main; + + // If posts haven't been loaded yet because main was null, load them now + if (postlist != null && postlist.getChildren().isEmpty()) { + loadPosts(); + loadDailyPosts(); + } + } + @FXML + void toProfile(MouseEvent event) { + main.loadPage("profile"); + } + /** - * Crops the given {@link ImageView} to fit inside the specified frame size - * while preserving aspect ratio and applying rounded corners. + * Gets the profile picture path for a given username from UserData.xml */ - private void cropToFit(ImageView imageView, double frameWidth, double frameHeight, double arcRadius) { - if (imageView.getImage() == null) return; - Image image = imageView.getImage(); - double scaleX = frameWidth / image.getWidth(); - double scaleY = frameHeight / image.getHeight(); - double scale = Math.max(scaleX, scaleY); - imageView.setFitWidth(image.getWidth() * scale); - imageView.setFitHeight(image.getHeight() * scale); - Rectangle clip = new Rectangle(frameWidth, frameHeight); - clip.setArcWidth(arcRadius); - clip.setArcHeight(arcRadius); - imageView.setClip(clip); + public String getProfilePictureForUser(String username) { + if (username == null) return null; + + List users = userRepository.loadUsers(); + for (User user : users) { + if (username.equals(user.username)) { + return user.profilepicture; + } + } + return null; } /** - * Loads posts from the repository and populates the UI. The first post is - * shown in the main area while the rest are added to a list below. + * Gets the display username from UserData.xml for a given username + */ + public String getDisplayUsernameForUser(String username) { + if (username == null) return null; + + List users = userRepository.loadUsers(); + for (User user : users) { + if (username.equals(user.username)) { + // Return fullname if available, otherwise return username + return user.fullname != null && !user.fullname.trim().isEmpty() + ? user.fullname : user.username; + } + } + return username; // fallback to original username if not found + } + + /** + * Loads posts from the repository and populates the UI. All posts are + * added to the postlist container. */ private void loadPosts() { + // Safety check - don't load posts if main controller isn't available + if (main == null) { + return; + } + postlist.getChildren().clear(); - postlist.getChildren().add(post1); List posts = repository.loadPosts(); if (posts == null || posts.isEmpty()) { - username.setText(""); - uploadtime.setText(""); - title.setText(""); - description.setText(""); - recentphoto1.setImage(null); - likecounter.setText(""); return; } for (int i = 0; i < posts.size(); i++) { Post p = posts.get(i); String tits = p.title; - String pp = p.profilepicture; String usr = p.username; String desc = p.description; String image = p.image; String time = p.uploadtime; String likes = p.likecount; - - if (i == 0) { - username.setText(usr); - title.setText(tits); - description.setText(desc); - uploadtime.setText(time); - likecounter.setText(likes); - - if (pp != null) { - File fp = new File(pp); - if (fp.exists()) { - profile1.setImage(new Image(fp.toURI().toString())); - } else { - profile1.setImage(new Image(getClass().getResource("/com/starlight/images/missing.png").toExternalForm())); - } - } else { - profile1.setImage(new Image(getClass().getResource("/com/starlight/images/missing.png").toExternalForm())); + + // Get profile picture and display username from UserData.xml instead of Post data + String pp = getProfilePictureForUser(usr); + String displayUsername = getDisplayUsernameForUser(usr); + + try { + FXMLLoader loader = new FXMLLoader(getClass().getResource("/com/starlight/view/postItem.fxml")); + VBox node = loader.load(); + PostItemController c = loader.getController(); + + c.username.setText(displayUsername != null ? displayUsername : usr); + c.title.setText(tits); + c.uploadtime.setText(formatRelativeTime(time)); + c.description.setText(desc); + c.likecounter.setText(likes); + + // Set comment count (default to 0 if not set) + String commentCount = p.commentcount != null ? p.commentcount : "0"; + c.commentcounter.setText(commentCount); + + // Set the post data for the controller + c.setPost(p); + + // Set main controller reference for navigation + c.setMainController(main); + + // Initialize isLiked field if not set + if (p.isLiked == null) { + p.isLiked = "false"; } - cropToFit(profile1, 40, 40, 20); - - if (image != null) { - File f = new File(image); - if (f.exists()) { - recentphoto1.setImage(new Image(f.toURI().toString())); - } else { - recentphoto1.setImage(new Image(getClass().getResource("/com/starlight/images/missing.png").toExternalForm())); - } - } else { - recentphoto1.setImage(new Image(getClass().getResource("/com/starlight/images/missing.png").toExternalForm())); - } - cropToFit(recentphoto1, 425, 322, 20); - } else { - try { - FXMLLoader loader = new FXMLLoader(getClass().getResource("/com/starlight/view/postItem.fxml")); - VBox node = loader.load(); - PostItemController c = loader.getController(); - - c.username.setText(usr); - c.title.setText(tits); - c.uploadtime.setText(time); - c.description.setText(desc); - c.likecounter.setText(likes); - - if (pp != null) { - File fp = new File(pp); - if (fp.exists()) { - c.profile1.setImage(new Image(fp.toURI().toString())); - } else { - c.profile1.setImage(new Image(getClass().getResource("/com/starlight/images/missing.png").toExternalForm())); - } - } else { - c.profile1.setImage(new Image(getClass().getResource("/com/starlight/images/missing.png").toExternalForm())); - } - cropToFit(c.profile1, 40, 40, 20); - - if (image != null) { - File fi = new File(image); - if (fi.exists()) { - c.recentphoto1.setImage(new Image(fi.toURI().toString())); - } else { - c.recentphoto1.setImage(new Image(getClass().getResource("/com/starlight/images/missing.png").toExternalForm())); - } - } else { - c.recentphoto1.setImage(new Image(getClass().getResource("/com/starlight/images/missing.png").toExternalForm())); - } - cropToFit(c.recentphoto1, 425, 322, 20); - - postlist.getChildren().add(node); - } catch (IOException e) { - e.printStackTrace(); - } + // Load profile picture + ImageUtils.loadImage(c.profile1, pp, "/com/starlight/images/missing.png"); + ImageUtils.scaleToFit(c.profile1, 40, 40, 40); + + // Load post image + ImageUtils.loadImage(c.recentphoto1, image, "/com/starlight/images/missing.png"); + ImageUtils.scaleToFit(c.recentphoto1, 674, 485, 20); + + postlist.getChildren().add(node); + } catch (IOException e) { + e.printStackTrace(); + } + } + } + + /** + * Public method to refresh the posts and daily posts - used when posts are updated externally + */ + public void refreshPosts() { + if (postlist != null) { + loadPosts(); + } + loadDailyPosts(); + } + + /** + * Loads the top posts into the daily post slots with random selection + */ + private void loadDailyPosts() { + List posts = repository.loadPosts(); + if (posts == null || posts.isEmpty()) { + return; + } + + // Create a copy of the posts list and shuffle it randomly + List shuffledPosts = new java.util.ArrayList<>(posts); + Collections.shuffle(shuffledPosts); + + // Take up to 4 posts for daily posts from the shuffled list + for (int i = 0; i < Math.min(4, shuffledPosts.size()); i++) { + Post post = shuffledPosts.get(i); + switch (i) { + case 0: + dailytitle1.setText(post.title); + starrating.setText(post.rating); + dailylikecounter.setText(post.likecount); + ImageUtils.loadImage(dailyphoto1, post.image, "/com/starlight/images/missing.png"); + ImageUtils.scaleToFit(dailyphoto1, 280, 174, 30); + break; + case 1: + dailytitle2.setText(post.title); + starrating2.setText(post.rating); + dailylikecounter2.setText(post.likecount); + ImageUtils.loadImage(dailyphoto2, post.image, "/com/starlight/images/missing.png"); + ImageUtils.scaleToFit(dailyphoto2, 280, 174, 30); + break; + case 2: + dailytitle3.setText(post.title); + starrating3.setText(post.rating); + dailylikecounter3.setText(post.likecount); + ImageUtils.loadImage(dailyphoto3, post.image, "/com/starlight/images/missing.png"); + ImageUtils.scaleToFit(dailyphoto3, 280, 174, 30); + break; + case 3: + dailytitle4.setText(post.title); + starrating4.setText(post.rating); + dailylikecounter4.setText(post.likecount); + ImageUtils.loadImage(dailyphoto4, post.image, "/com/starlight/images/missing.png"); + ImageUtils.scaleToFit(dailyphoto4, 280, 174, 30); + break; } } } @@ -251,49 +287,59 @@ private void loadPosts() { @Override public void initialize(URL url, ResourceBundle rb) { repository.ensureDummyData(); - loadPosts(); - createpost.setOnAction(e -> showCreatePostPopup()); - } - - @FXML - private void handleCreatePost() { - showCreatePostPopup(); + + // Only load posts if main controller is available + // Otherwise, they'll be loaded when setMainController is called + if (main != null) { + loadPosts(); + loadDailyPosts(); + } } /** - * Displays a modal dialog that allows the user to create a new post. + * Formats a timestamp string into a relative time format like "2h ago", "1d ago" */ - private void showCreatePostPopup() { + public String formatRelativeTime(String timeString) { + if (timeString == null || timeString.trim().isEmpty()) { + return "unknown"; + } + try { - FXMLLoader loader = new FXMLLoader(getClass().getResource("/com/starlight/view/createPost.fxml")); - Parent popupRoot = loader.load(); - CreatePostController controller = loader.getController(); - - popupRoot.setScaleX(0.7); - popupRoot.setScaleY(0.7); - - Scene popupScene = new Scene(popupRoot); - Stage popupStage = new Stage(); - popupStage.initModality(Modality.APPLICATION_MODAL); - popupStage.setScene(popupScene); - popupStage.setTitle("Create Post"); - popupStage.setResizable(false); - - popupStage.setOnShown(e -> { - ScaleTransition st = new ScaleTransition(Duration.millis(220), popupRoot); - st.setFromX(0.7); - st.setFromY(0.7); - st.setToX(1.0); - st.setToY(1.0); - st.play(); - }); - - popupStage.showAndWait(); - if (controller.isSuccess()) { - loadPosts(); + LocalDateTime postTime; + String trimmedTime = timeString.trim(); + + // Try different formats to handle various timestamp formats + try { + // First try format with seconds: "2025-07-11 17:12:55" + DateTimeFormatter formatterWithSeconds = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); + postTime = LocalDateTime.parse(trimmedTime, formatterWithSeconds); + } catch (Exception e1) { + try { + // Then try format without seconds: "2025-07-11 17:12" + DateTimeFormatter formatterWithoutSeconds = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"); + postTime = LocalDateTime.parse(trimmedTime, formatterWithoutSeconds); + } catch (Exception e2) { + // If both fail, return original string + return timeString; + } } - } catch (IOException e) { - e.printStackTrace(); + + long hours = ChronoUnit.HOURS.between(postTime, LocalDateTime.now()); + long minutes = ChronoUnit.MINUTES.between(postTime, LocalDateTime.now()); + + if (minutes < 1) return "just now"; + if (minutes < 60) return minutes + "m ago"; + if (hours < 24) return hours + "h ago"; + + long days = hours / 24; + if (days < 30) return days + "d ago"; + + long months = days / 30; + if (months < 12) return months + "mo ago"; + + return (months / 12) + "y ago"; + } catch (Exception e) { + return timeString; } } } diff --git a/src/main/java/com/starlight/controller/ConsultController.java b/src/main/java/com/starlight/controller/ConsultController.java index e8d0da2..7788479 100644 --- a/src/main/java/com/starlight/controller/ConsultController.java +++ b/src/main/java/com/starlight/controller/ConsultController.java @@ -2,20 +2,747 @@ import java.net.URL; import java.util.ResourceBundle; +import java.util.logging.Logger; +import java.util.logging.Level; +import com.starlight.api.ChatbotAPI; +import com.starlight.api.ChatbotAPI.ChatbotException; +import com.starlight.model.User; +import com.starlight.util.ChatHistoryManager; +import com.starlight.util.ImageUtils; +import com.starlight.util.Session; + +import io.github.palexdev.materialfx.controls.MFXButton; +import javafx.application.Platform; +import javafx.beans.property.BooleanProperty; +import javafx.beans.property.SimpleBooleanProperty; +import javafx.fxml.FXML; import javafx.fxml.Initializable; +import javafx.geometry.Insets; +import javafx.geometry.Pos; +import javafx.scene.control.Label; +import javafx.scene.control.TextArea; +import javafx.scene.effect.DropShadow; +import javafx.scene.image.ImageView; +import javafx.scene.input.KeyCode; +import javafx.scene.input.KeyEvent; +import javafx.scene.layout.HBox; +import javafx.scene.layout.VBox; +import javafx.scene.paint.Color; +import javafx.scene.text.Font; +import javafx.scene.text.FontWeight; +import javafx.scene.text.Text; +import javafx.scene.text.TextAlignment; +import javafx.scene.text.TextFlow; +import javafx.animation.Timeline; +import javafx.animation.KeyFrame; +import javafx.util.Duration; /** - * Placeholder controller for the consult view. + * Controller for the consult view with chatbot functionality. */ public class ConsultController implements Initializable { + @FXML + private MFXButton quickMessage1; + + @FXML + private MFXButton quickMessage2; + + @FXML + private MFXButton quickMessage3; + + private static final Logger logger = Logger.getLogger(ConsultController.class.getName()); + + @FXML + private Label attachmentnama; + + @FXML + private MFXButton attachImage; + + @FXML + private MFXButton send; + + @FXML + private VBox bubblelist; + + @FXML + private TextArea prompt; + + private ChatbotAPI chatbotAPI; + private User currentUser; + private ChatHistoryManager historyManager; + + // Properties for FXML binding + private final BooleanProperty isProcessing = new SimpleBooleanProperty(false); + private final BooleanProperty hasTextInput = new SimpleBooleanProperty(false); + + // Loading animation variables + private Timeline loadingAnimation; + private HBox loadingBubble; + + public ConsultController() { + try { + this.chatbotAPI = new ChatbotAPI(); + this.historyManager = new ChatHistoryManager(); + loadCurrentUser(); + } catch (Exception e) { + logger.log(Level.SEVERE, "Failed to initialize chatbot: " + e.getMessage(), e); + } + } + + /** + * Loads the current user from the session. + */ + private void loadCurrentUser() { + try { + // Get the current user from the session instead of loading from repository + currentUser = Session.getCurrentUser(); + if (currentUser == null) { + logger.warning("No current user found in session"); + } + } catch (Exception e) { + logger.log(Level.WARNING, "Failed to load current user: " + e.getMessage(), e); + } + } /** - * Initializes the controller. Currently performs no initialization logic. + * Handles the user's message input. + * This method can be bound directly to FXML using onAction="#handleUserMessage" + */ + @FXML + public void handleUserMessage(javafx.event.ActionEvent event) { + handleUserMessage(); + } + + public void handleUserMessage() { + String userInput = prompt.getText().trim(); + if (userInput.isEmpty()) return; + + // Set processing state + isProcessing.set(true); + + addBubble(userInput, true); // user + prompt.clear(); + + // Make the TextArea transparent + prompt.setStyle("-fx-background-color: transparent; -fx-border-color: transparent;"); + + new Thread(() -> { + // Add loading animation + Platform.runLater(this::showLoadingBubble); + + try { + String response = chatbotAPI.askNutritionQuestion(userInput); + Platform.runLater(() -> { + hideLoadingBubble(); + addBubble(response, false); // bot + isProcessing.set(false); + }); + } catch (ChatbotException e) { + Platform.runLater(() -> { + hideLoadingBubble(); + addBubble("Sorry, I'm having trouble connecting right now. Please try again later.", false); + isProcessing.set(false); + }); + logger.log(Level.WARNING, "Chatbot error: " + e.getMessage(), e); + } catch (Exception e) { + Platform.runLater(() -> { + hideLoadingBubble(); + addBubble("An unexpected error occurred. Please try again.", false); + isProcessing.set(false); + }); + logger.log(Level.SEVERE, "Unexpected error: " + e.getMessage(), e); + } + }).start(); + } + + /** + * Adds a chat bubble to the UI and saves the message to history + * + * @param message The message to display. + * @param isUser True if the message is from the user, false if from the assistant. */ + private void addBubble(String message, boolean isUser) { + addBubble(message, isUser, true); + } + + /** + * Adds a chat bubble to the UI with option to save to history + * + * @param message The message to display. + * @param isUser True if the message is from the user, false if from the assistant. + * @param saveToHistory Whether to save this message to chat history + */ + private void addBubble(String message, boolean isUser, boolean saveToHistory) { + if (message == null || message.trim().isEmpty()) { + return; // Don't add empty messages + } + + // Save to chat history if enabled and not a welcome message + if (saveToHistory && historyManager != null && currentUser != null) { + // Skip saving welcome messages (messages that contain "Welcome Pal!") + if (!message.contains("Welcome Pal!")) { + historyManager.addMessage(message, isUser, currentUser.username); + } + } + + // Create main container for the bubble row + HBox bubble = new HBox(18); + bubble.setAlignment(isUser ? Pos.TOP_RIGHT : Pos.TOP_LEFT); + bubble.setPadding(new Insets(5, 10, 5, 10)); + + // Create and configure avatar + ImageView avatar = createAvatar(isUser); + + // Create and configure text bubble using TextFlow + TextFlow textBubble = createTextBubble(message, isUser); + + // Add components to the row in correct order + if (isUser) { + bubble.getChildren().addAll(textBubble, avatar); + } else { + bubble.getChildren().addAll(avatar, textBubble); + } + + // Add to the chat container + bubblelist.getChildren().add(bubble); + + // Auto-scroll to bottom + Platform.runLater(() -> { + if (bubblelist.getParent() instanceof javafx.scene.control.ScrollPane) { + javafx.scene.control.ScrollPane scrollPane = (javafx.scene.control.ScrollPane) bubblelist.getParent(); + scrollPane.setVvalue(1.0); + } + }); + } + + /** + * Creates and configures an avatar ImageView for the chat bubble. + * + * @param isUser True if this is a user avatar, false for bot avatar + * @return Configured ImageView for the avatar + */ + private ImageView createAvatar(boolean isUser) { + ImageView avatar = new ImageView(); + + // Set up the avatar image - mimic CommunityController approach + String avatarPath = isUser ? getUserAvatarPath() : getBotAvatarPath(); + + // Load image with fallback, using the same format as UserData.xml + ImageUtils.loadImage(avatar, avatarPath, "src/main/resources/com/starlight/images/missing.png"); + + // Apply scaling and styling + ImageUtils.scaleToFit(avatar, 32, 32, 40); + + return avatar; + } + + /** + * Creates and configures a text bubble TextFlow for the chat message. + * + * @param message The message text to display + * @param isUser True if this is a user message, false for bot message + * @return Configured TextFlow for the text bubble + */ + private TextFlow createTextBubble(String message, boolean isUser) { + TextFlow textBubble = new TextFlow(); + + // Basic configuration + textBubble.setMaxWidth(640); + textBubble.setPadding(new Insets(12, 15, 12, 15)); + textBubble.setTextAlignment(TextAlignment.LEFT); + + // Parse and add formatted text + parseAndAddFormattedText(textBubble, message, isUser); + + // Apply styling based on sender + if (isUser) { + textBubble.setStyle( + "-fx-background-color: #B8145B; " + + "-fx-background-radius: 18 5 18 18;" + ); + } else { + textBubble.setStyle( + "-fx-background-color: #FFFFFF; " + + "-fx-background-radius: 5 18 18 18;" + ); + } + + // Add drop shadow effect + DropShadow dropShadow = new DropShadow(); + dropShadow.setColor(Color.rgb(0, 0, 0, 0.1)); + dropShadow.setOffsetX(0); + dropShadow.setOffsetY(2); + dropShadow.setRadius(4); + textBubble.setEffect(dropShadow); + + return textBubble; + } + + /** + * Parses message text and adds formatted Text nodes to the TextFlow. + * Supports **bold** markdown formatting. + * + * @param textFlow The TextFlow to add Text nodes to + * @param message The message to parse + * @param isUser True if this is a user message, false for bot message + */ + private void parseAndAddFormattedText(TextFlow textFlow, String message, boolean isUser) { + // Base text color + Color textColor = isUser ? Color.WHITE : Color.rgb(63, 63, 91); + Font baseFont = Font.font("Poppins", 14); + Font boldFont = Font.font("Poppins", FontWeight.BOLD, 14); + + String[] parts = message.split("\\*\\*"); + + for (int i = 0; i < parts.length; i++) { + if (!parts[i].isEmpty()) { + Text text = new Text(parts[i]); + text.setFill(textColor); + + // Apply bold formatting to odd-indexed parts (text between **) + if (i % 2 == 1) { + text.setFont(boldFont); + } else { + text.setFont(baseFont); + } + + textFlow.getChildren().add(text); + } + } + + // If no text was added (empty message), add a space + if (textFlow.getChildren().isEmpty()) { + Text text = new Text(" "); + text.setFill(textColor); + text.setFont(baseFont); + textFlow.getChildren().add(text); + } + } + + /** + * Gets the bot avatar path. + * + * @return Path to the bot avatar image + */ + private String getBotAvatarPath() { + // Return the same format as used in UserData.xml + return "src/main/resources/com/starlight/images/botIcon.png"; + } + + /** + * Gets the avatar path for the current user. + */ + private String getUserAvatarPath() { + if (currentUser != null && currentUser.profilepicture != null && !currentUser.profilepicture.isEmpty()) { + return currentUser.profilepicture; + } + + // Return the same format as used in UserData.xml for default avatar + return "src/main/resources/com/starlight/images/dummy/profiledefault.png"; + } + + /** + * Handles image attachment functionality. + * This method can be bound directly to FXML using onAction="#handleAttachImage" + */ + @FXML + public void handleAttachImage(javafx.event.ActionEvent event) { + handleAttachImage(); + } + + public void handleAttachImage() { + // TODO: Implement image attachment functionality + attachmentnama.setText("Image attachment not yet implemented"); + } + + /** + * Handles key events on the prompt TextArea. + * This method can be bound directly to FXML using onKeyPressed="#handlePromptKeyPress" + */ + @FXML + private void handlePromptKeyPress(KeyEvent event) { + if (event.getCode() == KeyCode.ENTER && !event.isShiftDown()) { + event.consume(); + handleUserMessage(); + } + } + + /** + * Array of predefined quick questions that rotate randomly + */ + private static final String[] QUICK_QUESTIONS = { + "What is TabemonPal?", + "Why is healthy eating important?", + "What are superfoods?", + "What are the top 5 Indonesian superfoods?", + "How can I start eating healthier?", + "What nutrients should I focus on daily?", + "How do I plan balanced meals?", + "What are some healthy Indonesian dishes?" + }; + + /** + * Handles quick message button 1 click + */ + @FXML + public void handleQuickMessage1(javafx.event.ActionEvent event) { + sendQuickMessage(quickMessage1.getText()); + } + + /** + * Handles quick message button 2 click + */ + @FXML + public void handleQuickMessage2(javafx.event.ActionEvent event) { + sendQuickMessage(quickMessage2.getText()); + } + + /** + * Handles quick message button 3 click + */ + @FXML + public void handleQuickMessage3(javafx.event.ActionEvent event) { + sendQuickMessage(quickMessage3.getText()); + } + + /** + * Sends a predefined question to the AI chatbot + * @param question The question to send + */ + private void sendQuickMessage(String question) { + if (question == null || question.trim().isEmpty()) return; + + // Disable processing if already in progress + if (isProcessing.get()) { + logger.info("Already processing a message, ignoring quick message"); + return; + } + + // Set the question in the prompt area (optional, for user feedback) + prompt.setText(question); + + // Send the message using existing logic + handleUserMessage(); + + // Update quick message buttons with new random questions after sending + Platform.runLater(this::updateQuickMessageButtons); + } + + /** + * Updates quick message buttons with random questions from the predefined list + */ + private void updateQuickMessageButtons() { + java.util.Random random = new java.util.Random(); + java.util.List availableQuestions = new java.util.ArrayList<>(java.util.Arrays.asList(QUICK_QUESTIONS)); + + // Remove current button texts to avoid duplicates + availableQuestions.remove(quickMessage1.getText()); + availableQuestions.remove(quickMessage2.getText()); + availableQuestions.remove(quickMessage3.getText()); + + // If we have enough questions left, pick 3 random ones + if (availableQuestions.size() >= 3) { + quickMessage1.setText(availableQuestions.remove(random.nextInt(availableQuestions.size()))); + quickMessage2.setText(availableQuestions.remove(random.nextInt(availableQuestions.size()))); + quickMessage3.setText(availableQuestions.remove(random.nextInt(availableQuestions.size()))); + } else { + // Fallback to shuffling all questions if we don't have enough unique ones left + java.util.Collections.shuffle(availableQuestions); + if (availableQuestions.size() > 0) quickMessage1.setText(availableQuestions.get(0)); + if (availableQuestions.size() > 1) quickMessage2.setText(availableQuestions.get(1)); + if (availableQuestions.size() > 2) quickMessage3.setText(availableQuestions.get(2)); + } + } + @Override public void initialize(URL url, ResourceBundle rb) { - // no-op + // Bind hasTextInput property to prompt text property + hasTextInput.bind(prompt.textProperty().isNotEmpty()); + + // Bind send button disable property to processing state and text input + send.disableProperty().bind(isProcessing.or(hasTextInput.not())); + + // Bind attachment button disable property to processing state + attachImage.disableProperty().bind(isProcessing); + + // Set up event handlers + send.setOnAction(e -> handleUserMessage()); + attachImage.setOnAction(e -> handleAttachImage()); + prompt.setOnKeyPressed(this::handlePromptKeyPress); + + // Make the TextArea transparent + prompt.setStyle("-fx-background-color: transparent; -fx-border-color: transparent;"); + + // Add welcome message (don't save to history) + addBubble("**Welcome Pal!** 🌱\n" + + "\n" + + "**I'm here to help you with:** \n" + + "🍏 Nutrition advice (meal planning, healthy eating) \n" + + "🍎 Nutritious food choices (e.g., balanced meals, superfoods, recipes) \n" + + "💪 Healthy lifestyle habits (exercise, sleep, stress management) \n" + + "🔍 Tips & tricks (meal prep, portion control, eating out) \n" + + "🚀 Motivation & mindset (staying consistent, goal-setting) \n" + + "\n" + + "**Ask me anything!** Examples: \n" + + "• \"What's a quick healthy breakfast?\" \n" + + "• \"How can I reduce sugar cravings?\" \n" + + "\n" + + "Let's make healthy living simple and fun! 😊\n" + + "\n" + + "**What's your question today?**", + false, false); + + // Initialize quick message buttons with random questions + updateQuickMessageButtons(); + + // Start chat history session if user is available + if (currentUser != null && historyManager != null) { + historyManager.startSession(currentUser.username); + } + + // Load previous chat history if available + loadRecentChatHistory(); + } + + /** + * Shows a loading bubble with animated dots to indicate processing. + */ + private void showLoadingBubble() { + if (loadingBubble != null) { + hideLoadingBubble(); // Remove any existing loading bubble + } + + // Create main container for the loading bubble row + loadingBubble = new HBox(18); + loadingBubble.setAlignment(Pos.TOP_LEFT); + loadingBubble.setPadding(new Insets(5, 10, 5, 10)); + + // Create and configure bot avatar + ImageView avatar = createAvatar(false); + + // Create text bubble for loading animation + TextFlow textBubble = new TextFlow(); + textBubble.setMaxWidth(640); + textBubble.setPadding(new Insets(12, 15, 12, 15)); + textBubble.setTextAlignment(TextAlignment.LEFT); + + // Create the processing text + Text processingText = new Text("Processing"); + processingText.setFont(Font.font("Segoe UI", FontWeight.NORMAL, 14)); + processingText.setFill(Color.rgb(51, 51, 51)); + + Text dotsText = new Text(""); + dotsText.setFont(Font.font("Segoe UI", FontWeight.NORMAL, 14)); + dotsText.setFill(Color.rgb(51, 51, 51)); + + textBubble.getChildren().addAll(processingText, dotsText); + + // Apply bot styling + textBubble.setStyle( + "-fx-background-color: #FFFFFF; " + + "-fx-background-radius: 5 18 18 18;" + ); + + // Add drop shadow effect + DropShadow dropShadow = new DropShadow(); + dropShadow.setColor(Color.rgb(0, 0, 0, 0.1)); + dropShadow.setOffsetX(0); + dropShadow.setOffsetY(2); + dropShadow.setRadius(4); + textBubble.setEffect(dropShadow); + + // Add components to the row + loadingBubble.getChildren().addAll(avatar, textBubble); + + // Add to the chat container + bubblelist.getChildren().add(loadingBubble); + + // Auto-scroll to bottom + Platform.runLater(() -> { + if (bubblelist.getParent() instanceof javafx.scene.control.ScrollPane) { + javafx.scene.control.ScrollPane scrollPane = (javafx.scene.control.ScrollPane) bubblelist.getParent(); + scrollPane.setVvalue(1.0); + } + }); + + // Start the animation + startLoadingAnimation(dotsText); + } + + /** + * Hides the loading bubble and stops the animation. + */ + private void hideLoadingBubble() { + if (loadingAnimation != null) { + loadingAnimation.stop(); + loadingAnimation = null; + } + + if (loadingBubble != null) { + bubblelist.getChildren().remove(loadingBubble); + loadingBubble = null; + } } + /** + * Starts the loading animation with cycling dots. + */ + private void startLoadingAnimation(Text dotsText) { + if (loadingAnimation != null) { + loadingAnimation.stop(); + } + + loadingAnimation = new Timeline( + new KeyFrame(Duration.millis(500), e -> dotsText.setText("")), + new KeyFrame(Duration.millis(1000), e -> dotsText.setText(".")), + new KeyFrame(Duration.millis(1500), e -> dotsText.setText("..")), + new KeyFrame(Duration.millis(2000), e -> dotsText.setText("...")) + ); + + loadingAnimation.setCycleCount(Timeline.INDEFINITE); + loadingAnimation.play(); + } + + /** + * Loads recent chat history and displays it in the chat view. + * This method loads the most recent chat session (excluding the current one) + * to show conversation continuity. + */ + private void loadRecentChatHistory() { + if (currentUser == null || historyManager == null) { + return; + } + + try { + var histories = historyManager.loadUserHistory(currentUser.username); + + // Skip the first history if it's the current session + int startIndex = 0; + if (!histories.isEmpty() && historyManager.hasActiveSession()) { + var currentSession = historyManager.getCurrentSession(); + if (currentSession != null && + histories.get(0).sessionId.equals(currentSession.sessionId)) { + startIndex = 1; + } + } + + // Load messages from the most recent completed session (limit to avoid UI overload) + if (histories.size() > startIndex) { + var recentHistory = histories.get(startIndex); + if (recentHistory.messages != null && !recentHistory.messages.isEmpty()) { + + // Add a separator message to show this is from previous session + addBubble("─── *Previous conversation from " + + recentHistory.sessionStart.substring(0, 10) + "* ───", + false, false); + + // Load up to 10 most recent messages to avoid overwhelming the UI + var messages = recentHistory.messages; + int startMessageIndex = Math.max(0, messages.size() - 10); + + for (int i = startMessageIndex; i < messages.size(); i++) { + var msg = messages.get(i); + if (msg != null && msg.content != null && !msg.content.trim().isEmpty()) { + addBubble(msg.content, msg.isUser, false); + } + } + + // Add another separator to distinguish from new conversation + addBubble("─── *New conversation* ───", false, false); + + logger.info("Loaded " + (messages.size() - startMessageIndex) + + " messages from previous session: " + recentHistory.sessionId); + } + } + } catch (Exception e) { + logger.log(Level.WARNING, "Failed to load recent chat history: " + e.getMessage(), e); + } + } + + /** + * Cleanup method to stop any running animations and end chat session when the controller is destroyed. + * This should be called when switching views or closing the application. + */ + public void cleanup() { + hideLoadingBubble(); + + // End the current chat session + if (historyManager != null && historyManager.hasActiveSession()) { + historyManager.endSession(); + logger.info("Chat session ended during cleanup"); + } + } + + // Getter methods for property access (useful for advanced bindings) + public BooleanProperty isProcessingProperty() { + return isProcessing; + } + + public BooleanProperty hasTextInputProperty() { + return hasTextInput; + } + + /** + * Gets the chat history manager for external access. + * + * @return The chat history manager instance + */ + public ChatHistoryManager getHistoryManager() { + return historyManager; + } + + /** + * Manually ends the current chat session. + * This can be called from external controllers or when user explicitly wants to end session. + */ + public void endCurrentSession() { + if (historyManager != null && historyManager.hasActiveSession()) { + historyManager.endSession(); + logger.info("Chat session ended manually"); + } + } + + /** + * Starts a new chat session, ending the current one if active. + */ + public void startNewSession() { + if (currentUser != null && historyManager != null) { + historyManager.startSession(currentUser.username); + logger.info("New chat session started"); + } + } + + /** + * Clears the current chat UI and optionally starts a new session. + * + * @param startNewSession Whether to start a new chat session after clearing + */ + public void clearChat(boolean startNewSession) { + // Clear the chat UI + bubblelist.getChildren().clear(); + + // Add welcome message + addBubble("**Welcome Pal!** 🌱\n" + + "\n" + + "**I'm here to help you with:** \n" + + "🍏 Nutrition advice (meal planning, healthy eating) \n" + + "🍎 Nutritious food choices (e.g., balanced meals, superfoods, recipes) \n" + + "💪 Healthy lifestyle habits (exercise, sleep, stress management) \n" + + "🔍 Tips & tricks (meal prep, portion control, eating out) \n" + + "🚀 Motivation & mindset (staying consistent, goal-setting) \n" + + "\n" + + "**Ask me anything!** Examples: \n" + + "• \"What's a quick healthy breakfast?\" \n" + + "• \"How can I reduce sugar cravings?\" \n" + + "\n" + + "Let's make healthy living simple and fun! 😊\n" + + "\n" + + "**What's your question today?**", + false, false); + + if (startNewSession) { + startNewSession(); + } + } } diff --git a/src/main/java/com/starlight/controller/CreatePostController.java b/src/main/java/com/starlight/controller/CreatePostController.java index 5915d7d..255fe69 100644 --- a/src/main/java/com/starlight/controller/CreatePostController.java +++ b/src/main/java/com/starlight/controller/CreatePostController.java @@ -6,10 +6,21 @@ import java.time.format.DateTimeFormatter; import java.util.ResourceBundle; import java.util.List; +import java.util.UUID; +import java.util.logging.Logger; +import java.util.logging.Level; -import com.starlight.models.Post; -import com.starlight.models.PostDataRepository; +import com.starlight.repository.PostDataRepository; import com.starlight.util.Session; +import com.starlight.api.ChatbotAPI; +import com.starlight.model.Post; +import com.starlight.util.NutritionParser; +import com.starlight.App; +import javafx.fxml.FXMLLoader; +import javafx.scene.Parent; +import javafx.scene.Scene; +import javafx.stage.Modality; +import javafx.stage.Stage; import io.github.palexdev.materialfx.controls.MFXButton; import io.github.palexdev.materialfx.controls.MFXTextField; @@ -18,14 +29,16 @@ import javafx.scene.control.Label; import javafx.scene.control.TextArea; import javafx.stage.FileChooser; -import javafx.stage.Stage; import javafx.scene.Node; +import javafx.application.Platform; +import javafx.concurrent.Task; /** * Controller responsible for the "create post" dialog. */ public class CreatePostController implements Initializable { + private static final Logger logger = Logger.getLogger(CreatePostController.class.getName()); @FXML private MFXTextField title; @@ -56,6 +69,44 @@ public class CreatePostController implements Initializable { private boolean success; private final PostDataRepository repository = new PostDataRepository(); + private final ChatbotAPI chatbotAPI = new ChatbotAPI(); + private final NutritionParser nutritionParser = new NutritionParser(); + + /** + * Formats text by replacing newlines with vertical lines and cleaning up spacing. + * This ensures ingredients and directions are stored as vertical line-separated single lines. + * + * @param text the text to format + * @return formatted text with vertical lines instead of newlines + */ + private String formatTextAsVerticalLineSeparated(String text) { + if (text == null || text.trim().isEmpty()) { + return ""; + } + + // Replace newlines with vertical lines, trim whitespace, and remove empty entries + return text.trim() + .replaceAll("\\r?\\n", "| ") // Replace newlines with "| " + .replaceAll("\\|\\s*\\|", "|") // Remove duplicate vertical lines + .replaceAll("^\\|\\s*|\\|\\s*$", "") // Remove leading/trailing vertical lines + .replaceAll("\\s{2,}", " "); // Replace multiple spaces with single space + } + + /** + * Copies the selected image to the user's data directory. + * + * @param image the image file selected by the user + * @return the copied image file path + */ + private String copyImageToUserDir(File image) { + try { + String username = Session.getCurrentUser() != null ? Session.getCurrentUser().username : "unknown"; + return com.starlight.util.FileSystemManager.copyFileToUserDirectoryWithUniqueFilename(image, username); + } catch (Exception e) { + logger.log(Level.SEVERE, "Failed to copy image to user directory: " + e.getMessage(), e); + return null; + } + } /** * Indicates whether the user successfully created a post. @@ -92,68 +143,378 @@ public void initialize(URL url, ResourceBundle rb) { submit.setOnAction(event -> { String postTitle = title.getText(); String postDescription = description.getText(); - String postIngredients = ingredients.getText(); - String postDirections = directions.getText(); + String postIngredients = formatTextAsVerticalLineSeparated(ingredients.getText()); + String postDirections = formatTextAsVerticalLineSeparated(directions.getText()); if (postTitle.isEmpty() || postDescription.isEmpty() || postIngredients.isEmpty() || postDirections.isEmpty() || selectedImage == null) { - System.err.println("Please complete all fields and select an image."); + logger.warning("Please complete all fields and select an image."); return; } - boolean result = savePostToXML(postTitle, postDescription, postIngredients, postDirections, selectedImage.getAbsolutePath()); - success = result; - if (result) { - title.clear(); - description.clear(); - ingredients.clear(); - directions.clear(); - selectedImage = null; - System.out.println("Post submitted and saved!"); - Stage stage = (Stage)((Node)event.getSource()).getScene().getWindow(); - stage.close(); - } else { - System.err.println("Error saving post."); + // Create a new Post object + Post newPost = new Post(); + newPost.uuid = UUID.randomUUID().toString(); + newPost.username = Session.getCurrentUser().username; + newPost.profilepicture = "src/main/resources/com/starlight/images/dummy/2.png"; + newPost.title = postTitle; + newPost.description = postDescription; + newPost.ingredients = postIngredients; + newPost.directions = postDirections; + newPost.uploadtime = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")); + try { + String storedPath = copyImageToUserDir(selectedImage); + newPost.image = storedPath != null ? storedPath : selectedImage.getAbsolutePath(); + } catch (Exception e) { + e.printStackTrace(); + newPost.image = selectedImage.getAbsolutePath(); } + newPost.likecount = "0"; + newPost.commentcount = "0"; + newPost.isLiked = "false"; + newPost.rating = "0.0"; + + // Close the current dialog first + Stage currentStage = (Stage) submit.getScene().getWindow(); + currentStage.close(); + + // Show processing screen and perform AI analysis + showProcessingAndAnalyzeNutrition(newPost, postIngredients); }); cancel.setOnAction(event -> { - title.clear(); - description.clear(); - ingredients.clear(); - directions.clear(); - selectedImage = null; - System.err.println("Post creation canceled."); - - Stage stage = (Stage)((Node)event.getSource()).getScene().getWindow(); + // Close the dialog without saving + Stage stage = (Stage) cancel.getScene().getWindow(); stage.close(); }); } /** - * Persists a newly created post to XML storage. - * - * @return {@code true} if saving succeeded, otherwise {@code false} + * Shows processing dialog and performs nutrition analysis asynchronously, + * as a modal popup dialog */ - private boolean savePostToXML(String title, String description, String ingredients, String directions, String imagePath) { + private void showProcessingAndAnalyzeNutrition(Post newPost, String ingredients) { try { - List posts = repository.loadPosts(); - Post post = new Post(); - post.username = Session.getCurrentUser() != null ? Session.getCurrentUser().username : null; - post.title = title; - post.description = description; - post.ingredients = ingredients; - post.directions = directions; - post.image = imagePath; - post.rating = "0"; - post.uploadtime = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm")); - post.likecount = "0"; - posts.add(post); - repository.savePosts(posts); - return true; + // Create and show processing dialog as a modal popup + FXMLLoader loader = new FXMLLoader(getClass().getResource("/com/starlight/view/processingDialog.fxml")); + Parent root = loader.load(); + + ProcessingDialogController processingController = loader.getController(); + + Stage dialogStage = new Stage(); + dialogStage.setTitle("Creating Post"); + dialogStage.initModality(Modality.APPLICATION_MODAL); + dialogStage.setScene(new Scene(root)); + dialogStage.setResizable(false); + + // Set the stage reference in the controller + processingController.setDialogStage(dialogStage); + + // Center the dialog + dialogStage.centerOnScreen(); + + // Show the dialog (non-blocking) + dialogStage.show(); + + // Create background task for AI analysis + Task analysisTask = new Task() { + @Override + protected Void call() throws Exception { + final int MAX_RETRIES = 3; + int attempt = 0; + Exception lastException = null; + boolean isApiKeyIssue = false; + boolean isNetworkIssue = false; + + while (attempt < MAX_RETRIES) { + attempt++; + final int currentAttempt = attempt; // Final copy for lambda use + + try { + logger.info("Analyzing nutrition facts for ingredients (attempt " + currentAttempt + "/" + MAX_RETRIES + "): " + ingredients); + + // Update status on UI thread + Platform.runLater(() -> { + try { + if (currentAttempt == 1) { + processingController.updateStatus("Analyzing nutrition facts..."); + } else { + processingController.updateStatus("Analyzing nutrition facts... (retry " + currentAttempt + "/" + MAX_RETRIES + ")"); + } + } catch (Exception e) { + logger.log(Level.WARNING, "Failed to update processing status: " + e.getMessage()); + } + }); + + String nutritionResponse = chatbotAPI.analyzeNutritionFacts(ingredients); + newPost.nutrition = nutritionParser.parseNutritionFromResponse(nutritionResponse); + + // Check if we got valid nutrition data + if (newPost.nutrition != null && newPost.nutrition.ingredient != null && !newPost.nutrition.ingredient.isEmpty()) { + logger.info("Nutrition analysis completed successfully on attempt " + currentAttempt); + + // Update status to show success + Platform.runLater(() -> { + processingController.updateStatus("Nutrition analysis completed successfully!"); + }); + + return null; // Success - exit the retry loop + } else { + throw new Exception("Received empty or invalid nutrition data from AI"); + } } catch (Exception e) { + lastException = e; + String errorMessage = e.getMessage() != null ? e.getMessage().toLowerCase() : ""; + + // Detect specific error types + if (errorMessage.contains("api key") || errorMessage.contains("configure your openai api key")) { + isApiKeyIssue = true; + logger.log(Level.WARNING, "API Key issue detected: " + e.getMessage(), e); + break; // No point retrying API key issues + } else if (errorMessage.contains("network") || errorMessage.contains("connection") || + errorMessage.contains("timeout") || errorMessage.contains("unreachable") || + errorMessage.contains("failed to get response")) { + isNetworkIssue = true; + logger.log(Level.WARNING, "Network issue detected on attempt " + currentAttempt + ": " + e.getMessage(), e); + } else { + logger.log(Level.WARNING, "Nutrition analysis attempt " + currentAttempt + " failed: " + e.getMessage(), e); + } + + if (currentAttempt < MAX_RETRIES && !isApiKeyIssue) { + // Wait a bit before retrying (unless it's an API key issue) + Platform.runLater(() -> { + try { + processingController.updateStatus("Analysis failed, retrying... (" + (currentAttempt + 1) + "/" + MAX_RETRIES + ")"); + } catch (Exception ex) { + logger.log(Level.WARNING, "Failed to update retry status: " + ex.getMessage()); + } + }); + + try { + Thread.sleep(2000); // Wait 2 seconds before retry + } catch (InterruptedException ie) { + Thread.currentThread().interrupt(); + throw new Exception("Analysis interrupted", ie); + } + } + } + } + + // All retries failed or specific issue detected + final boolean finalIsApiKeyIssue = isApiKeyIssue; + final boolean finalIsNetworkIssue = isNetworkIssue; + final Exception finalException = lastException; + + Platform.runLater(() -> { + try { + if (finalIsApiKeyIssue) { + logger.log(Level.WARNING, "Skipping nutrition analysis - API key not configured"); + processingController.updateStatus("API key not configured. Using default values..."); + showPopupDialog("apikey"); + } else if (finalIsNetworkIssue) { + logger.log(Level.WARNING, "Skipping nutrition analysis - network issues detected"); + processingController.updateStatus("Network issues detected. Using default values..."); + showPopupDialog("network"); + } else { + logger.log(Level.SEVERE, "All " + MAX_RETRIES + " nutrition analysis attempts failed. Last error: " + + (finalException != null ? finalException.getMessage() : "Unknown error")); + processingController.updateStatus("Nutrition analysis failed. Using default values..."); + showPopupDialog("analysis_failed"); + } + } catch (Exception e) { + logger.log(Level.WARNING, "Failed to update error status or show warning: " + e.getMessage()); + } + }); + + // Use fallback nutrition data + newPost.nutrition = nutritionParser.parseNutritionFromResponse(null); // Creates fallback nutrition + + return null; + } + + @Override + protected void succeeded() { + Platform.runLater(() -> { + try { + // Update final status + processingController.updateStatus("Saving post..."); + + // Save the new post + List posts = repository.loadPosts(); + posts.add(0, newPost); + repository.savePosts(posts); + + success = true; + logger.info("Post created and saved successfully"); + + // Close the processing dialog + processingController.closeDialog(); + + // Show success popup + showResultDialog("post_created_success"); + + // Navigate back to main view + App.setRoot("main"); + + } catch (Exception e) { + logger.log(Level.SEVERE, "Failed to save post or navigate to main: " + e.getMessage(), e); + + // Close dialog and show failure message + try { + processingController.closeDialog(); + showResultDialog("post_creation_failed"); + App.setRoot("main"); + } catch (Exception fallbackEx) { + logger.log(Level.SEVERE, "Fallback navigation failed: " + fallbackEx.getMessage(), fallbackEx); + } + } + }); + } + + @Override + protected void failed() { + Platform.runLater(() -> { + logger.log(Level.SEVERE, "Nutrition analysis task failed completely", getException()); + + // Still try to save post without nutrition data + try { + processingController.updateStatus("Saving post with default values..."); + newPost.nutrition = nutritionParser.parseNutritionFromResponse(null); // Creates fallback nutrition + List posts = repository.loadPosts(); + posts.add(0, newPost); + repository.savePosts(posts); + success = true; + + // Close the processing dialog + processingController.closeDialog(); + + // Show success popup (post was still created) + showResultDialog("post_created_success"); + + // Navigate back to main view + App.setRoot("main"); + } catch (Exception e) { + logger.log(Level.SEVERE, "Failed to save post: " + e.getMessage(), e); + + // Close the processing dialog and show failure message + try { + processingController.closeDialog(); + showResultDialog("post_creation_failed"); + App.setRoot("main"); + } catch (Exception ex) { + logger.log(Level.SEVERE, "Failed to close dialog or navigate to main view: " + ex.getMessage(), ex); + } + } + }); + } + }; + + // Start the task in a background thread + Thread analysisThread = new Thread(analysisTask); + analysisThread.setDaemon(true); + analysisThread.start(); + } catch (Exception e) { - e.printStackTrace(); - return false; + logger.log(Level.SEVERE, "Failed to show processing screen: " + e.getMessage(), e); + + // Fallback: try to save post and navigate directly + try { + newPost.nutrition = nutritionParser.parseNutritionFromResponse(null); // Creates fallback nutrition + List posts = repository.loadPosts(); + posts.add(0, newPost); + repository.savePosts(posts); + success = true; + App.setRoot("main"); + } catch (Exception fallbackEx) { + logger.log(Level.SEVERE, "Fallback save and navigation failed: " + fallbackEx.getMessage(), fallbackEx); + } + } + } + + + /** + * Shows a popup dialog for nutrition analysis issues + */ + private void showPopupDialog(String warningType) { + try { + FXMLLoader loader = new FXMLLoader(getClass().getResource("/com/starlight/view/popupDialog.fxml")); + Parent root = loader.load(); + + PopupDialogController controller = loader.getController(); + + // Set appropriate warning message based on type + switch (warningType) { + case "apikey": + controller.setApiKeyWarning(); + break; + case "network": + controller.setNetworkWarning(); + break; + case "analysis_failed": + controller.setAnalysisFailedWarning(); + break; + default: + controller.setMessage("An error occurred during nutrition analysis. Using default values."); + break; + } + + Stage dialogStage = new Stage(); + dialogStage.setTitle("Nutrition Analysis Warning"); + dialogStage.initModality(Modality.APPLICATION_MODAL); + dialogStage.setScene(new Scene(root)); + dialogStage.setResizable(false); + + // Center the dialog + dialogStage.centerOnScreen(); + + // Show and wait for user to close + dialogStage.showAndWait(); + + } catch (Exception e) { + logger.log(Level.WARNING, "Failed to show warning dialog: " + e.getMessage(), e); + // Fallback: just log the warning + logger.warning("WARNING: " + warningType + " - Nutrition analysis skipped, using default values"); + } + } + + /** + * Shows a popup dialog with success or failure message + */ + private void showResultDialog(String resultType) { + try { + FXMLLoader loader = new FXMLLoader(getClass().getResource("/com/starlight/view/popupDialog.fxml")); + Parent root = loader.load(); + + PopupDialogController controller = loader.getController(); + + // Set appropriate message based on result type + switch (resultType) { + case "post_created_success": + controller.setPostCreatedSuccess(); + break; + case "post_creation_failed": + controller.setPostCreationFailed(); + break; + case "nutrition_analysis_success": + controller.setNutritionAnalysisSuccess(); + break; + default: + controller.setMessage("Operation completed."); + break; + } + + Stage dialogStage = new Stage(); + dialogStage.setTitle(resultType.contains("success") ? "Success" : "Error"); + dialogStage.initModality(Modality.APPLICATION_MODAL); + dialogStage.setScene(new Scene(root)); + dialogStage.setResizable(false); + + // Center the dialog + dialogStage.centerOnScreen(); + + // Show the dialog + dialogStage.showAndWait(); + + } catch (Exception e) { + logger.log(Level.SEVERE, "Failed to show result dialog: " + e.getMessage(), e); } } - } \ No newline at end of file diff --git a/src/main/java/com/starlight/controller/DeleteDialogController.java b/src/main/java/com/starlight/controller/DeleteDialogController.java new file mode 100644 index 0000000..0344cbd --- /dev/null +++ b/src/main/java/com/starlight/controller/DeleteDialogController.java @@ -0,0 +1,129 @@ +package com.starlight.controller; + +import io.github.palexdev.materialfx.controls.MFXButton; +import javafx.fxml.FXML; +import javafx.stage.Stage; +import javafx.stage.Modality; +import javafx.scene.Parent; +import javafx.scene.Scene; +import javafx.fxml.FXMLLoader; + +import com.starlight.model.Post; +import com.starlight.repository.PostDataRepository; + +import java.util.List; +import java.util.logging.Level; + +public class DeleteDialogController { + private static final java.util.logging.Logger logger = java.util.logging.Logger.getLogger(DeleteDialogController.class.getName()); + + @FXML + private MFXButton delete; + + @FXML + private MFXButton cancel; + + private Post postToDelete; + private boolean confirmed = false; + private final PostDataRepository repository = new PostDataRepository(); + + /** + * Sets the post to be deleted + */ + public void setPost(Post post) { + this.postToDelete = post; + } + + /** + * Returns whether the user confirmed the deletion + */ + public boolean isConfirmed() { + return confirmed; + } + + @FXML + private void initialize() { + // Delete button logic + delete.setOnAction(event -> { + if (postToDelete == null) { + logger.warning("No post to delete"); + return; + } + + // Remove the post from the repository + try { + List posts = repository.loadPosts(); + posts.removeIf(post -> post.uuid != null && post.uuid.equals(postToDelete.uuid)); + repository.savePosts(posts); + + confirmed = true; + logger.info("Post deleted successfully"); + + // Close the dialog first + Stage stage = (Stage) delete.getScene().getWindow(); + stage.close(); + + // Show success message + showResultDialog("post_deleted_success"); + + } catch (Exception e) { + logger.log(Level.SEVERE, "Failed to delete post: " + e.getMessage(), e); + + // Show failure message + showResultDialog("post_deletion_failed"); + + // Still close the dialog + Stage stage = (Stage) delete.getScene().getWindow(); + stage.close(); + } + }); + + // Cancel button logic + cancel.setOnAction(event -> { + confirmed = false; + // Close the dialog without deleting + Stage stage = (Stage) cancel.getScene().getWindow(); + stage.close(); + }); + } + + /** + * Shows a popup dialog with success or failure message + */ + private void showResultDialog(String resultType) { + try { + FXMLLoader loader = new FXMLLoader(getClass().getResource("/com/starlight/view/popupDialog.fxml")); + Parent root = loader.load(); + + PopupDialogController controller = loader.getController(); + + // Set appropriate message based on result type + switch (resultType) { + case "post_deleted_success": + controller.setPostDeletedSuccess(); + break; + case "post_deletion_failed": + controller.setPostDeletionFailed(); + break; + default: + controller.setMessage("Operation completed."); + break; + } + + Stage dialogStage = new Stage(); + dialogStage.setTitle(resultType.contains("success") ? "Success" : "Error"); + dialogStage.initModality(Modality.APPLICATION_MODAL); + dialogStage.setScene(new Scene(root)); + dialogStage.setResizable(false); + + // Center the dialog + dialogStage.centerOnScreen(); + + // Show the dialog + dialogStage.showAndWait(); + + } catch (Exception e) { + logger.log(Level.SEVERE, "Failed to show result dialog: " + e.getMessage(), e); + } + } +} diff --git a/src/main/java/com/starlight/controller/EditPostController.java b/src/main/java/com/starlight/controller/EditPostController.java new file mode 100644 index 0000000..830a415 --- /dev/null +++ b/src/main/java/com/starlight/controller/EditPostController.java @@ -0,0 +1,253 @@ +package com.starlight.controller; + +import java.io.File; +import java.net.URL; +import java.util.ResourceBundle; +import java.util.List; +import java.util.logging.Logger; +import java.util.logging.Level; + +import com.starlight.model.Post; +import com.starlight.repository.PostDataRepository; +import com.starlight.util.Session; + +import io.github.palexdev.materialfx.controls.MFXButton; +import io.github.palexdev.materialfx.controls.MFXTextField; +import javafx.fxml.FXML; +import javafx.fxml.Initializable; +import javafx.scene.control.Label; +import javafx.scene.control.TextArea; +import javafx.stage.FileChooser; +import javafx.stage.Stage; +import javafx.stage.Modality; +import javafx.scene.Node; +import javafx.scene.Parent; +import javafx.scene.Scene; +import javafx.fxml.FXMLLoader; + + +/** + * Controller responsible for the "edit post" dialog. + */ +public class EditPostController implements Initializable { + private static final Logger logger = Logger.getLogger(EditPostController.class.getName()); + + @FXML + private MFXTextField title; + + @FXML + private MFXButton imagepicker; + + @FXML + private Label pickerstatus; + + @FXML + private MFXTextField description; + + @FXML + private TextArea ingredients; + + @FXML + private TextArea directions; + + @FXML + private MFXButton submit; + + @FXML + private MFXButton cancel; + + private File selectedImage; + private Post currentPost; + private boolean success; + + private final PostDataRepository repository = new PostDataRepository(); + + /** + * Sets the post to edit and populates the form fields + */ + public void setPost(Post post) { + this.currentPost = post; + populateFields(); + } + + /** + * Populates the form fields with the current post data + */ + private void populateFields() { + if (currentPost == null) return; + + title.setText(currentPost.title != null ? currentPost.title : ""); + description.setText(currentPost.description != null ? currentPost.description : ""); + ingredients.setText(currentPost.ingredients != null ? currentPost.ingredients : ""); + directions.setText(currentPost.directions != null ? currentPost.directions : ""); + + // Set image status + if (currentPost.image != null && !currentPost.image.isEmpty()) { + pickerstatus.setText("Current: " + new File(currentPost.image).getName()); + } else { + pickerstatus.setText("No image selected"); + } + } + + /** + * Copies the selected image to the user's data directory. + */ + private String copyImageToUserDir(File image) { + try { + String username = Session.getCurrentUser() != null ? Session.getCurrentUser().username : "unknown"; + return com.starlight.util.FileSystemManager.copyFileToUserDirectoryWithUniqueFilename(image, username); + } catch (Exception e) { + logger.log(Level.SEVERE, "Failed to copy image to user directory: " + e.getMessage(), e); + return null; + } + } + + /** + * Indicates whether the user successfully updated the post. + */ + public boolean isSuccess() { + return success; + } + + @Override + public void initialize(URL location, ResourceBundle resources) { + pickerstatus.setText("No image selected"); + + // Image picker logic - reuse from CreatePostController + imagepicker.setOnAction(event -> { + FileChooser fileChooser = new FileChooser(); + fileChooser.setTitle("Choose an image"); + fileChooser.getExtensionFilters().add( + new FileChooser.ExtensionFilter("Image Files", "*.png", "*.jpg", "*.jpeg") + ); + Stage stage = (Stage)((Node)event.getSource()).getScene().getWindow(); + File file = fileChooser.showOpenDialog(stage); + if (file != null) { + selectedImage = file; + pickerstatus.setText("Selected: " + file.getName()); + } else { + // Reset to current image status if no new image selected + if (currentPost != null && currentPost.image != null && !currentPost.image.isEmpty()) { + pickerstatus.setText("Current: " + new File(currentPost.image).getName()); + } else { + pickerstatus.setText("No image selected"); + } + } + }); + + // Submit button logic + submit.setOnAction(event -> { + if (currentPost == null) { + logger.warning("No post to edit"); + return; + } + + String postTitle = title.getText(); + String postDescription = description.getText(); + String postIngredients = ingredients.getText(); + String postDirections = directions.getText(); + + if (postTitle.isEmpty() || postDescription.isEmpty() || postIngredients.isEmpty() || postDirections.isEmpty()) { + logger.warning("Please complete all fields."); + return; + } + + // Update the post object + currentPost.title = postTitle; + currentPost.description = postDescription; + currentPost.ingredients = postIngredients; + currentPost.directions = postDirections; + + // Handle image update if new image was selected + if (selectedImage != null) { + try { + String storedPath = copyImageToUserDir(selectedImage); + currentPost.image = storedPath != null ? storedPath : selectedImage.getAbsolutePath(); + } catch (Exception e) { + e.printStackTrace(); + currentPost.image = selectedImage.getAbsolutePath(); + } + } + + // Save the updated post + try { + List posts = repository.loadPosts(); + for (int i = 0; i < posts.size(); i++) { + Post post = posts.get(i); + if (post.uuid != null && post.uuid.equals(currentPost.uuid)) { + posts.set(i, currentPost); + break; + } + } + repository.savePosts(posts); + + success = true; + logger.info("Post updated successfully"); + + // Close the dialog first + Stage stage = (Stage) submit.getScene().getWindow(); + stage.close(); + + // Show success message + showResultDialog("post_updated_success"); + + } catch (Exception e) { + logger.log(Level.SEVERE, "Failed to update post: " + e.getMessage(), e); + + // Show failure message + showResultDialog("post_update_failed"); + + // Still close the dialog + Stage stage = (Stage) submit.getScene().getWindow(); + stage.close(); + } + }); + + // Cancel button logic + cancel.setOnAction(event -> { + // Close the dialog without saving + Stage stage = (Stage) cancel.getScene().getWindow(); + stage.close(); + }); + } + + /** + * Shows a popup dialog with success or failure message + */ + private void showResultDialog(String resultType) { + try { + FXMLLoader loader = new FXMLLoader(getClass().getResource("/com/starlight/view/popupDialog.fxml")); + Parent root = loader.load(); + + PopupDialogController controller = loader.getController(); + + // Set appropriate message based on result type + switch (resultType) { + case "post_updated_success": + controller.setPostUpdatedSuccess(); + break; + case "post_update_failed": + controller.setPostUpdateFailed(); + break; + default: + controller.setMessage("Operation completed."); + break; + } + + Stage dialogStage = new Stage(); + dialogStage.setTitle(resultType.contains("success") ? "Success" : "Error"); + dialogStage.initModality(Modality.APPLICATION_MODAL); + dialogStage.setScene(new Scene(root)); + dialogStage.setResizable(false); + + // Center the dialog + dialogStage.centerOnScreen(); + + // Show the dialog + dialogStage.showAndWait(); + + } catch (Exception e) { + logger.log(Level.SEVERE, "Failed to show result dialog: " + e.getMessage(), e); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/starlight/controller/EditProfileController.java b/src/main/java/com/starlight/controller/EditProfileController.java new file mode 100644 index 0000000..51f136c --- /dev/null +++ b/src/main/java/com/starlight/controller/EditProfileController.java @@ -0,0 +1,367 @@ +package com.starlight.controller; + +import java.io.File; +import java.io.IOException; +import java.io.OutputStream; +import java.net.HttpURLConnection; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.util.ResourceBundle; +import java.util.logging.Logger; + +import io.github.palexdev.materialfx.controls.MFXButton; +import io.github.palexdev.materialfx.controls.MFXDatePicker; +import io.github.palexdev.materialfx.controls.MFXTextField; +import javafx.fxml.FXML; +import javafx.fxml.FXMLLoader; +import javafx.fxml.Initializable; +import javafx.scene.Parent; +import javafx.scene.Scene; +import javafx.scene.control.Label; +import javafx.scene.image.ImageView; +import javafx.stage.FileChooser; +import javafx.stage.Stage; +import javafx.stage.Modality; +import java.util.logging.Level; +import com.thoughtworks.xstream.XStream; +import com.thoughtworks.xstream.io.xml.DomDriver; +import com.starlight.util.Session; +import com.starlight.model.User; +import com.starlight.util.ImageUtils; + +/** + * Controller for the user settings dialog where profile information can be + * edited. + * + * Architecture: Uses CommunityController's public utility methods for image loading + * and scaling to maintain consistency and reduce code redundancy across the application. + */ +public class EditProfileController implements Initializable { + private static final Logger logger = Logger.getLogger(EditProfileController.class.getName()); + @FXML + private ImageView Image; + + @FXML + private MFXButton imagepicker; + + @FXML + private Label welcomeLabel; + + @FXML + private MFXTextField emailField; + + @FXML + private MFXTextField passwordField; + + @FXML + private MFXDatePicker birthDayPicker; + + @FXML + private MFXButton savebutton; + + @FXML + private MFXButton deleteaccbutton; + + private User currentUser; + private Runnable onLogout; + private Stage previousStage; + + public void setUser(User user) { + this.currentUser = user; + if (welcomeLabel != null) welcomeLabel.setText("Hello, " + user.username); + if (emailField != null) emailField.setText(user.email); + if (passwordField != null) passwordField.setText(user.password); + if (birthDayPicker != null && user.birthDay != null && !user.birthDay.isEmpty()) { + birthDayPicker.setValue(java.time.LocalDate.parse(user.birthDay)); + } + + // Load and scale profile image using ImageUtils + if (Image != null) { + ImageUtils.loadImage(Image, user.profilepicture, "/com/starlight/images/missing.png"); + ImageUtils.scaleToFit(Image, 170, 170, 200); + } + } + + public void setOnLogout(Runnable onLogout) { + this.onLogout = onLogout; + } + + /** + * Sets the stage to return to when the "Back to previous menu" button is clicked. + * + * @param stage the stage to return to + */ + public void setPreviousStage(Stage stage) { + this.previousStage = stage; + } + + /** + * Loads the current user's profile image using ImageUtils + */ + private void loadCurrentUserProfileImage() { + User currentSessionUser = Session.getCurrentUser(); + if (currentSessionUser != null && Image != null) { + // Load profile image using ImageUtils + ImageUtils.loadImage(Image, currentSessionUser.profilepicture, "/com/starlight/images/missing.png"); + ImageUtils.scaleToFit(Image, 170, 170, 85); // Circular profile image with rounded corners + } + } + + /** + * Handles the image picker button click to allow user to select a new profile image + */ + @FXML + public void onImagePickerClick(javafx.event.ActionEvent event) { + FileChooser fileChooser = new FileChooser(); + fileChooser.setTitle("Select Profile Image"); + + // Set extension filters for image files + FileChooser.ExtensionFilter imageFilter = new FileChooser.ExtensionFilter( + "Image Files", "*.png", "*.jpg", "*.jpeg"); + fileChooser.getExtensionFilters().add(imageFilter); + + // Show the file chooser dialog + Stage stage = (Stage) imagepicker.getScene().getWindow(); + File selectedFile = fileChooser.showOpenDialog(stage); + + if (selectedFile != null) { + updateProfileImage(selectedFile); + } + } + + /** + * Updates the user's profile image in UserData XML and refreshes the display. + * Copies the selected file to the user's directory and updates the XML with the new path. + */ + private void updateProfileImage(File selectedFile) { + try { + User currentSessionUser = Session.getCurrentUser(); + if (currentSessionUser == null) { + logger.warning("No current session user found"); + return; + } + + // Copy the selected file to the user's directory + String copiedFilePath = com.starlight.util.FileSystemManager.copyFileToUserDirectoryWithUniqueFilename( + selectedFile, currentSessionUser.username); + + if (copiedFilePath == null) { + logger.warning("Failed to copy image file to user directory"); + return; + } + + // Update the current user's profile picture path + currentSessionUser.profilepicture = copiedFilePath; + + // Also update currentUser if it's set + if (currentUser != null) { + currentUser.profilepicture = copiedFilePath; + } + + // Update the UserData XML file + com.starlight.repository.UserDataRepository repo = new com.starlight.repository.UserDataRepository(); + java.util.List users = repo.loadUsers(); + for (com.starlight.model.User u : users) { + if (u.username.equals(currentSessionUser.username)) { + u.profilepicture = copiedFilePath; + break; + } + } + repo.saveUsers(users); + + // Refresh the profile image display + loadCurrentUserProfileImage(); + + logger.info("Profile image updated successfully: " + copiedFilePath); + } catch (Exception e) { + logger.warning("Failed to update profile image: " + e.getMessage()); + e.printStackTrace(); + } + } + + /** + * Wires up the save and cancel buttons for the dialog. + */ + @Override + public void initialize(URL url, ResourceBundle rb) { + savebutton.setOnAction(event -> { + String newEmail = emailField.getText(); + String newPass = passwordField.getText(); + String birth = birthDayPicker.getValue() != null ? birthDayPicker.getValue().toString() : null; + try { + URL endpoint = new URL("http://localhost:8000/users/" + currentUser.username); + HttpURLConnection conn = (HttpURLConnection) endpoint.openConnection(); + conn.setRequestMethod("PUT"); + conn.setRequestProperty("Content-Type", "application/xml"); + conn.setDoOutput(true); + User u = new User(); + u.email = newEmail; + u.password = newPass; + u.birthDay = birth; + XStream xs = new XStream(new DomDriver()); + xs.allowTypesByWildcard(new String[]{"com.starlight.model.*"}); + xs.alias("user", User.class); + String xml = xs.toXML(u); + try (OutputStream os = conn.getOutputStream()) { + os.write(xml.getBytes(StandardCharsets.UTF_8)); + } + if (conn.getResponseCode() == 200) { + logger.info("User updated successfully"); + showResultDialog("account_updated_success"); + } else { + logger.info("Update failed: " + conn.getResponseCode()); + showResultDialog("account_update_failed"); + } + } catch (Exception e) { + logger.log(Level.SEVERE, "Failed to update user: " + e.getMessage(), e); + showResultDialog("account_update_failed"); + } + }); + + // Add delete account button handler + deleteaccbutton.setOnAction(event -> onDelete(event)); + + User currentSessionUser = Session.getCurrentUser(); + if (currentSessionUser != null && welcomeLabel != null) { + welcomeLabel.setText("Hello, " + currentSessionUser.username); + } + + // Load the current user's profile image + loadCurrentUserProfileImage(); + } + + @FXML + private void onUpdate(javafx.event.ActionEvent event) { + String email = emailField.getText(); + String password = passwordField.getText(); + java.time.LocalDate birthDay = birthDayPicker.getValue(); + if (email.isEmpty() || password.isEmpty()) { + logger.warning("All fields are required."); + return; + } + com.starlight.repository.UserDataRepository repo = new com.starlight.repository.UserDataRepository(); + java.util.List users = repo.loadUsers(); + for (com.starlight.model.User u : users) { + if (u.username.equals(currentUser.username)) { + u.email = email; + u.password = password; + u.birthDay = birthDay != null ? birthDay.toString() : null; + break; + } + } + repo.saveUsers(users); + logger.info("User updated successfully"); + currentUser.email = email; + currentUser.password = password; + currentUser.birthDay = birthDay != null ? birthDay.toString() : null; + + // Refresh the profile image in case it was updated + loadCurrentUserProfileImage(); + } + + @FXML + public void onDelete(javafx.event.ActionEvent event) { + try { + // Remove user from UserDataRepository using the new deleteUser method + com.starlight.repository.UserDataRepository repo = new com.starlight.repository.UserDataRepository(); + boolean userDeleted = repo.deleteUser(currentUser.username); + + if (!userDeleted) { + logger.warning("User could not be deleted."); + showResultDialog("account_deletion_failed"); + return; + } + + // Clear current user session + com.starlight.util.Session.setCurrentUser(null); + + // Execute logout callback if provided + if (onLogout != null) { + onLogout.run(); + } + + // Show success message first + showResultDialog("account_deleted_success"); + + // Load the authorization view + FXMLLoader loader = new FXMLLoader(getClass().getResource("/com/starlight/view/Authorization.fxml")); + Parent authView = loader.load(); + + // Get the current stage + Stage currentStage = (Stage) deleteaccbutton.getScene().getWindow(); + + // Set the authorization view in the current stage + javafx.scene.Scene scene = new javafx.scene.Scene(authView); + currentStage.setScene(scene); + currentStage.setTitle("Login/Register"); + currentStage.show(); + + // Display a confirmation message + logger.info("User account deleted successfully"); + + } catch (IOException e) { + logger.log(Level.WARNING, "Failed to load authorization view: " + e.getMessage(), e); + showResultDialog("account_deletion_failed"); + + // Fallback to previous behavior if authorization view can't be loaded + Stage currentStage = (Stage) deleteaccbutton.getScene().getWindow(); + currentStage.hide(); + + if (previousStage != null) { + previousStage.show(); + previousStage.toFront(); + previousStage.requestFocus(); + } + } catch (Exception e) { + logger.log(Level.SEVERE, "Failed to delete account: " + e.getMessage(), e); + showResultDialog("account_deletion_failed"); + } + } + + /** + * Shows a popup dialog with success or failure message + */ + private void showResultDialog(String resultType) { + try { + FXMLLoader loader = new FXMLLoader(getClass().getResource("/com/starlight/view/popupDialog.fxml")); + Parent root = loader.load(); + + PopupDialogController controller = loader.getController(); + + // Set appropriate message based on result type + switch (resultType) { + case "account_updated_success": + controller.setAccountUpdatedSuccess(); + break; + case "account_update_failed": + controller.setAccountUpdateFailed(); + break; + case "account_deleted_success": + controller.setAccountDeletedSuccess(); + break; + case "account_deletion_failed": + controller.setAccountDeletionFailed(); + break; + default: + controller.setMessage("Operation completed."); + break; + } + + Stage dialogStage = new Stage(); + dialogStage.setTitle(resultType.contains("success") ? "Success" : "Error"); + dialogStage.initModality(Modality.APPLICATION_MODAL); + dialogStage.setScene(new Scene(root)); + dialogStage.setResizable(false); + + // Center the dialog + dialogStage.centerOnScreen(); + + // Show the dialog + dialogStage.showAndWait(); + + } catch (Exception e) { + logger.log(Level.SEVERE, "Failed to show result dialog: " + e.getMessage(), e); + } + } + +} diff --git a/src/main/java/com/starlight/controller/LoginViewController.java b/src/main/java/com/starlight/controller/LoginViewController.java index c6c3141..8e460b8 100644 --- a/src/main/java/com/starlight/controller/LoginViewController.java +++ b/src/main/java/com/starlight/controller/LoginViewController.java @@ -2,11 +2,11 @@ import java.net.URL; import java.util.ResourceBundle; - +import java.util.logging.Logger; import com.starlight.App; import com.starlight.api.ApiClient; import com.starlight.api.ApiClient.ApiException; -import com.starlight.models.User; +import com.starlight.model.User; import com.starlight.util.Session; import io.github.palexdev.materialfx.controls.MFXButton; @@ -25,6 +25,7 @@ * Controller handling user login and transition to the main application. */ public class LoginViewController implements Initializable { + private static final Logger logger = Logger.getLogger(LoginViewController.class.getName()); @FXML private VBox loginView; @@ -97,12 +98,12 @@ private void performLogin() { try { User logged = apiClient.login(em, pass); Session.setCurrentUser(logged); - System.out.println("Login success for user: " + logged.username); + logger.info("Login success for user: " + logged.username); // Ensure we redirect to main.fxml after successful login App.loadMainWithSplash(); } catch (ApiException e) { - System.out.println("Login failed: " + e.getMessage()); + logger.warning("Login failed: " + e.getMessage()); // Show user-friendly error message if (e.getStatusCode() == 401) { diff --git a/src/main/java/com/starlight/controller/MainController.java b/src/main/java/com/starlight/controller/MainController.java index 1183424..c656aa2 100644 --- a/src/main/java/com/starlight/controller/MainController.java +++ b/src/main/java/com/starlight/controller/MainController.java @@ -5,6 +5,7 @@ import java.util.ResourceBundle; import java.util.logging.Logger; +import com.starlight.model.Post; import com.starlight.util.Session; import io.github.palexdev.materialfx.controls.MFXButton; @@ -16,6 +17,9 @@ import javafx.geometry.HPos; import javafx.geometry.VPos; import javafx.scene.Parent; +import javafx.scene.control.Label; +import javafx.scene.image.Image; +import javafx.scene.image.ImageView; import javafx.scene.input.MouseEvent; import javafx.scene.layout.BorderPane; import javafx.scene.layout.GridPane; @@ -28,11 +32,52 @@ * track of the active navigation button. */ public class MainController implements Initializable { + @FXML + private ImageView recipeManagerIcon; + + @FXML + private ImageView userManagerIcon; + + @FXML + private MFXButton recipeManager; + + @FXML + private Label adminMenu; + + @FXML + private MFXButton userManager; + + @FXML + private ImageView homeIcon; + + @FXML + private ImageView communityIcon; + + @FXML + private ImageView wikiIcon; + + @FXML + private ImageView consultIcon; + + @FXML + private ImageView missionIcon; + + @FXML + private ImageView gameIcon; + + @FXML + private ImageView achievemntIcon; + + @FXML + private ImageView appLogo; + private NavbarController navbarController; private MFXButton currentActiveButton; + private Post currentPost; // Current post for navigation to post view + @FXML private BorderPane bp; @@ -111,6 +156,69 @@ void wiki(MouseEvent event) { selected(wiki); } + /** Handles navigation to the recipe manager view (only for admin users). */ + @FXML + void recipeManager(MouseEvent event) { + // This should only be called if button is visible (admin user logged in) + loadPage("recipeManager"); + selected(recipeManager); + } + + /** Handles navigation to the user manager view (only for admin users). */ + @FXML + void userManager(MouseEvent event) { + // This should only be called if button is visible (admin user logged in) + loadPage("userManager"); + selected(userManager); + } + + /** + * Checks if the current user has admin privileges + */ + private boolean isCurrentUserAdmin() { + if (Session.getCurrentUser() == null) { + return false; + } + + String username = Session.getCurrentUser().username; + return username != null && username.toLowerCase().equals("admin"); + } + + /** + * Updates the visibility of navigation elements based on user permissions + */ + public void updateNavigationForUser() { + boolean isAdmin = isCurrentUserAdmin(); + adminMenu.setVisible(isAdmin); + adminMenu.setManaged(isAdmin); // This makes the label not take up space when hidden + recipeManager.setVisible(isAdmin); + recipeManager.setManaged(isAdmin); // This makes the button not take up space when hidden + userManager.setVisible(isAdmin); + userManager.setManaged(isAdmin); // This makes the button not take up space when hidden + + // If recipe manager button was active and user is no longer admin, switch to home + if (!isAdmin && currentActiveButton == recipeManager) { + home(null); + } + // If user manager button was active and user is no longer admin, switch to home + if (!isAdmin && currentActiveButton == userManager) { + home(null); + } + } + + /** + * Public method to refresh navigation state - can be called by other controllers + * when user login state changes without a full application reload + */ + public void refreshNavigationState() { + updateNavigationForUser(); + + // Also refresh the navbar if needed + if (navbarController != null) { + // The navbar controller will handle its own user state updates + } + } + /** * Loads the given FXML page into the grid pane container. */ @@ -136,11 +244,25 @@ protected Parent call() throws Exception { // Get the controller and pass user data if applicable Object controller = loader.getController(); - if (controller instanceof ProfileController) { - ((ProfileController) controller).setUser( + if (controller instanceof EditProfileController) { + ((EditProfileController) controller).setUser( Session.getCurrentUser() ); } + if (controller instanceof CommunityController) { + ((CommunityController) controller).setMainController(MainController.this); + } + if (controller instanceof ProfileController) { + ((ProfileController) controller).setMainController(MainController.this); + } + if (controller instanceof PostController) { + PostController postController = (PostController) controller; + postController.setPost(currentPost); + postController.setMainController(MainController.this); + } + if (controller instanceof SettingController) { + ((SettingController) controller).setMainController(MainController.this); + } return root; } @@ -179,23 +301,127 @@ void selected(MFXButton button) { Integer row = GridPane.getRowIndex(button); if (row == null) row = 0; GridPane.setRowIndex(activeBar, row); + + activeBar.setTranslateX(2); + activeBar.getStyleClass().clear(); + activeBar.getStyleClass().add("black"); + - // Change button background + // Reset previous active button to inactive state if (currentActiveButton != null) { currentActiveButton.getStyleClass().remove("button-active"); - if (!currentActiveButton.getStyleClass().contains("button")) { - currentActiveButton.getStyleClass().add("button"); - } + currentActiveButton.getStyleClass().add("button-sidebar"); + setButtonIcon(currentActiveButton, false); // Set to inactive icon } + + // Set new active button state + button.getStyleClass().remove("button-sidebar"); button.getStyleClass().add("button-active"); + setButtonIcon(button, true); // Set to active icon // Track the current active button currentActiveButton = button; } + /** + * Sets the icon for a button based on its active state. + * @param button The button to update + * @param isActive Whether the button is active or not + */ + private void setButtonIcon(MFXButton button, boolean isActive) { + String buttonId = getButtonIdentifier(button); + if (buttonId == null) return; + + ImageView iconView; + String iconBaseName; + + switch (buttonId) { + case "home": + iconView = homeIcon; + iconBaseName = "Home"; + break; + case "community": + iconView = communityIcon; + iconBaseName = "Community"; + break; + case "wiki": + iconView = wikiIcon; + iconBaseName = "Wiki"; + break; + case "consult": + iconView = consultIcon; + iconBaseName = "Consult"; + break; + case "mission": + iconView = missionIcon; + iconBaseName = "Mission"; + break; + case "games": + iconView = gameIcon; + iconBaseName = "Game"; + break; + case "achievement": + iconView = achievemntIcon; + iconBaseName = "Achievements"; + break; + case "recipeManager": + iconView = recipeManagerIcon; + iconBaseName = "RecipeManager"; + break; + case "userManager": + iconView = userManagerIcon; + iconBaseName = "UserManager"; + break; + default: + return; // Unknown button, do nothing + } + + // Update the icon + try { + String iconName = isActive ? iconBaseName + ".png" : iconBaseName + "_1.png"; + String iconPath = "/com/starlight/icon/" + iconName; + Image newImage = new Image(getClass().getResourceAsStream(iconPath)); + iconView.setImage(newImage); + } catch (Exception e) { + Logger.getLogger(MainController.class.getName()).log(Level.WARNING, + "Failed to load icon for button: " + buttonId, e); + } + } + + /** + * Helper method to get a string identifier for a button + * @param button The button to identify + * @return String identifier for the button, or null if unknown + */ + private String getButtonIdentifier(MFXButton button) { + if (button == home) return "home"; + if (button == community) return "community"; + if (button == wiki) return "wiki"; + if (button == consult) return "consult"; + if (button == mission) return "mission"; + if (button == games) return "games"; + if (button == achievement) return "achievement"; + if (button == recipeManager) return "recipeManager"; + if (button == userManager) return "userManager"; + return null; + } + /** Initializes the controller by showing the home view. */ @Override public void initialize(URL url, ResourceBundle rb) { + // Initialize all buttons with inactive state and icons + initializeButtons(); + + + // Load the bot icon for the app logo + try { + Image botIcon = new Image(getClass().getResourceAsStream("/com/starlight/images/botIcon.png")); + appLogo.setImage(botIcon); + } catch (Exception e) { + Logger.getLogger(MainController.class.getName()).log(Level.WARNING, + "Failed to load botIcon.png", e); + } + // Load navbar and set up controller communication try { FXMLLoader navbarLoader = new FXMLLoader(getClass().getResource("/com/starlight/view/navbar.fxml")); @@ -213,6 +439,56 @@ public void initialize(URL url, ResourceBundle rb) { } loadPage("home"); - selected(home); + selected(home); // This will set home as active and update its icon accordingly + + // Update navigation visibility based on current user + updateNavigationForUser(); + } + + /** + * Initializes all navigation buttons with their inactive state and icons. + */ + private void initializeButtons() { + // Set all buttons to inactive style class + MFXButton[] buttons = {home, community, wiki, consult, mission, games, achievement, recipeManager, userManager}; + for (MFXButton button : buttons) { + button.getStyleClass().remove("button-active"); + button.getStyleClass().add("button-sidebar"); + } + + // Set all icons to inactive state + setButtonIcon(home, false); + setButtonIcon(community, false); + setButtonIcon(wiki, false); + setButtonIcon(consult, false); + setButtonIcon(mission, false); + setButtonIcon(games, false); + setButtonIcon(achievement, false); + setButtonIcon(recipeManager, false); + setButtonIcon(userManager, false); + + // Hide admin button initially - will be shown if admin user logs in + adminMenu.setVisible(false); + adminMenu.setManaged(false); + recipeManager.setVisible(false); + recipeManager.setManaged(false); + userManager.setVisible(false); + userManager.setManaged(false); + } + + /** + * Public method to navigate to community page with proper button selection + * Used by other controllers like PostController for navigation + */ + public void navigateToCommunity() { + loadPage("community"); + selected(community); + } + + /** + * Sets the current post for navigation to post view + */ + public void setCurrentPost(Post post) { + this.currentPost = post; } } diff --git a/src/main/java/com/starlight/controller/MissionController.java b/src/main/java/com/starlight/controller/MissionController.java index f14c04e..e6f372b 100644 --- a/src/main/java/com/starlight/controller/MissionController.java +++ b/src/main/java/com/starlight/controller/MissionController.java @@ -1,8 +1,6 @@ package com.starlight.controller; -import java.io.IOException; import javafx.fxml.FXML; -import javafx.scene.control.Button; public class MissionController { diff --git a/src/main/java/com/starlight/controller/NavbarController.java b/src/main/java/com/starlight/controller/NavbarController.java index ee80b65..c493b29 100644 --- a/src/main/java/com/starlight/controller/NavbarController.java +++ b/src/main/java/com/starlight/controller/NavbarController.java @@ -1,15 +1,20 @@ package com.starlight.controller; -import com.starlight.models.User; +import com.starlight.model.User; +import com.starlight.util.ImageUtils; import com.starlight.util.Session; import io.github.palexdev.materialfx.controls.MFXButton; import io.github.palexdev.materialfx.controls.MFXTextField; import javafx.fxml.FXML; +import javafx.scene.image.ImageView; import javafx.scene.input.MouseEvent; import javafx.scene.layout.HBox; public class NavbarController { + @FXML + private ImageView photoprofile; + @FXML private HBox navbar; @@ -36,7 +41,7 @@ public void setMainController(MainController main) { @FXML void profile(MouseEvent event) { - main.loadPage("profile"); + main.loadPage("editAccount"); } @FXML @@ -58,9 +63,15 @@ void inbox(MouseEvent event) { private void initialize() { User currentUser = Session.getCurrentUser(); if (currentUser != null) { - profile.setText(currentUser.username); + if (currentUser.profilepicture != null && !currentUser.profilepicture.isEmpty()) { + ImageUtils.loadImage(photoprofile, currentUser.profilepicture, "/com/starlight/images/default-profile.png"); + } else { + ImageUtils.loadImage(photoprofile, "/com/starlight/images/default-profile.png"); + } + ImageUtils.scaleToFit(photoprofile, 40, 40, 80); } else { - profile.setText("Guest"); + ImageUtils.loadImage(photoprofile, "/com/starlight/images/missing.png"); + ImageUtils.scaleToFit(photoprofile, 40, 40, 80); } } } \ No newline at end of file diff --git a/src/main/java/com/starlight/controller/PopupDialogController.java b/src/main/java/com/starlight/controller/PopupDialogController.java new file mode 100644 index 0000000..6dcc46b --- /dev/null +++ b/src/main/java/com/starlight/controller/PopupDialogController.java @@ -0,0 +1,161 @@ +package com.starlight.controller; + +import javafx.fxml.FXML; +import javafx.scene.control.Label; +import javafx.stage.Stage; +import io.github.palexdev.materialfx.controls.MFXButton; +import java.net.URL; +import java.util.ResourceBundle; +import javafx.fxml.Initializable; + +public class PopupDialogController implements Initializable { + @FXML + private Label message; + + @FXML + private MFXButton okButton; + + @Override + public void initialize(URL location, ResourceBundle resources) { + // Set up OK button to close the dialog + if (okButton != null) { + okButton.setOnAction(event -> { + Stage stage = (Stage) okButton.getScene().getWindow(); + stage.close(); + }); + } + } + + /** + * Sets the warning message to display + */ + public void setMessage(String warningMessage) { + if (message != null) { + message.setText(warningMessage); + } + } + + /** + * Sets a predefined warning message for API key issues + */ + public void setApiKeyWarning() { + setMessage("Nutrition analysis is not available because no OpenAI API key is configured.\n\n" + + "To enable this feature:\n" + + "1. Get an API key from OpenAI\n" + + "2. Save it in ~/.tabemonpal/Database/.SECRET_KEY.xml\n\n" + + "Your post will be created with default nutrition values."); + } + + /** + * Sets a predefined warning message for network/connectivity issues + */ + public void setNetworkWarning() { + setMessage("Nutrition analysis failed due to network connectivity issues.\n\n" + + "Please check your internet connection and try again.\n\n" + + "Your post will be created with default nutrition values."); + } + + /** + * Sets a predefined warning message for analysis failure after retries + */ + public void setAnalysisFailedWarning() { + setMessage("Nutrition analysis failed after multiple attempts.\n\n" + + "This could be due to:\n" + + "• Temporary server issues\n" + + "• Network connectivity problems\n" + + "• API service unavailability\n\n" + + "Your post will be created with default nutrition values."); + } + + // ======= SUCCESS MESSAGES ======= + + /** + * Sets a success message for post creation + */ + public void setPostCreatedSuccess() { + setMessage("Post created successfully!\n\n" + + "Your recipe has been saved and is now available in the community feed."); + } + + /** + * Sets a success message for post update + */ + public void setPostUpdatedSuccess() { + setMessage("Post updated successfully!\n\n" + + "Your changes have been saved and are now visible to the community."); + } + + /** + * Sets a success message for post deletion + */ + public void setPostDeletedSuccess() { + setMessage("Post deleted successfully!\n\n" + + "The post has been removed from the community feed."); + } + + /** + * Sets a success message for account update + */ + public void setAccountUpdatedSuccess() { + setMessage("Account updated successfully!\n\n" + + "Your profile changes have been saved."); + } + + /** + * Sets a success message for account deletion + */ + public void setAccountDeletedSuccess() { + setMessage("Account deleted successfully!\n\n" + + "Your account and all associated data have been removed."); + } + + /** + * Sets a success message for nutrition analysis completion + */ + public void setNutritionAnalysisSuccess() { + setMessage("Nutrition analysis completed successfully!\n\n" + + "AI-powered nutrition facts have been added to your post."); + } + + // ======= FAILURE MESSAGES ======= + + /** + * Sets a failure message for post creation + */ + public void setPostCreationFailed() { + setMessage("Failed to create post!\n\n" + + "There was an error saving your recipe. Please check your input and try again."); + } + + /** + * Sets a failure message for post update + */ + public void setPostUpdateFailed() { + setMessage("Failed to update post!\n\n" + + "There was an error saving your changes. Please try again."); + } + + /** + * Sets a failure message for post deletion + */ + public void setPostDeletionFailed() { + setMessage("Failed to delete post!\n\n" + + "There was an error removing the post. Please try again."); + } + + /** + * Sets a failure message for account update + */ + public void setAccountUpdateFailed() { + setMessage("Failed to update account!\n\n" + + "There was an error saving your profile changes. Please try again."); + } + + /** + * Sets a failure message for account deletion + */ + public void setAccountDeletionFailed() { + setMessage("Failed to delete account!\n\n" + + "There was an error removing your account. Please try again or contact support."); + } +} diff --git a/src/main/java/com/starlight/controller/PostController.java b/src/main/java/com/starlight/controller/PostController.java new file mode 100644 index 0000000..c832530 --- /dev/null +++ b/src/main/java/com/starlight/controller/PostController.java @@ -0,0 +1,751 @@ +package com.starlight.controller; + +import io.github.palexdev.materialfx.controls.MFXButton; +import javafx.fxml.FXML; +import javafx.scene.chart.PieChart; +import javafx.scene.control.Label; +import javafx.scene.image.ImageView; +import javafx.scene.layout.VBox; +import com.starlight.util.DoughnutChart; +import javafx.scene.text.TextFlow; +import javafx.scene.text.Text; +import javafx.scene.text.Font; +import javafx.scene.text.FontWeight; +import javafx.scene.paint.Color; +import java.net.URL; +import java.util.ResourceBundle; +import javafx.fxml.Initializable; + +import com.starlight.util.ImageUtils; +import com.starlight.repository.UserDataRepository; +import com.starlight.repository.PostDataRepository; +import com.starlight.api.ChatbotAPI; +import com.starlight.model.Nutrition; +import com.starlight.model.Post; +import com.starlight.util.NutritionParser; +import javafx.collections.FXCollections; +import javafx.collections.ObservableList; +import javafx.concurrent.Task; +import javafx.application.Platform; +import javafx.fxml.FXMLLoader; +import javafx.scene.Parent; +import javafx.scene.Scene; +import javafx.stage.Modality; +import javafx.stage.Stage; +import java.util.List; +import java.util.logging.Logger; +import java.util.logging.Level; + +public class PostController implements Initializable { + @FXML + private MFXButton doAnalysis; + + @FXML + private MFXButton goBack; + + @FXML + private MFXButton verdict; + + @FXML + private VBox recipeContainer; + + @FXML + private DoughnutChart nutritionFacts; + + @FXML + private VBox postTemplate; + + @FXML + private VBox post1; + + @FXML + private ImageView profile1; + + @FXML + private Label username; + + @FXML + private Label uploadtime; + + + @FXML + private Label description; + + @FXML + private ImageView recentphoto1; + + private Post currentPost; + private final UserDataRepository userRepository = new UserDataRepository(); + private final PostDataRepository postRepository = new PostDataRepository(); + private final CommunityController communityController = new CommunityController(); + private final ChatbotAPI chatbotAPI = new ChatbotAPI(); + private final NutritionParser nutritionParser = new NutritionParser(); + private MainController mainController; // Reference to main controller for navigation + private static final Logger logger = Logger.getLogger(PostController.class.getName()); + private static final int MAX_RETRIES = 3; + + @FXML + private void initialize() { + // Initialize with placeholder data or wait for post data to be set + } + + /** + * Handles the go back button click - navigates back to community view + */ + @FXML + private void goBack(javafx.scene.input.MouseEvent event) { + if (mainController != null) { + // Navigate back to community view with proper button selection + mainController.navigateToCommunity(); + } + } + + /** + * Handles the do analysis button click - performs nutrition analysis on current recipe + */ + @FXML + private void doAnalysis(javafx.event.ActionEvent event) { + if (currentPost == null) { + logger.warning("Cannot perform analysis - no current post available"); + return; + } + + if (currentPost.ingredients == null || currentPost.ingredients.trim().isEmpty()) { + logger.warning("Cannot perform analysis - no ingredients available"); + showAnalysisErrorDialog("No ingredients found in this recipe to analyze."); + return; + } + + // Show processing dialog and start analysis + showProcessingAndAnalyzeNutrition(); + } + + /** + * Sets the main controller reference for navigation + */ + public void setMainController(MainController mainController) { + this.mainController = mainController; + } + + @Override + public void initialize(URL location, ResourceBundle resources) { + } + + /** + * Sets the post data and updates the UI + */ + public void setPost(Post post) { + this.currentPost = post; + updateUIFromPost(); + } + + /** + * Updates all UI elements with the current post data + */ + private void updateUIFromPost() { + if (currentPost == null) return; + + if (description != null) { + description.setText(currentPost.description != null ? currentPost.description : ""); + } + + if (uploadtime != null) { + uploadtime.setText(formatRelativeTime(currentPost.uploadtime)); + } + + // Update username with display name from UserData + if (username != null) { + String displayUsername = getDisplayUsernameForUser(currentPost.username); + username.setText(displayUsername != null ? displayUsername : currentPost.username); + } + + // Update verdict button if available + if (verdict != null && currentPost.nutrition != null) { + String verdictText = currentPost.nutrition.verdict != null ? + currentPost.nutrition.verdict : "Unknown"; + verdict.setText(verdictText); + + // Apply different styling based on verdict + verdict.getStyleClass().clear(); + verdict.getStyleClass().add("food-tag"); + switch (verdictText) { + case "Healthy": + verdict.getStyleClass().add("verdict-healthy"); + break; + case "Moderate": + verdict.getStyleClass().add("verdict-moderate"); + break; + case "Unhealthy": + verdict.getStyleClass().add("verdict-unhealthy"); + break; + case "Junk Food": + verdict.getStyleClass().add("verdict-junk"); + break; + case "Unknown": + verdict.getStyleClass().add("verdict-unknown"); + break; + default: + verdict.getStyleClass().add("verdict-unknown"); + break; + } + } else if (verdict != null) { + verdict.setText("Unknown"); + verdict.getStyleClass().clear(); + verdict.getStyleClass().add("food-tag"); + verdict.getStyleClass().add("verdict-unknown"); + } + + // Update doAnalysis button state based on current nutrition status + if (doAnalysis != null) { + if (currentPost.nutrition != null && currentPost.nutrition.verdict != null && !currentPost.nutrition.verdict.equals("Unknown")) { + // Recipe has been analyzed - button can re-analyze + doAnalysis.setText("Re-analyze"); + doAnalysis.setDisable(false); + } else { + // Recipe hasn't been analyzed - button can analyze + doAnalysis.setText("Analyze"); + doAnalysis.setDisable(false); + } + + // Disable if no ingredients available + if (currentPost.ingredients == null || currentPost.ingredients.trim().isEmpty()) { + doAnalysis.setText("No Ingredients"); + doAnalysis.setDisable(true); + } + } + + // Load profile picture + if (profile1 != null) { + String profilePicture = getProfilePictureForUser(currentPost.username); + ImageUtils.loadImage(profile1, profilePicture, "/com/starlight/images/missing.png"); + ImageUtils.scaleToFit(profile1, 80, 80, 500); + } + + // Load post image + if (recentphoto1 != null) { + ImageUtils.loadImage(recentphoto1, currentPost.image, "/com/starlight/images/missing.png"); + ImageUtils.scaleToFit(recentphoto1, 1270, 990, 20); + } + + // Populate recipe container with formatted text + populateRecipeContainer(); + + // Populate nutrition facts chart + populateNutritionFacts(); + } + + /** + * Populates the recipe container with formatted ingredients and directions + */ + private void populateRecipeContainer() { + if (recipeContainer == null || currentPost == null) return; + + recipeContainer.getChildren().clear(); + TextFlow textFlow = new TextFlow(); + textFlow.getStyleClass().add("post-recipe"); + + // Add title section + if (currentPost.title != null && !currentPost.title.trim().isEmpty()) { + addSectionTitle(textFlow, currentPost.title); + addNewLine(textFlow, 2); + } + + // Add ingredients section + if (currentPost.ingredients != null && !currentPost.ingredients.trim().isEmpty()) { + addSectionTitle(textFlow, "Ingredients :"); + addNewLine(textFlow, 1); + // Parse vertical line-separated ingredients as newlines + String ingredientsWithNewlines = currentPost.ingredients.replace("|", "\n"); + addBulletList(textFlow, ingredientsWithNewlines); + addNewLine(textFlow, 1); + } + + // Add directions section + if (currentPost.directions != null && !currentPost.directions.trim().isEmpty()) { + addSectionTitle(textFlow, "Directions :"); + addNewLine(textFlow, 1); + // Parse vertical line-separated directions as newlines + String directionsWithNewlines = currentPost.directions.replace("|", "\n"); + addNumberedList(textFlow, directionsWithNewlines); + } + + recipeContainer.getChildren().add(textFlow); + } + + /** + * Adds a bold section title to the TextFlow + */ + private void addSectionTitle(TextFlow textFlow, String titleText) { + Text title = new Text("**" + titleText + "**"); + parseAndAddFormattedText(textFlow, title.getText()); + } + + /** + * Adds bullet list items to the TextFlow + */ + private void addBulletList(TextFlow textFlow, String content) { + String[] lines = content.split("\n"); + for (String line : lines) { + line = line.trim(); + if (!line.isEmpty()) { + Text bullet = new Text("• " + line); + bullet.setFont(Font.font("Poppins", 24)); + bullet.setFill(Color.rgb(63, 63, 91)); // #3F3F5B + textFlow.getChildren().add(bullet); + addNewLine(textFlow, 1); + } + } + } + + /** + * Adds numbered list items to the TextFlow + */ + private void addNumberedList(TextFlow textFlow, String content) { + String[] lines = content.split("\n"); + int counter = 1; + for (String line : lines) { + line = line.trim(); + if (!line.isEmpty()) { + Text numberedItem = new Text(counter + ". " + line); + numberedItem.setFont(Font.font("Poppins", 24)); + numberedItem.setFill(Color.rgb(63, 63, 91)); // #3F3F5B + textFlow.getChildren().add(numberedItem); + addNewLine(textFlow, 1); + counter++; + } + } + } + + /** + * Adds newline characters to the TextFlow + */ + private void addNewLine(TextFlow textFlow, int count) { + for (int i = 0; i < count; i++) { + Text newLine = new Text("\n"); + textFlow.getChildren().add(newLine); + } + } + + /** + * Parses message text and adds formatted Text nodes to the TextFlow. + * Supports **bold** markdown formatting. + * Based on ConsultController implementation + */ + private void parseAndAddFormattedText(TextFlow textFlow, String message) { + // Base text color and fonts + Color textColor = Color.rgb(63, 63, 91); // #3F3F5B + Font baseFont = Font.font("Poppins", 24); + Font boldFont = Font.font("Poppins", FontWeight.BOLD, 24); + + String[] parts = message.split("\\*\\*"); + + for (int i = 0; i < parts.length; i++) { + if (!parts[i].isEmpty()) { + Text text = new Text(parts[i]); + text.setFill(textColor); + + // Apply bold formatting to odd-indexed parts (text between **) + if (i % 2 == 1) { + text.setFont(boldFont); + } else { + text.setFont(baseFont); + } + + textFlow.getChildren().add(text); + } + } + + // If no text was added (empty message), add a space + if (textFlow.getChildren().isEmpty()) { + Text text = new Text(" "); + text.setFill(textColor); + text.setFont(baseFont); + textFlow.getChildren().add(text); + } + } + + /** + * Gets the profile picture path for a given username from UserData.xml + * Reuses logic from CommunityController + */ + private String getProfilePictureForUser(String username) { + if (username == null) return null; + + var users = userRepository.loadUsers(); + for (var user : users) { + if (username.equals(user.username)) { + return user.profilepicture; + } + } + return null; + } + + /** + * Gets the display username from UserData.xml for a given username + * Reuses logic from CommunityController + */ + private String getDisplayUsernameForUser(String username) { + if (username == null) return null; + + var users = userRepository.loadUsers(); + for (var user : users) { + if (username.equals(user.username)) { + // Return fullname if available, otherwise return username + return user.fullname != null && !user.fullname.trim().isEmpty() + ? user.fullname : user.username; + } + } + return username; // fallback to original username if not found + } + + /** + * Formats a timestamp string into a relative time format like "2h ago", "1d ago" + * Reuses logic from CommunityController + */ + private String formatRelativeTime(String timeString) { + return communityController.formatRelativeTime(timeString); + } + + /** + * Populates the nutrition facts DoughnutChart with data from the current post. + */ + private void populateNutritionFacts() { + if (nutritionFacts == null || currentPost == null) { + return; + } + + // Show placeholder data when no nutrition analysis available + if (currentPost.nutrition == null) { + ObservableList placeholderData = FXCollections.observableArrayList( + new PieChart.Data("No nutrition analysis available", 1) + ); + nutritionFacts.setData(placeholderData); + nutritionFacts.setTitle("Nutrition Facts"); + nutritionFacts.setLegendVisible(true); + nutritionFacts.getStyleClass().add("nutrition-chart"); + + // Force layout update to ensure DoughnutChart inner circle is properly displayed + refreshDoughnutChart(); + return; + } + + try { + Nutrition nutrition = currentPost.nutrition; + ObservableList pieChartData = FXCollections.observableArrayList(); + + // Get total values for all nutrients + double totalProtein = nutrition.getTotalProtein(); + double totalFat = nutrition.getTotalFat(); + double totalCarbs = nutrition.getTotalCarbohydrates(); + double totalFiber = nutrition.getTotalFiber(); + double totalSugar = nutrition.getTotalSugar(); + double totalSalt = nutrition.getTotalSalt(); + + // Create pie chart data for macronutrients and key micronutrients + if (totalProtein > 0) { + pieChartData.add(new PieChart.Data("Protein (" + String.format("%.1f", totalProtein) + "g)", totalProtein)); + } + + if (totalFat > 0) { + pieChartData.add(new PieChart.Data("Fat (" + String.format("%.1f", totalFat) + "g)", totalFat)); + } + + if (totalCarbs > 0) { + pieChartData.add(new PieChart.Data("Carbohydrates (" + String.format("%.1f", totalCarbs) + "g)", totalCarbs)); + } + + if (totalFiber > 0) { + pieChartData.add(new PieChart.Data("Fiber (" + String.format("%.1f", totalFiber) + "g)", totalFiber)); + } + + if (totalSugar > 0) { + pieChartData.add(new PieChart.Data("Sugar (" + String.format("%.1f", totalSugar) + "g)", totalSugar)); + } + + if (totalSalt > 0) { + // Convert mg to g for display consistency, or keep as mg if preferred + if (totalSalt >= 1000) { + pieChartData.add(new PieChart.Data("Salt (" + String.format("%.1f", totalSalt/1000) + "g)", totalSalt/100)); // Scale down for chart + } else { + pieChartData.add(new PieChart.Data("Salt (" + String.format("%.0f", totalSalt) + "mg)", totalSalt/100)); // Scale down for chart + } + } + + // If no macronutrient data, show calories breakdown + if (pieChartData.isEmpty()) { + double totalCalories = nutrition.getTotalCalories(); + if (totalCalories > 0) { + pieChartData.add(new PieChart.Data("Calories (" + String.format("%.0f", totalCalories) + " kcal)", totalCalories)); + } else { + // Fallback: show a placeholder + pieChartData.add(new PieChart.Data("No nutrition data available", 1)); + } + } + + nutritionFacts.setData(pieChartData); + nutritionFacts.setTitle("Nutrition Facts"); + nutritionFacts.setLegendVisible(true); + + // Apply custom styling if needed + nutritionFacts.getStyleClass().add("nutrition-chart"); + + // Force layout update to ensure DoughnutChart inner circle is properly displayed + refreshDoughnutChart(); + + } catch (Exception e) { + java.util.logging.Logger.getLogger(PostController.class.getName()) + .warning("Failed to populate nutrition facts: " + e.getMessage()); + + // Show fallback data + ObservableList fallbackData = FXCollections.observableArrayList( + new PieChart.Data("Nutrition data unavailable", 1) + ); + nutritionFacts.setData(fallbackData); + nutritionFacts.setTitle("Nutrition Facts"); + + // Force layout update to ensure DoughnutChart inner circle is properly displayed + refreshDoughnutChart(); + } + } + + /** + * Forces a layout refresh on the DoughnutChart to ensure the inner circle is properly displayed. + * This is needed after data updates to maintain the doughnut appearance. + */ + private void refreshDoughnutChart() { + if (nutritionFacts != null) { + // Use the DoughnutChart's built-in refresh method + nutritionFacts.refreshDoughnut(); + + // Additional delayed refresh to ensure proper rendering + Platform.runLater(() -> { + nutritionFacts.refreshDoughnut(); + }); + } + } + + /** + * Shows processing dialog and performs nutrition analysis asynchronously. + * Based on CreatePostController implementation. + */ + private void showProcessingAndAnalyzeNutrition() { + try { + // Load processing dialog + FXMLLoader fxmlLoader = new FXMLLoader(getClass().getResource("/com/starlight/view/processingDialog.fxml")); + Parent parent = fxmlLoader.load(); + ProcessingDialogController processingController = fxmlLoader.getController(); + + // Create modal stage + Stage dialogStage = new Stage(); + dialogStage.initModality(Modality.APPLICATION_MODAL); + dialogStage.setTitle("Analyzing Nutrition..."); + dialogStage.setScene(new Scene(parent)); + dialogStage.setResizable(false); + + // Set up the dialog controller + processingController.setDialogStage(dialogStage); + processingController.updateStatus("Starting nutrition analysis..."); + + // Show dialog + dialogStage.show(); + + // Create and configure the analysis task + Task nutritionTask = createNutritionAnalysisTask(processingController); + + // Handle task completion + nutritionTask.setOnSucceeded(e -> { + Platform.runLater(() -> { + dialogStage.close(); + + Nutrition newNutrition = nutritionTask.getValue(); + if (newNutrition != null && !newNutrition.ingredient.isEmpty()) { + // Update the current post's nutrition + currentPost.nutrition = newNutrition; + + // Save the updated post to XML + saveUpdatedPost(); + + // Update UI to reflect new nutrition data + updateUIFromPost(); + + // Show success dialog + showResultDialog(true, "Nutrition analysis completed successfully!"); + + logger.info("Nutrition analysis completed and saved successfully"); + } else { + showResultDialog(false, "Nutrition analysis failed. Please try again."); + logger.warning("Nutrition analysis returned empty or invalid results"); + } + }); + }); + + nutritionTask.setOnFailed(e -> { + Platform.runLater(() -> { + dialogStage.close(); + + Throwable exception = nutritionTask.getException(); + String errorMessage = "Nutrition analysis failed"; + if (exception != null) { + errorMessage += ": " + exception.getMessage(); + } + + showResultDialog(false, errorMessage); + logger.log(Level.SEVERE, "Nutrition analysis task failed", exception); + }); + }); + + // Start the task in a background thread + Thread taskThread = new Thread(nutritionTask); + taskThread.setDaemon(true); + taskThread.start(); + + } catch (Exception e) { + logger.log(Level.SEVERE, "Failed to show processing dialog", e); + showAnalysisErrorDialog("Failed to start nutrition analysis: " + e.getMessage()); + } + } + + /** + * Creates the nutrition analysis task. + * Based on CreatePostController implementation. + */ + private Task createNutritionAnalysisTask(ProcessingDialogController processingController) { + return new Task() { + @Override + protected Nutrition call() throws Exception { + String ingredients = currentPost.ingredients; + Exception lastException = null; + + // Try analysis up to MAX_RETRIES times + for (int attempt = 1; attempt <= MAX_RETRIES; attempt++) { + final int currentAttempt = attempt; // Make effectively final for lambda + try { + Platform.runLater(() -> + processingController.updateStatus("Analyzing nutrition... (Attempt " + currentAttempt + "/" + MAX_RETRIES + ")") + ); + + // Perform nutrition analysis using ChatGPT API + String nutritionResponse = chatbotAPI.analyzeNutritionFacts(ingredients); + + // Parse the AI response into Nutrition object + Nutrition nutrition = nutritionParser.parseNutritionFromResponse(nutritionResponse); + + if (nutrition != null && !nutrition.ingredient.isEmpty()) { + logger.info("Nutrition analysis completed successfully on attempt " + currentAttempt); + Platform.runLater(() -> + processingController.updateStatus("Analysis completed successfully!") + ); + return nutrition; + } else { + throw new Exception("AI returned empty or invalid nutrition data"); + } + + } catch (Exception e) { + lastException = e; + logger.log(Level.WARNING, "Nutrition analysis attempt " + currentAttempt + " failed: " + e.getMessage(), e); + + if (currentAttempt < MAX_RETRIES) { + Platform.runLater(() -> + processingController.updateStatus("Attempt " + currentAttempt + " failed, retrying...") + ); + Thread.sleep(1000); // Brief pause between attempts + } + } + } + + // All attempts failed + Platform.runLater(() -> + processingController.updateStatus("Analysis failed after " + MAX_RETRIES + " attempts") + ); + throw lastException != null ? lastException : new Exception("All nutrition analysis attempts failed"); + } + }; + } + + /** + * Saves the updated post with new nutrition data to XML. + */ + private void saveUpdatedPost() { + try { + // Load all posts + List allPosts = postRepository.loadPosts(); + + // Find and update the current post in the list + boolean updated = false; + for (int i = 0; i < allPosts.size(); i++) { + Post post = allPosts.get(i); + if (post.uuid != null && post.uuid.equals(currentPost.uuid)) { + // Update the post in the list + allPosts.set(i, currentPost); + updated = true; + break; + } + } + + if (!updated) { + logger.warning("Could not find post to update in XML data"); + return; + } + + // Save the updated posts list back to XML + postRepository.savePosts(allPosts); + logger.info("Post nutrition data updated and saved to XML successfully"); + + } catch (Exception e) { + logger.log(Level.SEVERE, "Failed to save updated post to XML", e); + throw new RuntimeException("Failed to save nutrition analysis results", e); + } + } + + /** + * Shows a result dialog with success or failure message. + * Based on CreatePostController pattern. + */ + private void showResultDialog(boolean success, String message) { + try { + FXMLLoader fxmlLoader = new FXMLLoader(getClass().getResource("/com/starlight/view/popupDialog.fxml")); + Parent parent = fxmlLoader.load(); + PopupDialogController controller = fxmlLoader.getController(); + + // Set appropriate message + if (success) { + controller.setNutritionAnalysisSuccess(); + } else { + controller.setMessage(message); + } + + Stage dialogStage = new Stage(); + dialogStage.initModality(Modality.APPLICATION_MODAL); + dialogStage.setTitle(success ? "Analysis Complete" : "Analysis Failed"); + dialogStage.setScene(new Scene(parent)); + dialogStage.setResizable(false); + dialogStage.showAndWait(); + + } catch (Exception e) { + logger.log(Level.SEVERE, "Failed to show result dialog", e); + } + } + + /** + * Shows an error dialog for analysis issues. + */ + private void showAnalysisErrorDialog(String errorMessage) { + try { + FXMLLoader fxmlLoader = new FXMLLoader(getClass().getResource("/com/starlight/view/popupDialog.fxml")); + Parent parent = fxmlLoader.load(); + PopupDialogController controller = fxmlLoader.getController(); + + controller.setMessage("Analysis Error\n\n" + errorMessage); + + Stage dialogStage = new Stage(); + dialogStage.initModality(Modality.APPLICATION_MODAL); + dialogStage.setTitle("Analysis Error"); + dialogStage.setScene(new Scene(parent)); + dialogStage.setResizable(false); + dialogStage.showAndWait(); + + } catch (Exception e) { + logger.log(Level.SEVERE, "Failed to show error dialog", e); + } + } +} diff --git a/src/main/java/com/starlight/controller/PostItemController.java b/src/main/java/com/starlight/controller/PostItemController.java index b117444..bd968d3 100644 --- a/src/main/java/com/starlight/controller/PostItemController.java +++ b/src/main/java/com/starlight/controller/PostItemController.java @@ -3,24 +3,32 @@ import io.github.palexdev.materialfx.controls.MFXButton; import javafx.fxml.FXML; import javafx.scene.control.Label; +import javafx.scene.image.Image; import javafx.scene.image.ImageView; import javafx.scene.layout.VBox; +import com.starlight.model.Post; +import com.starlight.repository.PostDataRepository; + +import java.util.List; + /** * Simple controller used for dynamically loaded post items. */ public class PostItemController { + private static final java.util.logging.Logger logger = java.util.logging.Logger.getLogger(PostItemController.class.getName()); + @FXML - private VBox post; + private VBox postTemplate; @FXML private VBox post1; @FXML - private ImageView likebutton; + public ImageView likebutton; @FXML - private MFXButton commentcounter; + public MFXButton commentcounter; @FXML private ImageView likebutton1; @@ -46,8 +54,228 @@ public class PostItemController { @FXML public MFXButton likecounter; + private Post currentPost; + private PostDataRepository repository = new PostDataRepository(); + private MainController mainController; // Reference to main controller for navigation + @FXML private void initialize() { - // Hint: initialize() will be called when the associated FXML has been completely loaded. + // FXML initialize method - minimal setup only + // Click handling will be set up when main controller is set + } + + /** + * FXML binding method for like button click + */ + @FXML + public void handleLikeClick(javafx.event.ActionEvent event) { + if (currentPost == null) return; + + try { + int currentLikes = Integer.parseInt(currentPost.likecount != null ? currentPost.likecount : "0"); + boolean wasLiked = "true".equals(currentPost.isLiked); + + if (wasLiked) { + // Unlike the post + currentLikes = Math.max(0, currentLikes - 1); + currentPost.likecount = String.valueOf(currentLikes); + currentPost.isLiked = "false"; + + // Change image back to normal like icon + if (likebutton != null) { + likebutton.setImage(new Image(getClass().getResourceAsStream("/com/starlight/icon/like.png"))); + } + } else { + // Like the post + currentLikes++; + currentPost.likecount = String.valueOf(currentLikes); + currentPost.isLiked = "true"; + + // Change image to liked icon + if (likebutton != null) { + likebutton.setImage(new Image(getClass().getResourceAsStream("/com/starlight/icon/like_1.png"))); + } + } + + // Update the button text + likecounter.setText(String.valueOf(currentLikes)); + + // Save the updated post data + savePostData(); + + } catch (NumberFormatException e) { + // Handle invalid like count + logger.warning("Invalid like count for post: " + (currentPost != null ? currentPost.uuid : "unknown")); + e.printStackTrace(); + currentPost.likecount = "0"; + currentPost.isLiked = "false"; + likecounter.setText("0"); + } + } + + /** + * FXML binding method for comment button click + */ + @FXML + public void handleCommentClick(javafx.event.ActionEvent event) { + if (currentPost == null) return; + + try { + int currentComments = Integer.parseInt(currentPost.commentcount != null ? currentPost.commentcount : "0"); + currentComments++; + currentPost.commentcount = String.valueOf(currentComments); + + // Update the button text + commentcounter.setText(String.valueOf(currentComments)); + + // Save the updated post data + savePostData(); + + } catch (NumberFormatException e) { + // Handle invalid comment count + currentPost.commentcount = "0"; + commentcounter.setText("0"); + } + } + + /** + * Sets the current post data for this controller instance + */ + public void setPost(Post post) { + this.currentPost = post; + updateUIFromPost(); + } + + /** + * Updates the UI elements with the current post data + */ + private void updateUIFromPost() { + if (currentPost == null) return; + + // Update like counter + if (likecounter != null) { + likecounter.setText(currentPost.likecount != null ? currentPost.likecount : "0"); + } + + // Update comment counter + if (commentcounter != null) { + commentcounter.setText(currentPost.commentcount != null ? currentPost.commentcount : "0"); + } + + // Update like button image based on liked state + if (likebutton != null) { + boolean isLiked = "true".equals(currentPost.isLiked); + if (isLiked) { + likebutton.setImage(new Image(getClass().getResourceAsStream("/com/starlight/icon/like_1.png"))); + } else { + likebutton.setImage(new Image(getClass().getResourceAsStream("/com/starlight/icon/like.png"))); + } + } + } + + /** + * Saves the updated post data to the repository + */ + private void savePostData() { + if (currentPost == null) return; + + try { + List posts = repository.loadPosts(); + + // Find and update the current post in the list + for (int i = 0; i < posts.size(); i++) { + Post post = posts.get(i); + if (post.uuid != null && post.uuid.equals(currentPost.uuid)) { + posts.set(i, currentPost); + break; + } + } + + // Save the updated posts + repository.savePosts(posts); + + } catch (Exception e) { + e.printStackTrace(); + } + } + + /** + * Sets the main controller reference for navigation + */ + public void setMainController(MainController mainController) { + this.mainController = mainController; + setupPostClickHandling(); + } + + /** + * Sets up click handling for the post area (excluding buttons) + */ + private void setupPostClickHandling() { + // Make the post clickable (but not the buttons) + if (postTemplate != null) { + postTemplate.setOnMouseClicked(event -> { + // Check if click target is a button or interactive element + javafx.scene.Node target = (javafx.scene.Node) event.getTarget(); + + // Prevent clicks on buttons from triggering navigation + if (isClickableButton(target)) { + return; + } + + handlePostClick(); + }); + + // Set cursor to indicate clickable area + postTemplate.setStyle("-fx-cursor: hand;"); + } + + // Also make the image and text content explicitly clickable + if (recentphoto1 != null) { + recentphoto1.setOnMouseClicked(event -> handlePostClick()); + recentphoto1.setStyle("-fx-cursor: hand;"); + } + + if (title != null) { + title.setOnMouseClicked(event -> handlePostClick()); + title.setStyle("-fx-cursor: hand;"); + } + + if (description != null) { + description.setOnMouseClicked(event -> handlePostClick()); + description.setStyle("-fx-cursor: hand;"); + } + } + + /** + * Checks if the clicked element is a button or interactive element + */ + private boolean isClickableButton(javafx.scene.Node target) { + // Check if the target itself or any of its parents is a button + javafx.scene.Node current = target; + while (current != null) { + if (current == likecounter || current == commentcounter || current == sharebutton || + current == likebutton || current == likebutton1 || current == likebutton11) { + return true; + } + current = current.getParent(); + } + return false; + } + + /** + * Handles clicks on the post content to navigate to full post view + */ + private void handlePostClick() { + if (currentPost != null && mainController != null) { + mainController.setCurrentPost(currentPost); + mainController.loadPage("Post"); + } else { + if (currentPost == null) { + logger.warning("Cannot navigate: currentPost is null"); + } + if (mainController == null) { + logger.warning("Cannot navigate: mainController is null"); + } + } } } diff --git a/src/main/java/com/starlight/controller/ProcessingController.java b/src/main/java/com/starlight/controller/ProcessingController.java new file mode 100644 index 0000000..e69de29 diff --git a/src/main/java/com/starlight/controller/ProcessingDialogController.java b/src/main/java/com/starlight/controller/ProcessingDialogController.java new file mode 100644 index 0000000..1aa05bc --- /dev/null +++ b/src/main/java/com/starlight/controller/ProcessingDialogController.java @@ -0,0 +1,48 @@ +package com.starlight.controller; + +import javafx.fxml.FXML; +import javafx.scene.control.Label; +import javafx.stage.Stage; +import java.net.URL; +import java.util.ResourceBundle; +import javafx.fxml.Initializable; + +public class ProcessingDialogController implements Initializable { + @FXML + private Label status; + + private Stage dialogStage; + + @Override + public void initialize(URL location, ResourceBundle resources) { + // Set initial processing message + if (status != null) { + status.setText("Creating your post..."); + } + } + + /** + * Sets the dialog stage so it can be closed programmatically + */ + public void setDialogStage(Stage dialogStage) { + this.dialogStage = dialogStage; + } + + /** + * Updates the status text displayed to the user + */ + public void updateStatus(String newStatus) { + if (status != null) { + status.setText(newStatus); + } + } + + /** + * Closes the processing dialog + */ + public void closeDialog() { + if (dialogStage != null) { + dialogStage.close(); + } + } +} diff --git a/src/main/java/com/starlight/controller/ProfileController.java b/src/main/java/com/starlight/controller/ProfileController.java index 80f404f..8837c0e 100644 --- a/src/main/java/com/starlight/controller/ProfileController.java +++ b/src/main/java/com/starlight/controller/ProfileController.java @@ -1,204 +1,301 @@ package com.starlight.controller; import java.io.IOException; -import java.io.OutputStream; -import java.net.HttpURLConnection; import java.net.URL; -import java.nio.charset.StandardCharsets; +import java.util.List; import java.util.ResourceBundle; +import java.util.logging.Logger; + +import com.starlight.model.Post; +import com.starlight.model.User; +import com.starlight.repository.PostDataRepository; +import com.starlight.util.Session; +import com.starlight.util.ImageUtils; import io.github.palexdev.materialfx.controls.MFXButton; -import io.github.palexdev.materialfx.controls.MFXDatePicker; -import io.github.palexdev.materialfx.controls.MFXTextField; +import io.github.palexdev.materialfx.controls.MFXScrollPane; +import javafx.animation.ScaleTransition; import javafx.fxml.FXML; import javafx.fxml.FXMLLoader; import javafx.fxml.Initializable; import javafx.scene.Parent; +import javafx.scene.Scene; import javafx.scene.control.Label; import javafx.scene.image.ImageView; +import javafx.scene.input.MouseEvent; +import javafx.scene.layout.GridPane; +import javafx.scene.layout.HBox; +import javafx.scene.layout.VBox; +import javafx.stage.Modality; import javafx.stage.Stage; -import com.thoughtworks.xstream.XStream; -import com.thoughtworks.xstream.io.xml.DomDriver; -import com.starlight.models.User; -import com.starlight.util.Session; +import javafx.util.Duration; /** - * Controller for the user settings dialog where profile information can be - * edited. + * Controller backing the profile page. It displays user profile information and user's recipes/posts. + * + * Architecture: This controller uses dependency injection pattern by utilizing CommunityController's + * public utility methods to reduce code redundancy and maintain consistency across the application. + * Instead of duplicating image loading, scaling, and user data retrieval logic, we delegate these + * operations to the CommunityController which serves as a shared utility provider. */ public class ProfileController implements Initializable { + private static final Logger logger = Logger.getLogger(ProfileController.class.getName()); + @FXML - private ImageView profileimage; + private MFXButton editBio; @FXML - private MFXButton imagepicker; + private MFXButton addRecipe; @FXML - private Label welcomeLabel; + private VBox badge; @FXML - private MFXTextField emailField; - - @FXML - private MFXTextField passwordField; - - @FXML - private MFXDatePicker birthDayPicker; + private ImageView badgeICon; @FXML - private MFXButton savebutton; + private Label badgeTitle; @FXML - private MFXButton deleteaccbutton; - - private User currentUser; - private Runnable onLogout; - private Stage previousStage; - - public void setUser(User user) { - this.currentUser = user; - if (welcomeLabel != null) welcomeLabel.setText("Hello, " + user.username); - if (emailField != null) emailField.setText(user.email); - if (passwordField != null) passwordField.setText(user.password); - if (birthDayPicker != null && user.birthDay != null && !user.birthDay.isEmpty()) { - birthDayPicker.setValue(java.time.LocalDate.parse(user.birthDay)); - } - } + private MFXButton swapToCommunity; + + @FXML + private MFXScrollPane myrepiceList; + + @FXML + private ImageView profile; + + @FXML + private Label username; + + @FXML + private Label bio; + + @FXML + private Label recipes; + + @FXML + private Label followers; + + @FXML + private Label following; + + private MainController main; + + private final PostDataRepository repository = new PostDataRepository(); + + // Use CommunityController for getDisplayUsernameForUser method and ImageUtils for image operations + private final CommunityController communityController = new CommunityController(); - public void setOnLogout(Runnable onLogout) { - this.onLogout = onLogout; + public void setMainController(MainController main) { + this.main = main; + // Also set the main controller for the community controller if needed + // No need to set main controller for utility class + + // Update any existing RecipeItemControllers with the MainController reference + updateRecipeItemControllersMainController(); } /** - * Sets the stage to return to when the "Back to previous menu" button is clicked. - * - * @param stage the stage to return to + * Updates all existing RecipeItemControllers with MainController reference + * This handles the timing issue where recipes are loaded before MainController is set */ - public void setPreviousStage(Stage stage) { - this.previousStage = stage; + private void updateRecipeItemControllersMainController() { + if (main == null || myrepiceList == null) { + return; + } + + // Since we can't easily access individual controllers after they're created, + // the best approach is to reload recipes if they were loaded before MainController was set + javafx.scene.Node content = myrepiceList.getContent(); + if (content != null && content instanceof javafx.scene.Parent) { + javafx.scene.Parent parentContent = (javafx.scene.Parent) content; + if (parentContent.getChildrenUnmodifiable().size() > 0) { + logger.info("Reloading recipes with MainController reference"); + loadUserRecipes(); + } + } + } + + @FXML + void swapToCommunity(MouseEvent event) { + main.loadPage("community"); } /** - * Wires up the save and cancel buttons for the dialog. + * Loads the current user's profile information using CommunityController's methods */ - @Override - public void initialize(URL url, ResourceBundle rb) { - savebutton.setOnAction(event -> { - String newEmail = emailField.getText(); - String newPass = passwordField.getText(); - String birth = birthDayPicker.getValue() != null ? birthDayPicker.getValue().toString() : null; - try { - URL endpoint = new URL("http://localhost:8000/users/" + currentUser.username); - HttpURLConnection conn = (HttpURLConnection) endpoint.openConnection(); - conn.setRequestMethod("PUT"); - conn.setRequestProperty("Content-Type", "application/xml"); - conn.setDoOutput(true); - User u = new User(); - u.email = newEmail; - u.password = newPass; - u.birthDay = birth; - XStream xs = new XStream(new DomDriver()); - xs.allowTypesByWildcard(new String[]{"com.starlight.models.*"}); - xs.alias("user", User.class); - String xml = xs.toXML(u); - try (OutputStream os = conn.getOutputStream()) { - os.write(xml.getBytes(StandardCharsets.UTF_8)); - } - if (conn.getResponseCode() == 200) { - System.out.println("User updated"); - } else { - System.out.println("Update failed: " + conn.getResponseCode()); - } - } catch (Exception e) { - e.printStackTrace(); + private void loadUserProfile() { + User currentUser = Session.getCurrentUser(); + if (currentUser != null) { + // Set username using display name logic from CommunityController + if (username != null) { + String displayName = communityController.getDisplayUsernameForUser(currentUser.username); + username.setText(displayName != null ? displayName : currentUser.username); + } + + // Load profile picture using ImageUtils + if (profile != null) { + ImageUtils.loadImage(profile, currentUser.profilepicture, "/com/starlight/images/missing.png"); + ImageUtils.scaleToFit(profile, 170, 170, 200); // Adjust size as needed + } + + // Set followers and following counts (placeholder values since not in User model) + if (followers != null) { + followers.setText("125"); // Placeholder - could be calculated from user relationships + } + + if (following != null) { + following.setText("89"); // Placeholder - could be calculated from user relationships + } + + // Set bio if available (though not in current User model, prepare for future) + if (bio != null) { + bio.setText("Food enthusiast and recipe creator"); // Placeholder bio } - }); - - // Add delete account button handler - deleteaccbutton.setOnAction(event -> onDelete(event)); - - User currentSessionUser = Session.getCurrentUser(); - if (currentSessionUser != null && welcomeLabel != null) { - welcomeLabel.setText("Hello, " + currentSessionUser.username); } } - - @FXML - private void onUpdate(javafx.event.ActionEvent event) { - String email = emailField.getText(); - String password = passwordField.getText(); - java.time.LocalDate birthDay = birthDayPicker.getValue(); - if (email.isEmpty() || password.isEmpty()) { - System.err.println("All fields are required."); + + /** + * Loads and displays the current user's recipes/posts using CommunityController's approach + */ + private void loadUserRecipes() { + if (myrepiceList == null) return; + + User currentUser = Session.getCurrentUser(); + if (currentUser == null) return; + + List allPosts = repository.loadPosts(); + if (allPosts == null || allPosts.isEmpty()) { + // Clear the container if no posts + myrepiceList.setContent(new HBox()); + if (recipes != null) { + recipes.setText("0"); + } return; } - com.starlight.models.UserDataRepository repo = new com.starlight.models.UserDataRepository(); - java.util.List users = repo.loadUsers(); - for (com.starlight.models.User u : users) { - if (u.username.equals(currentUser.username)) { - u.email = email; - u.password = password; - u.birthDay = birthDay != null ? birthDay.toString() : null; - break; + + // Filter posts by current user + List userPosts = allPosts.stream() + .filter(post -> currentUser.username.equals(post.username)) + .toList(); + + if (userPosts.isEmpty()) { + // Clear the container if no posts + myrepiceList.setContent(new HBox()); + if (recipes != null) { + recipes.setText("0"); } + return; } - repo.saveUsers(users); - System.out.println("User updated successfully"); - currentUser.email = email; - currentUser.password = password; - currentUser.birthDay = birthDay != null ? birthDay.toString() : null; - } - - @FXML - private void onDelete(javafx.event.ActionEvent event) { - // Remove user from UserDataRepository using the new deleteUser method - com.starlight.models.UserDataRepository repo = new com.starlight.models.UserDataRepository(); - boolean userDeleted = repo.deleteUser(currentUser.username); - if (!userDeleted) { - System.err.println("User could not be deleted."); - return; + // Create horizontal layout for recipes + HBox recipeContainer = new HBox(); + recipeContainer.setSpacing(20); + + for (Post post : userPosts) { + String title = post.title; + String image = post.image; + String likes = post.likecount; + String rating = post.rating; + + try { + FXMLLoader loader = new FXMLLoader(getClass().getResource("/com/starlight/view/recipeItem.fxml")); + GridPane recipeNode = loader.load(); + RecipeItemController controller = loader.getController(); + + // Set the recipe data using the setter method + controller.setRecipeData(title, rating != null ? rating : "0.0", likes != null ? likes : "0"); + + // Set the post data for edit/delete functionality + controller.setPost(post); + + // Set the main controller reference for navigation (check if available) + if (main != null) { + controller.setMainController(main); + } else { + // Log warning if main controller not available yet + logger.warning("MainController not available for RecipeItemController - navigation will not work"); + } + + // Set up callback to refresh the recipes when post is updated/deleted + controller.setOnPostUpdated(() -> loadUserRecipes()); + + // Load recipe image using ImageUtils + ImageView recipeImageView = controller.getImageView(); + if (recipeImageView != null) { + ImageUtils.loadImage(recipeImageView, image, "/com/starlight/images/missing.png"); + ImageUtils.scaleToFit(recipeImageView, 280, 174, 20); // Adjust size for recipe item + } + + recipeContainer.getChildren().add(recipeNode); + } catch (IOException e) { + e.printStackTrace(); + } } - // Clear current user session - com.starlight.util.Session.setCurrentUser(null); + myrepiceList.setContent(recipeContainer); - // Execute logout callback if provided - if (onLogout != null) { - onLogout.run(); + // Update recipe count + if (recipes != null) { + recipes.setText(String.valueOf(userPosts.size())); } + } + + /** + * Initializes the controller by ensuring dummy data and loading profile and recipes. + */ + @Override + public void initialize(URL url, ResourceBundle rb) { + repository.ensureDummyData(); + loadUserProfile(); + loadUserRecipes(); + // Connect addRecipe button to handleCreatePost method + if (addRecipe != null) { + addRecipe.setOnAction(e -> handleCreatePost()); + } + } + + @FXML + private void handleCreatePost() { + showCreatePostPopup(); + } + + /** + * Displays a modal dialog that allows the user to create a new post. + */ + private void showCreatePostPopup() { try { - // Load the authorization view - FXMLLoader loader = new FXMLLoader(getClass().getResource("/com/starlight/view/Authorization.fxml")); - Parent authView = loader.load(); - - // Get the current stage - Stage currentStage = (Stage) deleteaccbutton.getScene().getWindow(); - - // Set the authorization view in the current stage - javafx.scene.Scene scene = new javafx.scene.Scene(authView); - currentStage.setScene(scene); - currentStage.setTitle("Login/Register"); - currentStage.show(); - - // Display a confirmation message - System.out.println("User account deleted successfully"); - + FXMLLoader loader = new FXMLLoader(getClass().getResource("/com/starlight/view/createPost.fxml")); + Parent popupRoot = loader.load(); + CreatePostController controller = loader.getController(); + + popupRoot.setScaleX(0.7); + popupRoot.setScaleY(0.7); + + Scene popupScene = new Scene(popupRoot); + Stage popupStage = new Stage(); + popupStage.initModality(Modality.APPLICATION_MODAL); + popupStage.setScene(popupScene); + popupStage.setTitle("Create Post"); + popupStage.setResizable(false); + + popupStage.setOnShown(e -> { + ScaleTransition st = new ScaleTransition(Duration.millis(220), popupRoot); + st.setFromX(0.7); + st.setFromY(0.7); + st.setToX(1.0); + st.setToY(1.0); + st.play(); + }); + + popupStage.showAndWait(); + if (controller.isSuccess()) { + loadUserRecipes(); // Reload user recipes after creating new post + } } catch (IOException e) { - System.err.println("Failed to load authorization view: " + e.getMessage()); e.printStackTrace(); - - // Fallback to previous behavior if authorization view can't be loaded - Stage currentStage = (Stage) deleteaccbutton.getScene().getWindow(); - currentStage.hide(); - - if (previousStage != null) { - previousStage.show(); - previousStage.toFront(); - previousStage.requestFocus(); - } } } - } diff --git a/src/main/java/com/starlight/controller/RecipeItemController.java b/src/main/java/com/starlight/controller/RecipeItemController.java new file mode 100644 index 0000000..3b3cc9d --- /dev/null +++ b/src/main/java/com/starlight/controller/RecipeItemController.java @@ -0,0 +1,263 @@ +package com.starlight.controller; + +import io.github.palexdev.materialfx.controls.MFXButton; +import javafx.fxml.FXML; +import javafx.fxml.FXMLLoader; +import javafx.scene.Parent; +import javafx.scene.Scene; +import javafx.scene.control.Label; +import javafx.scene.image.ImageView; +import javafx.stage.Modality; +import javafx.stage.Stage; +import javafx.animation.ScaleTransition; +import javafx.util.Duration; +import java.io.IOException; +import java.util.logging.Logger; + +import com.starlight.model.Post; + +public class RecipeItemController { + private static final Logger logger = Logger.getLogger(RecipeItemController.class.getName()); + + @FXML + private ImageView image; + + @FXML + private Label title; + + @FXML + private Label rating; + + @FXML + private Label likecount; + + @FXML + private MFXButton editPost; + + @FXML + private MFXButton deletePost; + + private Post currentPost; + private Runnable onPostUpdated; // Callback to refresh the parent view + private MainController mainController; // Reference to main controller for navigation + + @FXML + private void initialize() { + // Connect edit and delete buttons + editPost.setOnAction(event -> handleEditPost()); + deletePost.setOnAction(event -> handleDeletePost()); + + // Click handling will be set up when main controller is set + } + + /** + * Sets the recipe data for this recipe item + */ + public void setRecipeData(String title, String rating, String likecount) { + if (this.title != null) { + this.title.setText(title); + } + if (this.rating != null) { + this.rating.setText(rating); + } + if (this.likecount != null) { + this.likecount.setText(likecount); + } + } + + /** + * Sets the post data for this recipe item + */ + public void setPost(Post post) { + this.currentPost = post; + } + + /** + * Sets the callback to refresh the parent view after updates + */ + public void setOnPostUpdated(Runnable callback) { + this.onPostUpdated = callback; + } + + /** + * Gets the image view for external image loading + */ + public ImageView getImageView() { + return image; + } + + /** + * Sets the main controller reference for navigation + */ + public void setMainController(MainController mainController) { + this.mainController = mainController; + // Now that we have the main controller, set up click handling + setupRecipeClickHandling(); + } + + /** + * Handles the edit post button click + */ + private void handleEditPost() { + if (currentPost == null) { + logger.warning("No post data available for editing"); + return; + } + + try { + FXMLLoader loader = new FXMLLoader(getClass().getResource("/com/starlight/view/editPost.fxml")); + Parent editRoot = loader.load(); + EditPostController editController = loader.getController(); + + // Set the post data in the edit controller + editController.setPost(currentPost); + + // Create and configure the modal dialog + editRoot.setScaleX(0.7); + editRoot.setScaleY(0.7); + + Scene editScene = new Scene(editRoot); + Stage editStage = new Stage(); + editStage.initModality(Modality.APPLICATION_MODAL); + editStage.setScene(editScene); + editStage.setTitle("Edit Recipe"); + editStage.setResizable(false); + + // Add scale animation + editStage.setOnShown(e -> { + ScaleTransition st = new ScaleTransition(Duration.millis(220), editRoot); + st.setFromX(0.7); + st.setFromY(0.7); + st.setToX(1.0); + st.setToY(1.0); + st.play(); + }); + + editStage.showAndWait(); + + // Refresh the parent view if the edit was successful + if (editController.isSuccess() && onPostUpdated != null) { + onPostUpdated.run(); + } + + } catch (IOException e) { + logger.warning("Failed to load edit post dialog: " + e.getMessage()); + e.printStackTrace(); + } + } + + /** + * Handles the delete post button click + */ + private void handleDeletePost() { + if (currentPost == null) { + logger.warning("No post data available for deletion"); + return; + } + + try { + FXMLLoader loader = new FXMLLoader(getClass().getResource("/com/starlight/view/deleteDialog.fxml")); + Parent deleteRoot = loader.load(); + DeleteDialogController deleteController = loader.getController(); + + // Set the post data in the delete controller + deleteController.setPost(currentPost); + + // Create and configure the modal dialog + Scene deleteScene = new Scene(deleteRoot); + Stage deleteStage = new Stage(); + deleteStage.initModality(Modality.APPLICATION_MODAL); + deleteStage.setScene(deleteScene); + deleteStage.setTitle("Delete Recipe"); + deleteStage.setResizable(false); + + deleteStage.showAndWait(); + + // Refresh the parent view if the deletion was confirmed + if (deleteController.isConfirmed() && onPostUpdated != null) { + onPostUpdated.run(); + } + + } catch (IOException e) { + logger.warning("Failed to load delete dialog: " + e.getMessage()); + e.printStackTrace(); + } + } + + /** + * Sets up click handling for the recipe item (excluding buttons) + */ + private void setupRecipeClickHandling() { + // Get the root container (could be GridPane or other layout) + javafx.scene.Node rootContainer = null; + if (image != null && image.getParent() != null) { + rootContainer = image.getParent(); + } + + // Make the root container clickable (but not the buttons) + if (rootContainer != null) { + rootContainer.setOnMouseClicked(event -> { + // Check if click target is a button or interactive element + javafx.scene.Node target = (javafx.scene.Node) event.getTarget(); + + // Prevent clicks on edit/delete buttons from triggering navigation + if (isClickableButton(target)) { + return; + } + + handleRecipeClick(); + }); + + // Set cursor to indicate clickable area + rootContainer.setStyle("-fx-cursor: hand;"); + } + + // Also make the image and title explicitly clickable + if (image != null) { + image.setOnMouseClicked(event -> handleRecipeClick()); + image.setStyle("-fx-cursor: hand;"); + } + + if (title != null) { + title.setOnMouseClicked(event -> handleRecipeClick()); + title.setStyle("-fx-cursor: hand;"); + } + + if (rating != null) { + rating.setOnMouseClicked(event -> handleRecipeClick()); + rating.setStyle("-fx-cursor: hand;"); + } + } + + /** + * Checks if the clicked element is a button or interactive element + */ + private boolean isClickableButton(javafx.scene.Node target) { + // Check if the target itself or any of its parents is a button + javafx.scene.Node current = target; + while (current != null) { + if (current == editPost || current == deletePost) { + return true; + } + current = current.getParent(); + } + return false; + } + + /** + * Handles clicks on the recipe content to navigate to full post view + */ + private void handleRecipeClick() { + if (currentPost != null && mainController != null) { + mainController.setCurrentPost(currentPost); + mainController.loadPage("Post"); + } else { + if (currentPost == null) { + logger.warning("Cannot navigate: currentPost is null"); + } + if (mainController == null) { + logger.warning("Cannot navigate: mainController is null"); + } + } + } +} diff --git a/src/main/java/com/starlight/controller/RecipeManagerController.java b/src/main/java/com/starlight/controller/RecipeManagerController.java new file mode 100644 index 0000000..eb602ac --- /dev/null +++ b/src/main/java/com/starlight/controller/RecipeManagerController.java @@ -0,0 +1,720 @@ +package com.starlight.controller; + +import javafx.collections.FXCollections; +import javafx.collections.ObservableList; +import javafx.fxml.FXML; +import javafx.fxml.Initializable; +import javafx.scene.control.*; +import javafx.scene.Node; +import javafx.scene.layout.GridPane; +import javafx.geometry.Insets; +import java.util.Optional; +import javafx.beans.property.SimpleStringProperty; +import javafx.application.Platform; +import javafx.concurrent.Task; +import javafx.geometry.Pos; +import javafx.scene.layout.HBox; + +import com.starlight.model.Post; +import com.starlight.model.User; +import com.starlight.repository.PostDataRepository; +import com.starlight.repository.UserDataRepository; +import com.starlight.util.Session; + +import io.github.palexdev.materialfx.controls.MFXButton; +import io.github.palexdev.materialfx.controls.MFXComboBox; +import io.github.palexdev.materialfx.controls.MFXTableView; +import io.github.palexdev.materialfx.controls.MFXTableColumn; +import io.github.palexdev.materialfx.controls.cell.MFXTableRowCell; + +import java.net.URL; +import java.util.ResourceBundle; +import java.util.List; +import java.util.Comparator; +import java.util.stream.Collectors; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; + +/** + * Controller for the admin panel that displays and manages posts and users data. + * Provides functionality to view, edit, sort, and delete posts from XML data. + */ +public class RecipeManagerController implements Initializable { + + @FXML + private MFXTableView postsTable; + + // Create table columns programmatically instead of FXML injection + private MFXTableColumn titleColumn; + private MFXTableColumn usernameColumn; + private MFXTableColumn ratingColumn; + private MFXTableColumn likeCountColumn; + private MFXTableColumn uploadTimeColumn; + private MFXTableColumn actionColumn; + + @FXML + private MFXComboBox sortComboBox; + + @FXML + private MFXButton refreshButton; + + @FXML + private Label totalPostsLabel; + + @FXML + private Label totalUsersLabel; + + @FXML + private ProgressIndicator loadingIndicator; + + private PostDataRepository postRepository; + private UserDataRepository userRepository; + private ObservableList postDataList; + + /** + * Data model for the table view + */ + public static class PostTableData { + private final SimpleStringProperty uuid; + private final SimpleStringProperty title; + private final SimpleStringProperty username; + private final SimpleStringProperty rating; + private final SimpleStringProperty likeCount; + private final SimpleStringProperty uploadTime; + private final SimpleStringProperty description; + private final Post originalPost; + + public PostTableData(Post post) { + this.originalPost = post; + this.uuid = new SimpleStringProperty(post.uuid != null ? post.uuid : ""); + this.title = new SimpleStringProperty(post.title != null ? post.title : ""); + this.username = new SimpleStringProperty(post.username != null ? post.username : ""); + this.rating = new SimpleStringProperty(post.rating != null ? post.rating : "0.0"); + this.likeCount = new SimpleStringProperty(post.likecount != null ? post.likecount : "0"); + this.uploadTime = new SimpleStringProperty(formatDateTime(post.uploadtime)); + this.description = new SimpleStringProperty(post.description != null ? post.description : ""); + } + + private static String formatDateTime(String uploadTime) { + if (uploadTime == null || uploadTime.trim().isEmpty()) { + return "Unknown"; + } + try { + LocalDateTime dateTime = LocalDateTime.parse(uploadTime, DateTimeFormatter.ISO_LOCAL_DATE_TIME); + return dateTime.format(DateTimeFormatter.ofPattern("MMM dd, yyyy HH:mm")); + } catch (DateTimeParseException e) { + // Try alternative format + try { + LocalDateTime dateTime = LocalDateTime.parse(uploadTime.replace("Z", "")); + return dateTime.format(DateTimeFormatter.ofPattern("MMM dd, yyyy HH:mm")); + } catch (Exception ex) { + return uploadTime; // Return original if parsing fails + } + } + } + + // Getters for table columns + public String getUuid() { return uuid.get(); } + public String getTitle() { return title.get(); } + public String getUsername() { return username.get(); } + public String getRating() { return rating.get(); } + public String getLikeCount() { return likeCount.get(); } + public String getUploadTime() { return uploadTime.get(); } + public String getDescription() { return description.get(); } + public Post getOriginalPost() { return originalPost; } + + // Property getters for table binding + public SimpleStringProperty titleProperty() { return title; } + public SimpleStringProperty usernameProperty() { return username; } + public SimpleStringProperty ratingProperty() { return rating; } + public SimpleStringProperty likeCountProperty() { return likeCount; } + public SimpleStringProperty uploadTimeProperty() { return uploadTime; } + } + + @Override + public void initialize(URL location, ResourceBundle resources) { + // Check admin access first + if (!hasAdminAccess()) { + showAccessDeniedAndClose(); + return; + } + + System.out.println("DEBUG: Initializing AdminController..."); + + // Initialize repositories + postRepository = new PostDataRepository(); + userRepository = new UserDataRepository(); + postDataList = FXCollections.observableArrayList(); + + // Setup table columns first + setupTableColumns(); + System.out.println("DEBUG: Table columns setup completed"); + + // Setup sort combo box + setupSortComboBox(); + + // Add test data to verify table works + addTestData(); + + // Load actual data + loadData(); + + // Setup refresh button + refreshButton.setOnAction(e -> loadData()); + + System.out.println("DEBUG: AdminController initialization completed"); + } + + /** + * Checks if the current user has admin access + */ + private boolean hasAdminAccess() { + User currentUser = Session.getCurrentUser(); + if (currentUser == null) { + return false; + } + + // Only allow access for users with exact username "admin" + return currentUser.username != null && + currentUser.username.toLowerCase().equals("admin"); + } + + /** + * Shows access denied message and closes the admin panel + */ + private void showAccessDeniedAndClose() { + Platform.runLater(() -> { + Alert alert = new Alert(Alert.AlertType.ERROR); + alert.setTitle("Access Denied"); + alert.setHeaderText("Admin Access Required"); + alert.setContentText("You do not have admin privileges to access this panel."); + alert.showAndWait(); + + // Close the window or navigate back + // This would ideally navigate back to the previous page + }); + } + + /** + * Sets up table columns with appropriate cell factories and value factories + */ + private void setupTableColumns() { + System.out.println("DEBUG: Setting up MFXTableView columns..."); + + // Create table columns programmatically with simplified approach + titleColumn = new MFXTableColumn<>("Title"); + usernameColumn = new MFXTableColumn<>("Author"); + ratingColumn = new MFXTableColumn<>("Rating"); + likeCountColumn = new MFXTableColumn<>("Likes"); + uploadTimeColumn = new MFXTableColumn<>("Upload Date"); + actionColumn = new MFXTableColumn<>("Actions"); + + // Set preferred widths - make them larger for better visibility + titleColumn.setPrefWidth(250.0); + usernameColumn.setPrefWidth(150.0); + ratingColumn.setPrefWidth(100.0); + likeCountColumn.setPrefWidth(100.0); + uploadTimeColumn.setPrefWidth(180.0); + actionColumn.setPrefWidth(180.0); + + // Set minimum widths to prevent columns from being too small + titleColumn.setMinWidth(200.0); + usernameColumn.setMinWidth(120.0); + ratingColumn.setMinWidth(80.0); + likeCountColumn.setMinWidth(80.0); + uploadTimeColumn.setMinWidth(150.0); + actionColumn.setMinWidth(160.0); + + // Setup cell factories using simple string extraction + titleColumn.setRowCellFactory(item -> new MFXTableRowCell<>(PostTableData::getTitle)); + usernameColumn.setRowCellFactory(item -> new MFXTableRowCell<>(PostTableData::getUsername)); + ratingColumn.setRowCellFactory(item -> new MFXTableRowCell<>(PostTableData::getRating)); + likeCountColumn.setRowCellFactory(item -> new MFXTableRowCell<>(PostTableData::getLikeCount)); + uploadTimeColumn.setRowCellFactory(item -> new MFXTableRowCell<>(PostTableData::getUploadTime)); + + // Setup action column + actionColumn.setRowCellFactory(item -> { + MFXTableRowCell cell = new MFXTableRowCell<>(data -> ""); + + // Create Edit button + MFXButton editButton = new MFXButton("Edit"); + editButton.getStyleClass().add("mfx-button"); + editButton.setStyle("-fx-background-color: #4CAF50; -fx-text-fill: white; -fx-font-size: 12px; -fx-pref-width: 60px;"); + editButton.setOnAction(event -> showEditDialog(item)); + + // Create Delete button + MFXButton deleteButton = new MFXButton("Delete"); + deleteButton.getStyleClass().add("mfx-button"); + deleteButton.setStyle("-fx-background-color: #ff4444; -fx-text-fill: white; -fx-font-size: 12px; -fx-pref-width: 60px;"); + deleteButton.setOnAction(event -> showDeleteConfirmation(item)); + + // Create HBox to hold both buttons + HBox buttonBox = new HBox(5); // 5px spacing between buttons + buttonBox.setAlignment(Pos.CENTER); + buttonBox.getChildren().addAll(editButton, deleteButton); + + cell.setGraphic(buttonBox); + cell.setAlignment(Pos.CENTER); + return cell; + }); + + // Add columns to table + postsTable.getTableColumns().clear(); + postsTable.getTableColumns().add(titleColumn); + postsTable.getTableColumns().add(usernameColumn); + postsTable.getTableColumns().add(ratingColumn); + postsTable.getTableColumns().add(likeCountColumn); + postsTable.getTableColumns().add(uploadTimeColumn); + postsTable.getTableColumns().add(actionColumn); + + // Set the items + postsTable.setItems(postDataList); + + // Configure table sizing + postsTable.setPrefHeight(400.0); + postsTable.setMinHeight(300.0); + postsTable.setMaxHeight(Double.MAX_VALUE); + + System.out.println("DEBUG: MFXTableView columns setup completed, postDataList size: " + postDataList.size()); + System.out.println("DEBUG: Table columns count: " + postsTable.getTableColumns().size()); + System.out.println("DEBUG: Table items count: " + postsTable.getItems().size()); + + // Force table update + Platform.runLater(() -> { + postsTable.update(); + System.out.println("DEBUG: Platform.runLater update called"); + }); + } + + /** + * Shows edit dialog for a post + */ + private void showEditDialog(PostTableData data) { + Dialog dialog = new Dialog<>(); + dialog.setTitle("Edit Post"); + dialog.setHeaderText("Edit post details for: " + data.getTitle()); + + // Set the button types + ButtonType saveButtonType = new ButtonType("Save", ButtonBar.ButtonData.OK_DONE); + dialog.getDialogPane().getButtonTypes().addAll(saveButtonType, ButtonType.CANCEL); + + // Create form fields + GridPane grid = new GridPane(); + grid.setHgap(10); + grid.setVgap(10); + grid.setPadding(new Insets(20, 150, 10, 10)); + + TextField titleField = new TextField(); + titleField.setText(data.getTitle()); + titleField.setPrefWidth(300); + + TextField ratingField = new TextField(); + ratingField.setText(data.getRating()); + ratingField.setPrefWidth(100); + + TextField likeCountField = new TextField(); + likeCountField.setText(data.getLikeCount()); + likeCountField.setPrefWidth(100); + + TextArea descriptionArea = new TextArea(); + descriptionArea.setText(data.getDescription()); + descriptionArea.setPrefRowCount(4); + descriptionArea.setPrefWidth(300); + descriptionArea.setWrapText(true); + + grid.add(new Label("Title:"), 0, 0); + grid.add(titleField, 1, 0); + grid.add(new Label("Rating:"), 0, 1); + grid.add(ratingField, 1, 1); + grid.add(new Label("Like Count:"), 0, 2); + grid.add(likeCountField, 1, 2); + grid.add(new Label("Description:"), 0, 3); + grid.add(descriptionArea, 1, 3); + + dialog.getDialogPane().setContent(grid); + + // Enable/Disable save button depending on whether valid data was entered + Node saveButton = dialog.getDialogPane().lookupButton(saveButtonType); + saveButton.setDisable(false); + + // Convert the result when the save button is clicked + dialog.setResultConverter(dialogButton -> { + if (dialogButton == saveButtonType) { + // Validate input + if (titleField.getText().trim().isEmpty()) { + showErrorAlert("Validation Error", "Title cannot be empty."); + return null; + } + + try { + double rating = Double.parseDouble(ratingField.getText()); + if (rating < 0 || rating > 5) { + showErrorAlert("Validation Error", "Rating must be between 0 and 5."); + return null; + } + } catch (NumberFormatException e) { + showErrorAlert("Validation Error", "Rating must be a valid number."); + return null; + } + + try { + int likeCount = Integer.parseInt(likeCountField.getText()); + if (likeCount < 0) { + showErrorAlert("Validation Error", "Like count cannot be negative."); + return null; + } + } catch (NumberFormatException e) { + showErrorAlert("Validation Error", "Like count must be a valid number."); + return null; + } + + // Create updated post + Post updatedPost = data.getOriginalPost(); + updatedPost.title = titleField.getText().trim(); + updatedPost.rating = ratingField.getText().trim(); + updatedPost.likecount = likeCountField.getText().trim(); + updatedPost.description = descriptionArea.getText().trim(); + + return updatedPost; + } + return null; + }); + + Optional result = dialog.showAndWait(); + result.ifPresent(updatedPost -> { + // Save changes + savePostChanges(updatedPost); + + // Update the table data + data.title.set(updatedPost.title); + data.rating.set(updatedPost.rating); + data.likeCount.set(updatedPost.likecount); + data.description.set(updatedPost.description); + + // Refresh the table + postsTable.update(); + + showSuccessAlert("Success", "Post updated successfully!"); + }); + } + + /** + * Sets up the sort combo box with sorting options + */ + private void setupSortComboBox() { + ObservableList sortOptions = FXCollections.observableArrayList( + "Name (A-Z)", "Name (Z-A)", + "Rating (High-Low)", "Rating (Low-High)", + "Likes (High-Low)", "Likes (Low-High)", + "Date (Newest)", "Date (Oldest)" + ); + + sortComboBox.setItems(sortOptions); + sortComboBox.setPromptText("Sort by..."); + + sortComboBox.setOnAction(e -> { + String selectedSort = sortComboBox.getSelectionModel().getSelectedItem(); + if (selectedSort != null) { + sortTable(selectedSort); + } + }); + } + + /** + * Loads data from XML files and populates the table + */ + private void loadData() { + loadingIndicator.setVisible(true); + + Task loadTask = new Task() { + @Override + protected Void call() throws Exception { + // Load posts and users + List posts = postRepository.loadPosts(); + List users = userRepository.loadUsers(); + + System.out.println("DEBUG: Loaded " + posts.size() + " posts from repository"); + + Platform.runLater(() -> { + // Clear existing data + postDataList.clear(); + + // Convert posts to table data + List tableData = posts.stream() + .map(PostTableData::new) + .collect(Collectors.toList()); + + System.out.println("DEBUG: Converted " + tableData.size() + " posts to table data"); + + // Debug: Print first few items + for (int i = 0; i < Math.min(3, tableData.size()); i++) { + PostTableData item = tableData.get(i); + System.out.println("DEBUG: Item " + i + ": " + item.getTitle() + " by " + item.getUsername()); + } + + postDataList.addAll(tableData); + + System.out.println("DEBUG: Added data to table, postDataList size: " + postDataList.size()); + + // Force MFXTableView to recognize the new data - multiple approaches + postsTable.setItems(null); // Clear first + postsTable.setItems(postDataList); // Reset + postsTable.update(); // Update + + // Try to manually trigger table update with delay + Platform.runLater(() -> { + postsTable.update(); + System.out.println("DEBUG: Manual table update after data load"); + + // Additional force update + Platform.runLater(() -> { + postsTable.update(); + System.out.println("DEBUG: Second manual table update"); + }); + }); + + // Update statistics + totalPostsLabel.setText("Total Posts: " + posts.size()); + totalUsersLabel.setText("Total Users: " + users.size()); + + // Force table refresh for MFXTableView + postsTable.update(); + + // Additional debug + System.out.println("DEBUG: After refresh - Table items: " + postsTable.getItems().size()); + System.out.println("DEBUG: After refresh - Table columns: " + postsTable.getTableColumns().size()); + + loadingIndicator.setVisible(false); + + System.out.println("DEBUG: Data loading completed successfully"); + }); + + return null; + } + }; + + loadTask.setOnFailed(e -> { + Platform.runLater(() -> { + loadingIndicator.setVisible(false); + System.err.println("DEBUG: Failed to load data: " + e.getSource().getException()); + e.getSource().getException().printStackTrace(); + showErrorAlert("Error", "Failed to load data from XML files: " + e.getSource().getException().getMessage()); + }); + }); + + new Thread(loadTask).start(); + } + + /** + * Sorts the table based on the selected criteria + */ + private void sortTable(String sortCriteria) { + Comparator comparator = null; + + switch (sortCriteria) { + case "Name (A-Z)": + comparator = Comparator.comparing(PostTableData::getTitle); + break; + case "Name (Z-A)": + comparator = Comparator.comparing(PostTableData::getTitle).reversed(); + break; + case "Rating (High-Low)": + comparator = Comparator.comparing(data -> { + try { + return Double.parseDouble(data.getRating()); + } catch (NumberFormatException e) { + return 0.0; + } + }, Comparator.reverseOrder()); + break; + case "Rating (Low-High)": + comparator = Comparator.comparing(data -> { + try { + return Double.parseDouble(data.getRating()); + } catch (NumberFormatException e) { + return 0.0; + } + }); + break; + case "Likes (High-Low)": + comparator = Comparator.comparing(data -> { + try { + return Integer.parseInt(data.getLikeCount()); + } catch (NumberFormatException e) { + return 0; + } + }, Comparator.reverseOrder()); + break; + case "Likes (Low-High)": + comparator = Comparator.comparing(data -> { + try { + return Integer.parseInt(data.getLikeCount()); + } catch (NumberFormatException e) { + return 0; + } + }); + break; + case "Date (Newest)": + comparator = Comparator.comparing((PostTableData data) -> parseDateTime(data.getOriginalPost().uploadtime)) + .reversed(); + break; + case "Date (Oldest)": + comparator = Comparator.comparing((PostTableData data) -> parseDateTime(data.getOriginalPost().uploadtime)); + break; + } + + if (comparator != null) { + postDataList.sort(comparator); + } + } + + /** + * Parses datetime string for sorting purposes + */ + private LocalDateTime parseDateTime(String uploadTime) { + if (uploadTime == null || uploadTime.trim().isEmpty()) { + return LocalDateTime.MIN; + } + try { + return LocalDateTime.parse(uploadTime, DateTimeFormatter.ISO_LOCAL_DATE_TIME); + } catch (DateTimeParseException e) { + try { + return LocalDateTime.parse(uploadTime.replace("Z", "")); + } catch (Exception ex) { + return LocalDateTime.MIN; + } + } + } + + /** + * Shows confirmation dialog for post deletion + */ + private void showDeleteConfirmation(PostTableData data) { + Alert alert = new Alert(Alert.AlertType.CONFIRMATION); + alert.setTitle("Confirm Delete"); + alert.setHeaderText("Delete Post"); + alert.setContentText("Are you sure you want to delete the post '" + data.getTitle() + "'?"); + + alert.showAndWait().ifPresent(response -> { + if (response == ButtonType.OK) { + deletePost(data); + } + }); + } + + /** + * Deletes a post from the data and XML file + */ + private void deletePost(PostTableData data) { + try { + // Use the repository's deletePost method + boolean deleted = postRepository.deletePost(data.getUuid()); + + if (deleted) { + // Remove from UI + postDataList.remove(data); + + // Update statistics + totalPostsLabel.setText("Total Posts: " + postDataList.size()); + + showSuccessAlert("Success", "Post deleted successfully!"); + } else { + showErrorAlert("Error", "Post not found or could not be deleted."); + } + + } catch (Exception e) { + showErrorAlert("Error", "Failed to delete post: " + e.getMessage()); + } + } + + /** + * Saves changes made to a post back to XML + */ + private void savePostChanges(Post post) { + try { + List allPosts = postRepository.loadPosts(); + + // Find and update the post + for (int i = 0; i < allPosts.size(); i++) { + if (allPosts.get(i).uuid != null && allPosts.get(i).uuid.equals(post.uuid)) { + allPosts.set(i, post); + break; + } + } + + postRepository.savePosts(allPosts); + } catch (Exception e) { + showErrorAlert("Error", "Failed to save changes: " + e.getMessage()); + } + } + + /** + * Shows success alert + */ + private void showSuccessAlert(String title, String message) { + Alert alert = new Alert(Alert.AlertType.INFORMATION); + alert.setTitle(title); + alert.setHeaderText(null); + alert.setContentText(message); + alert.showAndWait(); + } + + /** + * Shows error alert + */ + private void showErrorAlert(String title, String message) { + Alert alert = new Alert(Alert.AlertType.ERROR); + alert.setTitle(title); + alert.setHeaderText(null); + alert.setContentText(message); + alert.showAndWait(); + } + + /** + * Adds test data to verify the table is working + */ + private void addTestData() { + System.out.println("DEBUG: Adding test data to verify table functionality..."); + + // Create dummy posts for testing + Post testPost1 = new Post(); + testPost1.uuid = "test-1"; + testPost1.title = "Test Post 1"; + testPost1.username = "TestUser1"; + testPost1.rating = "4.5"; + testPost1.likecount = "10"; + testPost1.uploadtime = "2025-07-20T19:00:00"; + testPost1.description = "This is a test post to verify table functionality"; + + Post testPost2 = new Post(); + testPost2.uuid = "test-2"; + testPost2.title = "Test Post 2"; + testPost2.username = "TestUser2"; + testPost2.rating = "3.8"; + testPost2.likecount = "5"; + testPost2.uploadtime = "2025-07-20T18:30:00"; + testPost2.description = "Another test post"; + + // Convert to table data + PostTableData tableData1 = new PostTableData(testPost1); + PostTableData tableData2 = new PostTableData(testPost2); + + // Add to the list + postDataList.clear(); + postDataList.add(tableData1); + postDataList.add(tableData2); + + System.out.println("DEBUG: Added " + postDataList.size() + " test items to table"); + System.out.println("DEBUG: Test item 1: " + tableData1.getTitle() + " by " + tableData1.getUsername()); + System.out.println("DEBUG: Test item 2: " + tableData2.getTitle() + " by " + tableData2.getUsername()); + + // Force table update + postsTable.update(); + + Platform.runLater(() -> { + postsTable.update(); + System.out.println("DEBUG: Test data added and table updated"); + }); + } +} diff --git a/src/main/java/com/starlight/controller/RegisterViewController.java b/src/main/java/com/starlight/controller/RegisterViewController.java index 72bd5bf..8a364f7 100644 --- a/src/main/java/com/starlight/controller/RegisterViewController.java +++ b/src/main/java/com/starlight/controller/RegisterViewController.java @@ -2,10 +2,10 @@ import java.net.URL; import java.util.ResourceBundle; - +import java.util.logging.Logger; import com.starlight.api.ApiClient; import com.starlight.api.ApiClient.ApiException; -import com.starlight.models.User; +import com.starlight.model.User; import io.github.palexdev.materialfx.controls.MFXButton; import io.github.palexdev.materialfx.controls.MFXTextField; @@ -24,6 +24,7 @@ * Controller used for registering a new user account within the Authorization panel. */ public class RegisterViewController implements Initializable { + private static final Logger logger = Logger.getLogger(RegisterViewController.class.getName()); @FXML private VBox registerView; @@ -111,7 +112,7 @@ private void performRegister() { try { User registered = apiClient.register(user, em, pass); - System.out.println("Register success for user: " + registered.username); + logger.info("Register success for user: " + registered.username); // Show success message before switching view showSuccess("Account created successfully! Please log in."); @@ -121,7 +122,7 @@ private void performRegister() { authController.showLoginView(); } } catch (ApiException e) { - System.out.println("Register failed: " + e.getMessage()); + logger.warning("Register failed: " + e.getMessage()); // Show user-friendly error message if (e.getStatusCode() == 409) { diff --git a/src/main/java/com/starlight/controller/SettingController.java b/src/main/java/com/starlight/controller/SettingController.java new file mode 100644 index 0000000..a2e367b --- /dev/null +++ b/src/main/java/com/starlight/controller/SettingController.java @@ -0,0 +1,156 @@ +package com.starlight.controller; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.logging.Level; +import java.util.logging.Logger; + +import com.starlight.api.ChatbotAPI; +import com.thoughtworks.xstream.XStream; +import com.thoughtworks.xstream.io.xml.DomDriver; + +import io.github.palexdev.materialfx.controls.MFXButton; +import io.github.palexdev.materialfx.controls.MFXPasswordField; +import javafx.fxml.FXML; +import javafx.scene.control.Alert; +import javafx.scene.input.KeyCode; +import javafx.scene.input.KeyEvent; +import javafx.scene.input.MouseEvent; +import javafx.scene.layout.VBox; + +public class SettingController { + private static final Logger logger = Logger.getLogger(SettingController.class.getName()); + + @FXML + private VBox loginView; + + @FXML + private MFXButton goBack; + + @FXML + private MFXPasswordField secret_key; + + @FXML + private MFXButton saveButton; + + private MainController mainController; + private XStream xstream; + + @FXML + private void initialize() { + // Initialize XStream for XML serialization + this.xstream = new XStream(new DomDriver()); + this.xstream.allowTypesByWildcard(new String[]{"com.starlight.api.*"}); + this.xstream.alias("config", ChatbotAPI.Config.class); + this.xstream.aliasField("openai-key", ChatbotAPI.Config.class, "openaiKey"); + + // Load existing API key if available + loadExistingApiKey(); + + // Add key event handler for Enter key + secret_key.setOnKeyPressed(this::handleKeyPressed); + } + + /** + * Sets the main controller reference for navigation + */ + public void setMainController(MainController mainController) { + this.mainController = mainController; + } + + /** + * Handles the go back button click - navigates back to previous view + */ + @FXML + private void goBack(MouseEvent event) { + if (mainController != null) { + // Navigate back to home view using the same pattern as navigateToCommunity + mainController.loadPage("home"); + } + } + + /** + * Handles key press events on the secret key field + */ + private void handleKeyPressed(KeyEvent event) { + if (event.getCode() == KeyCode.ENTER) { + saveApiKey(); + } + } + + /** + * Saves the API key to the configuration file + */ + @FXML + private void saveApiKey() { + String apiKey = secret_key.getText(); + + if (apiKey == null || apiKey.trim().isEmpty()) { + showAlert("Error", "Please enter a valid API key.", Alert.AlertType.ERROR); + return; + } + + try { + // Create the directory if it doesn't exist + String userHome = System.getProperty("user.home"); + Path configDir = Paths.get(userHome, ".tabemonpal", "Database"); + Files.createDirectories(configDir); + + // Create config object + ChatbotAPI.Config config = new ChatbotAPI.Config(); + config.setOpenaiKey(apiKey.trim()); + + // Save to XML file + File configFile = configDir.resolve(".SECRET_KEY.xml").toFile(); + try (FileOutputStream fos = new FileOutputStream(configFile)) { + xstream.toXML(config, fos); + } + + showAlert("Success", "API key saved successfully! The chatbot functionality is now available.", Alert.AlertType.INFORMATION); + logger.info("API key saved successfully to: " + configFile.getAbsolutePath()); + + // Clear the password field for security + secret_key.clear(); + + } catch (IOException e) { + logger.log(Level.SEVERE, "Failed to save API key: " + e.getMessage(), e); + showAlert("Error", "Failed to save API key: " + e.getMessage(), Alert.AlertType.ERROR); + } + } + + /** + * Loads existing API key from the configuration file if it exists + */ + private void loadExistingApiKey() { + try { + String userHome = System.getProperty("user.home"); + Path configFile = Paths.get(userHome, ".tabemonpal", "Database", ".SECRET_KEY.xml"); + + if (Files.exists(configFile)) { + ChatbotAPI.Config config = (ChatbotAPI.Config) xstream.fromXML(configFile.toFile()); + if (config.getOpenaiKey() != null && !config.getOpenaiKey().trim().isEmpty() + && !config.getOpenaiKey().equals("your-openai-api-key-here")) { + // Show placeholder text to indicate key is already set + secret_key.setPromptText("API key is already configured"); + } + } + } catch (Exception e) { + logger.log(Level.WARNING, "Could not load existing API key: " + e.getMessage(), e); + } + } + + /** + * Shows an alert dialog with the specified message + */ + private void showAlert(String title, String message, Alert.AlertType type) { + Alert alert = new Alert(type); + alert.setTitle(title); + alert.setHeaderText(null); + alert.setContentText(message); + alert.showAndWait(); + } +} diff --git a/src/main/java/com/starlight/controller/TabestreakController.java b/src/main/java/com/starlight/controller/TabestreakController.java new file mode 100644 index 0000000..1d27487 --- /dev/null +++ b/src/main/java/com/starlight/controller/TabestreakController.java @@ -0,0 +1,21 @@ +package com.starlight.controller; + +import java.net.URL; +import java.util.ResourceBundle; + +import javafx.fxml.Initializable; + +/** + * Placeholder controller for the games view. + */ +public class TabestreakController implements Initializable { + + /** + * Initializes the controller. Currently no-op. + */ + @Override + public void initialize(URL url, ResourceBundle rb) { + // no-op + } + +} diff --git a/src/main/java/com/starlight/controller/UserManagerController.java b/src/main/java/com/starlight/controller/UserManagerController.java new file mode 100644 index 0000000..4525a69 --- /dev/null +++ b/src/main/java/com/starlight/controller/UserManagerController.java @@ -0,0 +1,642 @@ +package com.starlight.controller; + +import javafx.collections.FXCollections; +import javafx.collections.ObservableList; +import javafx.fxml.FXML; +import javafx.fxml.Initializable; +import javafx.scene.control.*; +import javafx.scene.Node; +import javafx.scene.layout.GridPane; +import javafx.geometry.Insets; +import java.util.Optional; +import javafx.beans.property.SimpleStringProperty; +import javafx.application.Platform; +import javafx.concurrent.Task; +import javafx.geometry.Pos; +import javafx.scene.layout.HBox; + +import com.starlight.model.Post; +import com.starlight.model.User; +import com.starlight.repository.PostDataRepository; +import com.starlight.repository.UserDataRepository; +import com.starlight.util.Session; + +import io.github.palexdev.materialfx.controls.MFXButton; +import io.github.palexdev.materialfx.controls.MFXComboBox; +import io.github.palexdev.materialfx.controls.MFXTableView; +import io.github.palexdev.materialfx.controls.MFXTableColumn; +import io.github.palexdev.materialfx.controls.cell.MFXTableRowCell; + +import java.net.URL; +import java.util.ResourceBundle; +import java.util.List; +import java.util.Comparator; +import java.util.stream.Collectors; +import java.util.logging.Logger; + +/** + * Controller for the user management panel that displays and manages user data. + * Provides functionality to view, edit, sort, and delete users from XML data. + */ +public class UserManagerController implements Initializable { + + private static final Logger logger = Logger.getLogger(UserManagerController.class.getName()); + + @FXML + private MFXTableView postsTable; + + // Create table columns programmatically instead of FXML injection + private MFXTableColumn usernameColumn; + private MFXTableColumn fullNameColumn; + private MFXTableColumn emailColumn; + private MFXTableColumn statusColumn; + private MFXTableColumn postsCountColumn; + private MFXTableColumn actionColumn; + + @FXML + private MFXComboBox sortComboBox; + + @FXML + private MFXButton refreshButton; + + @FXML + private Label totalPostsLabel; + + @FXML + private Label totalUsersLabel; + + @FXML + private ProgressIndicator loadingIndicator; + + private PostDataRepository postRepository; + private UserDataRepository userRepository; + private ObservableList userDataList; + + /** + * Data model for the user table view + */ + public static class UserTableData { + private final SimpleStringProperty email; // Using email as unique identifier + private final SimpleStringProperty username; + private final SimpleStringProperty fullName; + private final SimpleStringProperty joinDate; + private final SimpleStringProperty status; + private final SimpleStringProperty postsCount; + private final User originalUser; + + public UserTableData(User user, int userPostsCount) { + this.originalUser = user; + this.email = new SimpleStringProperty(user.email != null ? user.email : ""); + this.username = new SimpleStringProperty(user.username != null ? user.username : ""); + this.fullName = new SimpleStringProperty(user.fullname != null ? user.fullname : ""); + this.joinDate = new SimpleStringProperty("N/A"); // User model doesn't have creation date + this.status = new SimpleStringProperty(determineUserStatus(user)); + this.postsCount = new SimpleStringProperty(String.valueOf(userPostsCount)); + } + + private static String determineUserStatus(User user) { + if (user.username != null && user.username.toLowerCase().equals("admin")) { + return "Admin"; + } + // You can add more status logic here based on user properties + return "Active"; + } + + // Getters for table columns + public String getEmail() { return email.get(); } + public String getUsername() { return username.get(); } + public String getFullName() { return fullName.get(); } + public String getJoinDate() { return joinDate.get(); } + public String getStatus() { return status.get(); } + public String getPostsCount() { return postsCount.get(); } + public User getOriginalUser() { return originalUser; } + + // Property getters for table binding + public SimpleStringProperty emailProperty() { return email; } + public SimpleStringProperty usernameProperty() { return username; } + public SimpleStringProperty fullNameProperty() { return fullName; } + public SimpleStringProperty joinDateProperty() { return joinDate; } + public SimpleStringProperty statusProperty() { return status; } + public SimpleStringProperty postsCountProperty() { return postsCount; } + } + + @Override + public void initialize(URL location, ResourceBundle resources) { + // Check admin access first + if (!hasAdminAccess()) { + showAccessDeniedAndClose(); + return; + } + + logger.info("Initializing UserManagerController..."); + + // Initialize repositories + postRepository = new PostDataRepository(); + userRepository = new UserDataRepository(); + userDataList = FXCollections.observableArrayList(); + + // Setup table columns first + setupTableColumns(); + logger.info("Table columns setup completed"); + + // Setup sort combo box + setupSortComboBox(); + + // Load actual data + loadData(); + + // Setup refresh button + refreshButton.setOnAction(e -> loadData()); + + logger.info("UserManagerController initialization completed"); + } + + /** + * Checks if the current user has admin access + */ + private boolean hasAdminAccess() { + User currentUser = Session.getCurrentUser(); + if (currentUser == null) { + return false; + } + + // Only allow access for users with exact username "admin" + return currentUser.username != null && + currentUser.username.toLowerCase().equals("admin"); + } + + /** + * Shows access denied message and closes the admin panel + */ + private void showAccessDeniedAndClose() { + Platform.runLater(() -> { + Alert alert = new Alert(Alert.AlertType.ERROR); + alert.setTitle("Access Denied"); + alert.setHeaderText("Admin Access Required"); + alert.setContentText("You do not have admin privileges to access this panel."); + alert.showAndWait(); + + // Close the window or navigate back + // This would ideally navigate back to the previous page + }); + } + + /** + * Sets up table columns with appropriate cell factories and value factories + */ + private void setupTableColumns() { + logger.info("Setting up MFXTableView columns for users..."); + + // Create table columns programmatically + usernameColumn = new MFXTableColumn<>("Username"); + fullNameColumn = new MFXTableColumn<>("Full Name"); + emailColumn = new MFXTableColumn<>("Email"); + statusColumn = new MFXTableColumn<>("Status"); + postsCountColumn = new MFXTableColumn<>("Posts"); + actionColumn = new MFXTableColumn<>("Actions"); + + // Set preferred widths + usernameColumn.setPrefWidth(120.0); + fullNameColumn.setPrefWidth(150.0); + emailColumn.setPrefWidth(180.0); + statusColumn.setPrefWidth(80.0); + postsCountColumn.setPrefWidth(70.0); + actionColumn.setPrefWidth(180.0); + + // Set minimum widths + usernameColumn.setMinWidth(100.0); + fullNameColumn.setMinWidth(120.0); + emailColumn.setMinWidth(150.0); + statusColumn.setMinWidth(70.0); + postsCountColumn.setMinWidth(60.0); + actionColumn.setMinWidth(160.0); + + // Setup cell factories + usernameColumn.setRowCellFactory(item -> new MFXTableRowCell<>(UserTableData::getUsername)); + fullNameColumn.setRowCellFactory(item -> new MFXTableRowCell<>(UserTableData::getFullName)); + emailColumn.setRowCellFactory(item -> new MFXTableRowCell<>(UserTableData::getEmail)); + statusColumn.setRowCellFactory(item -> new MFXTableRowCell<>(UserTableData::getStatus)); + postsCountColumn.setRowCellFactory(item -> new MFXTableRowCell<>(UserTableData::getPostsCount)); + + // Setup action column + actionColumn.setRowCellFactory(item -> { + MFXTableRowCell cell = new MFXTableRowCell<>(data -> ""); + + // Create Edit button + MFXButton editButton = new MFXButton("Edit"); + editButton.getStyleClass().add("mfx-button"); + editButton.setStyle("-fx-background-color: #4CAF50; -fx-text-fill: white; -fx-font-size: 12px; -fx-pref-width: 60px;"); + editButton.setOnAction(event -> showEditDialog(item)); + + // Create Delete button (only for non-admin users) + MFXButton deleteButton = new MFXButton("Delete"); + deleteButton.getStyleClass().add("mfx-button"); + deleteButton.setStyle("-fx-background-color: #ff4444; -fx-text-fill: white; -fx-font-size: 12px; -fx-pref-width: 60px;"); + deleteButton.setOnAction(event -> showDeleteConfirmation(item)); + + // Disable delete for admin users + if (item.getUsername().toLowerCase().equals("admin")) { + deleteButton.setDisable(true); + deleteButton.setStyle("-fx-background-color: #cccccc; -fx-text-fill: white; -fx-font-size: 12px; -fx-pref-width: 60px;"); + } + + // Create HBox to hold both buttons + HBox buttonBox = new HBox(5); // 5px spacing between buttons + buttonBox.setAlignment(Pos.CENTER); + buttonBox.getChildren().addAll(editButton, deleteButton); + + cell.setGraphic(buttonBox); + cell.setAlignment(Pos.CENTER); + return cell; + }); + + // Add columns to table - using the existing FXML table ID + postsTable.getTableColumns().clear(); + postsTable.getTableColumns().add(usernameColumn); + postsTable.getTableColumns().add(fullNameColumn); + postsTable.getTableColumns().add(emailColumn); + postsTable.getTableColumns().add(statusColumn); + postsTable.getTableColumns().add(postsCountColumn); + postsTable.getTableColumns().add(actionColumn); + + // Set the items + postsTable.setItems(userDataList); + + // Configure table sizing + postsTable.setPrefHeight(400.0); + postsTable.setMinHeight(300.0); + postsTable.setMaxHeight(Double.MAX_VALUE); + + logger.info("MFXTableView columns setup completed, userDataList size: " + userDataList.size()); + + // Force table update + Platform.runLater(() -> { + postsTable.update(); + logger.fine("Platform.runLater update called for users table"); + }); + } + + /** + * Shows edit dialog for a user + */ + private void showEditDialog(UserTableData data) { + Dialog dialog = new Dialog<>(); + dialog.setTitle("Edit User"); + dialog.setHeaderText("Edit user details for: " + data.getUsername()); + + // Set the button types + ButtonType saveButtonType = new ButtonType("Save", ButtonBar.ButtonData.OK_DONE); + dialog.getDialogPane().getButtonTypes().addAll(saveButtonType, ButtonType.CANCEL); + + // Create form fields + GridPane grid = new GridPane(); + grid.setHgap(10); + grid.setVgap(10); + grid.setPadding(new Insets(20, 150, 10, 10)); + + TextField usernameField = new TextField(); + usernameField.setText(data.getUsername()); + usernameField.setPrefWidth(300); + + TextField fullNameField = new TextField(); + fullNameField.setText(data.getFullName()); + fullNameField.setPrefWidth(300); + + TextField emailField = new TextField(); + emailField.setText(data.getEmail()); + emailField.setPrefWidth(300); + + ComboBox statusComboBox = new ComboBox<>(); + statusComboBox.getItems().addAll("Active", "Inactive", "Admin"); + statusComboBox.setValue(data.getStatus()); + statusComboBox.setPrefWidth(300); + + grid.add(new Label("Username:"), 0, 0); + grid.add(usernameField, 1, 0); + grid.add(new Label("Full Name:"), 0, 1); + grid.add(fullNameField, 1, 1); + grid.add(new Label("Email:"), 0, 2); + grid.add(emailField, 1, 2); + grid.add(new Label("Status:"), 0, 3); + grid.add(statusComboBox, 1, 3); + + dialog.getDialogPane().setContent(grid); + + // Enable/Disable save button depending on whether valid data was entered + Node saveButton = dialog.getDialogPane().lookupButton(saveButtonType); + saveButton.setDisable(false); + + // Convert the result when the save button is clicked + dialog.setResultConverter(dialogButton -> { + if (dialogButton == saveButtonType) { + // Validate input + if (usernameField.getText().trim().isEmpty()) { + showErrorAlert("Validation Error", "Username cannot be empty."); + return null; + } + + if (emailField.getText().trim().isEmpty()) { + showErrorAlert("Validation Error", "Email cannot be empty."); + return null; + } + + // Basic email validation + if (!emailField.getText().contains("@")) { + showErrorAlert("Validation Error", "Please enter a valid email address."); + return null; + } + + // Create updated user + User updatedUser = data.getOriginalUser(); + updatedUser.username = usernameField.getText().trim(); + updatedUser.fullname = fullNameField.getText().trim(); + updatedUser.email = emailField.getText().trim(); + // Note: You might want to add more fields to User model for status management + + return updatedUser; + } + return null; + }); + + Optional result = dialog.showAndWait(); + result.ifPresent(updatedUser -> { + // Save changes + saveUserChanges(updatedUser); + + // Update the table data + data.username.set(updatedUser.username); + data.fullName.set(updatedUser.fullname); + data.email.set(updatedUser.email); + + // Refresh the table + postsTable.update(); + + showSuccessAlert("Success", "User updated successfully!"); + }); + } + + /** + * Sets up the sort combo box with sorting options + */ + private void setupSortComboBox() { + ObservableList sortOptions = FXCollections.observableArrayList( + "Username (A-Z)", "Username (Z-A)", + "Full Name (A-Z)", "Full Name (Z-A)", + "Email (A-Z)", "Email (Z-A)", + "Status", "Posts Count (High-Low)", "Posts Count (Low-High)" + ); + + sortComboBox.setItems(sortOptions); + sortComboBox.setPromptText("Sort by..."); + + sortComboBox.setOnAction(e -> { + String selectedSort = sortComboBox.getSelectionModel().getSelectedItem(); + if (selectedSort != null) { + sortTable(selectedSort); + } + }); + } + + /** + * Loads data from XML files and populates the table + */ + private void loadData() { + loadingIndicator.setVisible(true); + + Task loadTask = new Task() { + @Override + protected Void call() throws Exception { + // Load users and posts (to count posts per user) + List users = userRepository.loadUsers(); + List posts = postRepository.loadPosts(); + + logger.info("Loaded " + users.size() + " users from repository"); + + Platform.runLater(() -> { + // Clear existing data + userDataList.clear(); + + // Convert users to table data with post counts + List tableData = users.stream() + .map(user -> { + int userPostsCount = (int) posts.stream() + .filter(post -> post.username != null && post.username.equals(user.username)) + .count(); + return new UserTableData(user, userPostsCount); + }) + .collect(Collectors.toList()); + + logger.info("Converted " + tableData.size() + " users to table data"); + + // Debug: Print first few items + for (int i = 0; i < Math.min(3, tableData.size()); i++) { + UserTableData item = tableData.get(i); + logger.fine("User " + i + ": " + item.getUsername() + " (" + item.getEmail() + ")"); + } + + userDataList.addAll(tableData); + + logger.info("Added data to table, userDataList size: " + userDataList.size()); + + // Force MFXTableView to recognize the new data + postsTable.setItems(null); // Clear first + postsTable.setItems(userDataList); // Reset + postsTable.update(); // Update + + // Try to manually trigger table update with delay + Platform.runLater(() -> { + postsTable.update(); + logger.fine("Manual table update after data load"); + + // Additional force update + Platform.runLater(() -> { + postsTable.update(); + logger.fine("Second manual table update"); + }); + }); + + // Update statistics + totalPostsLabel.setText("Total Posts: " + posts.size()); + totalUsersLabel.setText("Total Users: " + users.size()); + + // Force table refresh for MFXTableView + postsTable.update(); + + // Additional debug + logger.fine("After refresh - Table items: " + postsTable.getItems().size()); + logger.fine("After refresh - Table columns: " + postsTable.getTableColumns().size()); + + loadingIndicator.setVisible(false); + + logger.info("User data loading completed successfully"); + }); + + return null; + } + }; + + loadTask.setOnFailed(e -> { + Platform.runLater(() -> { + loadingIndicator.setVisible(false); + logger.severe("Failed to load data: " + e.getSource().getException()); + e.getSource().getException().printStackTrace(); + showErrorAlert("Error", "Failed to load data from XML files: " + e.getSource().getException().getMessage()); + }); + }); + + new Thread(loadTask).start(); + } + + /** + * Sorts the table based on the selected criteria + */ + private void sortTable(String sortCriteria) { + Comparator comparator = null; + + switch (sortCriteria) { + case "Username (A-Z)": + comparator = Comparator.comparing(UserTableData::getUsername); + break; + case "Username (Z-A)": + comparator = Comparator.comparing(UserTableData::getUsername).reversed(); + break; + case "Full Name (A-Z)": + comparator = Comparator.comparing(UserTableData::getFullName); + break; + case "Full Name (Z-A)": + comparator = Comparator.comparing(UserTableData::getFullName).reversed(); + break; + case "Email (A-Z)": + comparator = Comparator.comparing(UserTableData::getEmail); + break; + case "Email (Z-A)": + comparator = Comparator.comparing(UserTableData::getEmail).reversed(); + break; + case "Status": + comparator = Comparator.comparing(UserTableData::getStatus); + break; + case "Posts Count (High-Low)": + comparator = Comparator.comparing(data -> { + try { + return Integer.parseInt(data.getPostsCount()); + } catch (NumberFormatException e) { + return 0; + } + }, Comparator.reverseOrder()); + break; + case "Posts Count (Low-High)": + comparator = Comparator.comparing(data -> { + try { + return Integer.parseInt(data.getPostsCount()); + } catch (NumberFormatException e) { + return 0; + } + }); + break; + } + + if (comparator != null) { + userDataList.sort(comparator); + } + } + + /** + * Shows confirmation dialog for user deletion + */ + private void showDeleteConfirmation(UserTableData data) { + // Prevent deletion of admin user + if (data.getUsername().toLowerCase().equals("admin")) { + showErrorAlert("Cannot Delete", "Admin user cannot be deleted."); + return; + } + + Alert alert = new Alert(Alert.AlertType.CONFIRMATION); + alert.setTitle("Confirm Delete"); + alert.setHeaderText("Delete User"); + alert.setContentText("Are you sure you want to delete the user '" + data.getUsername() + "'? This will also delete all their posts."); + + alert.showAndWait().ifPresent(response -> { + if (response == ButtonType.OK) { + deleteUser(data); + } + }); + } + + /** + * Deletes a user from the data and XML file + */ + private void deleteUser(UserTableData data) { + try { + // Use the repository's deleteUser method (using email as identifier) + boolean deleted = userRepository.deleteUser(data.getEmail()); + + if (deleted) { + // Also delete all posts by this user + List allPosts = postRepository.loadPosts(); + List postsToKeep = allPosts.stream() + .filter(post -> !post.username.equals(data.getUsername())) + .collect(Collectors.toList()); + + if (postsToKeep.size() < allPosts.size()) { + postRepository.savePosts(postsToKeep); + } + + // Remove from UI + userDataList.remove(data); + + // Update statistics + totalUsersLabel.setText("Total Users: " + userDataList.size()); + + showSuccessAlert("Success", "User and their posts deleted successfully!"); + } else { + showErrorAlert("Error", "User not found or could not be deleted."); + } + + } catch (Exception e) { + showErrorAlert("Error", "Failed to delete user: " + e.getMessage()); + } + } + + /** + * Saves changes made to a user back to XML + */ + private void saveUserChanges(User user) { + try { + List allUsers = userRepository.loadUsers(); + + // Find and update the user (using email as identifier) + for (int i = 0; i < allUsers.size(); i++) { + if (allUsers.get(i).email != null && allUsers.get(i).email.equals(user.email)) { + allUsers.set(i, user); + break; + } + } + + userRepository.saveUsers(allUsers); + } catch (Exception e) { + showErrorAlert("Error", "Failed to save changes: " + e.getMessage()); + } + } + + /** + * Shows success alert + */ + private void showSuccessAlert(String title, String message) { + Alert alert = new Alert(Alert.AlertType.INFORMATION); + alert.setTitle(title); + alert.setHeaderText(null); + alert.setContentText(message); + alert.showAndWait(); + } + + /** + * Shows error alert + */ + private void showErrorAlert(String title, String message) { + Alert alert = new Alert(Alert.AlertType.ERROR); + alert.setTitle(title); + alert.setHeaderText(null); + alert.setContentText(message); + alert.showAndWait(); + } +} diff --git a/src/main/java/com/starlight/data/Achievement.xml b/src/main/java/com/starlight/data/Achievement.xml new file mode 100644 index 0000000..66017f2 --- /dev/null +++ b/src/main/java/com/starlight/data/Achievement.xml @@ -0,0 +1,5 @@ + + First Steps + Complete your first quest. + first_steps.png + \ No newline at end of file diff --git a/src/main/java/com/starlight/data/LocalUserData.xml b/src/main/java/com/starlight/data/LocalUserData.xml new file mode 100644 index 0000000..d769247 --- /dev/null +++ b/src/main/java/com/starlight/data/LocalUserData.xml @@ -0,0 +1,19 @@ + + + + admin + admin@tabemonpal.local + Administrator + admin123 + 1990-01-01 + src/main/resources/com/starlight/images/dummy/profileman.jpg + + + user + user@tabemonpal.local + Default User + user + 1995-01-01 + src/main/resources/com/starlight/images/dummy/profiledefault.png + + diff --git a/src/main/java/com/starlight/data/PostDataDummy.xml b/src/main/java/com/starlight/data/PostDataDummy.xml new file mode 100644 index 0000000..80c92f6 --- /dev/null +++ b/src/main/java/com/starlight/data/PostDataDummy.xml @@ -0,0 +1,75 @@ + + + 91a726ad-92ee-4f48-9e5f-1d4b4f9e0421 + gw + src/main/resources/com/starlight/images/dummy/2.png + asdasdas + asdasdasdasd + /home/zan/.tabemonpal/UserData/gw/1752836360123.png + 0.0 + 2025-07-18 17:59:20 + 1 + 0 + true + + + 550e8400-e29b-41d4-a716-446655440001 + gw + src/main/resources/com/starlight/images/dummy/profileman.jpg + Delicious Spaghetti Carbonara + A classic Italian pasta dish with eggs| cheese| and pancetta. Simple yet elegant! + 400g spaghetti| 200g pancetta| 4 eggs| 100g Pecorino Romano| black pepper + 1. Cook pasta al dente| 2. Fry pancetta until crispy| 3. Mix eggs and cheese| 4. Combine everything off heat| + src/main/resources/com/starlight/images/dummy/image_1.jpg + 4.8 + 2025-07-15 14:30:00 + 127 + 23 + false + + + 550e8400-e29b-41d4-a716-446655440002 + M. Fakih Sahman + src/main/resources/com/starlight/images/dummy/profileman2.jpg + Healthy Quinoa Buddha Bowl + Nutritious and colorful bowl packed with vegetables| quinoa| and tahini dressing. + 1 cup quinoa| mixed vegetables| chickpeas| tahini| lemon juice| olive oil + 1. Cook quinoa| 2. Roast vegetables| 3. Mix tahini dressing| 4. Assemble bowl| + src/main/resources/com/starlight/images/dummy/image_2.jpg + 4.6 + 2025-07-14 09:15:00 + 90 + 15 + true + + + 550e8400-e29b-41d4-a716-446655440003 + Khairun Nissa + src/main/resources/com/starlight/images/dummy/profilewoman.jpg + Homemade Chocolate Chip Cookies + Soft| chewy chocolate chip cookies that are perfect for any occasion! + 2 cups flour| 1 cup butter| 1 cup brown sugar| 2 eggs| chocolate chips + 1. Cream butter and sugar| 2. Add eggs and flour| 3. Fold in chocolate chips| 4. Bake at 375°F| + src/main/resources/com/starlight/images/dummy/image_3.jpg + 4.9 + 2025-07-13 16:45:00 + 203 + 31 + false + + + 550e8400-e29b-41d4-a716-446655440004 + John Doe + src/main/resources/com/starlight/images/dummy/profileman3.jpg + Grilled Salmon with Lemon + Fresh salmon grilled to perfection with a zesty lemon marinade. + 4 salmon fillets| 2 lemons| olive oil| garlic| herbs + 1. Marinate salmon in lemon and herbs| 2. Preheat grill| 3. Grill for 6-8 minutes per side| + src/main/resources/com/starlight/images/dummy/image_4.png + 4.7 + 2025-07-12 18:20:00 + 156 + 19 + false + + diff --git a/src/main/java/com/starlight/data/UserDataDummy.xml b/src/main/java/com/starlight/data/UserDataDummy.xml new file mode 100644 index 0000000..20cc7c4 --- /dev/null +++ b/src/main/java/com/starlight/data/UserDataDummy.xml @@ -0,0 +1,30 @@ + + + gw + gw@example.com + GW + password123 + src/main/resources/com/starlight/images/dummy/profileman.jpg + + + M. Fakih Sahman + fakih@example.com + M. Fakih Sahman + password123 + src/main/resources/com/starlight/images/dummy/profileman2.jpg + + + Khairun Nissa + khairun@example.com + Khairun Nissa + password123 + src/main/resources/com/starlight/images/dummy/profilewoman.jpg + + + John Doe + john@example.com + John Doe + password123 + src/main/resources/com/starlight/images/dummy/profileman3.jpg + + diff --git a/src/main/java/com/starlight/model/Achievement.java b/src/main/java/com/starlight/model/Achievement.java new file mode 100644 index 0000000..e3facf7 --- /dev/null +++ b/src/main/java/com/starlight/model/Achievement.java @@ -0,0 +1,10 @@ +package com.starlight.model; + +/** + * Simple data model representing a Achievement. + */ +public class Achievement { + public String title; + public String description; + public String image; +} diff --git a/src/main/java/com/starlight/model/ChatHistory.java b/src/main/java/com/starlight/model/ChatHistory.java new file mode 100644 index 0000000..87836f5 --- /dev/null +++ b/src/main/java/com/starlight/model/ChatHistory.java @@ -0,0 +1,123 @@ +package com.starlight.model; + +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.List; + +/** + * Data model representing a complete chat history session. + */ +public class ChatHistory { + /** List of chat messages in chronological order. */ + public List messages; + + /** Session start timestamp. */ + public String sessionStart; + + /** Session end timestamp. */ + public String sessionEnd; + + /** Username of the user who participated in this session. */ + public String username; + + /** Session ID for unique identification. */ + public String sessionId; + + /** + * Default constructor for XML serialization. + */ + public ChatHistory() { + this.messages = new ArrayList<>(); + } + + /** + * Constructor for creating a new chat history session. + * + * @param username The username of the participant + */ + public ChatHistory(String username) { + this(); + this.username = username; + this.sessionStart = LocalDateTime.now().format(DateTimeFormatter.ISO_LOCAL_DATE_TIME); + this.sessionId = generateSessionId(); + } + + /** + * Adds a message to the chat history. + * + * @param message The chat message to add + */ + public void addMessage(ChatMessage message) { + if (messages == null) { + messages = new ArrayList<>(); + } + messages.add(message); + } + + /** + * Ends the current session by setting the end timestamp. + */ + public void endSession() { + this.sessionEnd = LocalDateTime.now().format(DateTimeFormatter.ISO_LOCAL_DATE_TIME); + } + + /** + * Generates a unique session ID based on timestamp and username. + * + * @return Unique session ID + */ + private String generateSessionId() { + String timestamp = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMdd_HHmmss")); + return String.format("%s_%s", username != null ? username : "anonymous", timestamp); + } + + /** + * Gets the session duration in a readable format. + * + * @return Duration string or null if session is still active + */ + public String getSessionDuration() { + if (sessionStart == null || sessionEnd == null) { + return null; + } + + try { + LocalDateTime start = LocalDateTime.parse(sessionStart, DateTimeFormatter.ISO_LOCAL_DATE_TIME); + LocalDateTime end = LocalDateTime.parse(sessionEnd, DateTimeFormatter.ISO_LOCAL_DATE_TIME); + + long minutes = java.time.Duration.between(start, end).toMinutes(); + if (minutes < 1) { + return "< 1 minute"; + } else if (minutes < 60) { + return minutes + " minutes"; + } else { + long hours = minutes / 60; + long remainingMinutes = minutes % 60; + return hours + "h " + remainingMinutes + "m"; + } + } catch (Exception e) { + return "Unknown"; + } + } + + /** + * Gets a summary of the chat session. + * + * @return Summary string + */ + public String getSessionSummary() { + int messageCount = messages != null ? messages.size() : 0; + String startTime = sessionStart != null ? + LocalDateTime.parse(sessionStart, DateTimeFormatter.ISO_LOCAL_DATE_TIME) + .format(DateTimeFormatter.ofPattern("MMM dd, yyyy HH:mm")) : "Unknown"; + + return String.format("Session: %s - %d messages - Started: %s", + sessionId, messageCount, startTime); + } + + @Override + public String toString() { + return getSessionSummary(); + } +} diff --git a/src/main/java/com/starlight/model/ChatMessage.java b/src/main/java/com/starlight/model/ChatMessage.java new file mode 100644 index 0000000..de658d6 --- /dev/null +++ b/src/main/java/com/starlight/model/ChatMessage.java @@ -0,0 +1,63 @@ +package com.starlight.model; + +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; + +/** + * Data model representing a chat message in the conversation history. + */ +public class ChatMessage { + /** The message content. */ + public String content; + + /** True if this message is from the user, false if from the AI assistant. */ + public boolean isUser; + + /** Timestamp when the message was created. */ + public String timestamp; + + /** The user who sent the message (for user messages). */ + public String username; + + /** + * Default constructor for XML serialization. + */ + public ChatMessage() { + } + + /** + * Constructor for creating a new chat message. + * + * @param content The message content + * @param isUser True if this is a user message, false for AI message + * @param username The username of the sender (for user messages) + */ + public ChatMessage(String content, boolean isUser, String username) { + this.content = content; + this.isUser = isUser; + this.username = isUser ? username : "TabemonPal AI"; + this.timestamp = LocalDateTime.now().format(DateTimeFormatter.ISO_LOCAL_DATE_TIME); + } + + /** + * Gets a formatted timestamp for display. + * + * @return Formatted timestamp string + */ + public String getFormattedTimestamp() { + try { + LocalDateTime dateTime = LocalDateTime.parse(timestamp, DateTimeFormatter.ISO_LOCAL_DATE_TIME); + return dateTime.format(DateTimeFormatter.ofPattern("MMM dd, yyyy HH:mm")); + } catch (Exception e) { + return timestamp; // Return original if parsing fails + } + } + + @Override + public String toString() { + return String.format("[%s] %s: %s", + getFormattedTimestamp(), + isUser ? username : "TabemonPal AI", + content.substring(0, Math.min(50, content.length())) + (content.length() > 50 ? "..." : "")); + } +} diff --git a/src/main/java/com/starlight/model/Nutrition.java b/src/main/java/com/starlight/model/Nutrition.java new file mode 100644 index 0000000..c2ea033 --- /dev/null +++ b/src/main/java/com/starlight/model/Nutrition.java @@ -0,0 +1,231 @@ +package com.starlight.model; + +import java.util.ArrayList; +import java.util.List; + +/** + * Model for nutrition facts data from AI analysis. + */ +public class Nutrition { + public List ingredient = new ArrayList<>(); + /** Recipe health verdict: Healthy, Moderate, Unhealthy, Junk Food, or Unknown */ + public String verdict = "Unknown"; + + /** + * Individual ingredient nutrition information. + */ + public static class NutritionIngredient { + public String name; + public String amount; + public Calories calories = new Calories(); + public Protein protein = new Protein(); + public Fat fat = new Fat(); + public Carbohydrates carbohydrates = new Carbohydrates(); + public Fiber fiber = new Fiber(); + public Sugar sugar = new Sugar(); + public Salt salt = new Salt(); + } + + /** + * Calories information with unit. + */ + public static class Calories { + public String unit = "kcal"; + public String value = "0"; + + public Calories() {} + + public Calories(String value) { + this.value = value; + } + } + + /** + * Protein information with unit. + */ + public static class Protein { + public String unit = "g"; + public String value = "0"; + + public Protein() {} + + public Protein(String value) { + this.value = value; + } + } + + /** + * Fat information with unit. + */ + public static class Fat { + public String unit = "g"; + public String value = "0"; + + public Fat() {} + + public Fat(String value) { + this.value = value; + } + } + + /** + * Carbohydrates information with unit. + */ + public static class Carbohydrates { + public String unit = "g"; + public String value = "0"; + + public Carbohydrates() {} + + public Carbohydrates(String value) { + this.value = value; + } + } + + /** + * Fiber information with unit. + */ + public static class Fiber { + public String unit = "g"; + public String value = "0"; + + public Fiber() {} + + public Fiber(String value) { + this.value = value; + } + } + + /** + * Sugar information with unit. + */ + public static class Sugar { + public String unit = "g"; + public String value = "0"; + + public Sugar() {} + + public Sugar(String value) { + this.value = value; + } + } + + /** + * Salt information with unit. + */ + public static class Salt { + public String unit = "mg"; + public String value = "0"; + + public Salt() {} + + public Salt(String value) { + this.value = value; + } + } + + /** + * Gets total calories from all ingredients. + */ + public double getTotalCalories() { + return ingredient.stream() + .mapToDouble(i -> { + try { + return Double.parseDouble(i.calories.value); + } catch (NumberFormatException e) { + return 0.0; + } + }) + .sum(); + } + + /** + * Gets total protein from all ingredients. + */ + public double getTotalProtein() { + return ingredient.stream() + .mapToDouble(i -> { + try { + return Double.parseDouble(i.protein.value); + } catch (NumberFormatException e) { + return 0.0; + } + }) + .sum(); + } + + /** + * Gets total fat from all ingredients. + */ + public double getTotalFat() { + return ingredient.stream() + .mapToDouble(i -> { + try { + return Double.parseDouble(i.fat.value); + } catch (NumberFormatException e) { + return 0.0; + } + }) + .sum(); + } + + /** + * Gets total carbohydrates from all ingredients. + */ + public double getTotalCarbohydrates() { + return ingredient.stream() + .mapToDouble(i -> { + try { + return Double.parseDouble(i.carbohydrates.value); + } catch (NumberFormatException e) { + return 0.0; + } + }) + .sum(); + } + + /** + * Gets total fiber from all ingredients. + */ + public double getTotalFiber() { + return ingredient.stream() + .mapToDouble(i -> { + try { + return Double.parseDouble(i.fiber.value); + } catch (NumberFormatException e) { + return 0.0; + } + }) + .sum(); + } + + /** + * Gets total sugar from all ingredients. + */ + public double getTotalSugar() { + return ingredient.stream() + .mapToDouble(i -> { + try { + return Double.parseDouble(i.sugar.value); + } catch (NumberFormatException e) { + return 0.0; + } + }) + .sum(); + } + + /** + * Gets total salt from all ingredients. + */ + public double getTotalSalt() { + return ingredient.stream() + .mapToDouble(i -> { + try { + return Double.parseDouble(i.salt.value); + } catch (NumberFormatException e) { + return 0.0; + } + }) + .sum(); + } +} diff --git a/src/main/java/com/starlight/models/Post.java b/src/main/java/com/starlight/model/Post.java similarity index 75% rename from src/main/java/com/starlight/models/Post.java rename to src/main/java/com/starlight/model/Post.java index 2bb5979..947781d 100644 --- a/src/main/java/com/starlight/models/Post.java +++ b/src/main/java/com/starlight/model/Post.java @@ -1,4 +1,4 @@ -package com.starlight.models; +package com.starlight.model; /** * Simple data model representing a community post. @@ -26,4 +26,10 @@ public class Post { public String uploadtime; /** Like count. */ public String likecount; + /** Comment count. */ + public String commentcount; + /** Whether the current user has liked this post. */ + public String isLiked; + /** Nutrition facts data from AI analysis. */ + public Nutrition nutrition; } diff --git a/src/main/java/com/starlight/model/User.java b/src/main/java/com/starlight/model/User.java new file mode 100644 index 0000000..77d1dde --- /dev/null +++ b/src/main/java/com/starlight/model/User.java @@ -0,0 +1,32 @@ +package com.starlight.model; + +/** + * Data model representing a user account. + */ +public class User { + /** Chosen username. */ + public String username; + /** Email address. */ + public String email; + /** Full display name. */ + public String fullname; + /** Password (plain text for simplicity). */ + public String password; + /** Birthday in ISO format. */ + public String birthDay; + /** Path to the profile picture. */ + public String profilepicture; + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + User user = (User) o; + return java.util.Objects.equals(email, user.email); + } + + @Override + public int hashCode() { + return java.util.Objects.hash(email); + } +} diff --git a/src/main/java/com/starlight/models/PostData.xml b/src/main/java/com/starlight/models/PostData.xml deleted file mode 100644 index 775d639..0000000 --- a/src/main/java/com/starlight/models/PostData.xml +++ /dev/null @@ -1,16 +0,0 @@ - - - Khairun Nissa - Veggie Salad - Fresh vegetable salad. - - lettuce -- tomato -- cucumber - Combine all veggies. -Serve chilled. - src/main/resources/com/starlight/images/dummy/image_2.jpg - 4.8 - 2023-11-23 11:00 - 8 - - \ No newline at end of file diff --git a/src/main/java/com/starlight/models/PostDataDummy.xml b/src/main/java/com/starlight/models/PostDataDummy.xml deleted file mode 100644 index 9d60279..0000000 --- a/src/main/java/com/starlight/models/PostDataDummy.xml +++ /dev/null @@ -1,41 +0,0 @@ - - - M. Fakih Sahman - Tasty Pancakes - Fluffy pancakes with syrup. - - 1 cup flour -- 2 eggs -- 1/2 cup milk - Mix ingredients. -Cook on skillet. - src/main/resources/com/starlight/images/dummy/recent_1.png - 4.5 - 2023-11-22 10:00 - 12 - - - Khairun Nissa - Veggie Salad - Fresh vegetable salad. - - lettuce -- tomato -- cucumber - Combine all veggies. -Serve chilled. - src/main/resources/com/starlight/images/dummy/image_2.jpg - 4.8 - 2023-11-23 11:00 - 8 - - - gw - qwert - asdfg - zxcvb - 123344 - /home/zan/Documents/main_folder/pfp/52073144.jpg - 0 - 2025-06-22 10:09 - 0 - - \ No newline at end of file diff --git a/src/main/java/com/starlight/models/User.java b/src/main/java/com/starlight/models/User.java deleted file mode 100644 index 24287dc..0000000 --- a/src/main/java/com/starlight/models/User.java +++ /dev/null @@ -1,17 +0,0 @@ -package com.starlight.models; - -/** - * Data model representing a user account. - */ -public class User { - /** Chosen username. */ - public String username; - /** Email address. */ - public String email; - /** Full display name. */ - public String fullname; - /** Password (plain text for simplicity). */ - public String password; - /** Birthday in ISO format. */ - public String birthDay; -} diff --git a/src/main/java/com/starlight/models/UserData.xml b/src/main/java/com/starlight/models/UserData.xml deleted file mode 100644 index ef5d8d1..0000000 --- a/src/main/java/com/starlight/models/UserData.xml +++ /dev/null @@ -1,67 +0,0 @@ - - - M. Fakih Sahman - fakih@example.com - pancake - 1990-01-01 - - - Khairun Nissa - nissa@example.com - salad - 1992-05-12 - - - gw - gw@example.com - qwerty - 1998-07-30 - - - M. Fakih Sahman - fakih@example.com - pancake - 1990-01-01 - - - Khairun Nissa - nissa@example.com - salad - 1992-05-12 - - - gw - gw@example.com - qwerty - 1998-07-30 - - - M. Fakih Sahman - fakih@example.com - pancake - 1990-01-01 - - - Khairun Nissa - nissa@example.com - salad - 1992-05-12 - - - gw - gw@example.com - qwerty - 1998-07-30 - - - fumo - fumo@example.com - fumo - 1999-09-09 - - - fumbo - fumbo@fumo.com - 123456 - - \ No newline at end of file diff --git a/src/main/java/com/starlight/models/UserDataDummy.xml b/src/main/java/com/starlight/models/UserDataDummy.xml deleted file mode 100644 index 88887fd..0000000 --- a/src/main/java/com/starlight/models/UserDataDummy.xml +++ /dev/null @@ -1,26 +0,0 @@ - - - M. Fakih Sahman - pancake - fakih@example.com - 1990-01-01 - - - Khairun Nissa - salad - nissa@example.com - 1992-05-12 - - - gw - qwerty - gw@example.com - 1998-07-30 - - - fumo - fumo - fumo@example.com - 1999-09-09 - - diff --git a/src/main/java/com/starlight/models/PostDataRepository.java b/src/main/java/com/starlight/repository/AchievementDataRepository.java similarity index 90% rename from src/main/java/com/starlight/models/PostDataRepository.java rename to src/main/java/com/starlight/repository/AchievementDataRepository.java index 7856e98..f114c1a 100644 --- a/src/main/java/com/starlight/models/PostDataRepository.java +++ b/src/main/java/com/starlight/repository/AchievementDataRepository.java @@ -1,19 +1,18 @@ -package com.starlight.models; +package com.starlight.repository; import java.io.File; import java.io.FileInputStream; import java.io.FileOutputStream; import java.util.ArrayList; import java.util.List; + +import com.starlight.model.Post; import com.thoughtworks.xstream.XStream; import com.thoughtworks.xstream.io.xml.DomDriver; -/** - * Repository for loading and saving {@link Post} objects to an XML file. - */ -public class PostDataRepository { - private static final String DEFAULT_XML_PATH = "src/main/java/com/starlight/models/PostData.xml"; - private static final String DUMMY_XML_PATH = "src/main/java/com/starlight/models/PostDataDummy.xml"; +public class AchievementDataRepository { + private static final String DEFAULT_XML_PATH = "src/main/java/com/starlight/model/PostData.xml"; + private static final String DUMMY_XML_PATH = "src/main/java/com/starlight/model/PostDataDummy.xml"; private final String xmlPath; private final XStream xstream; @@ -21,17 +20,17 @@ public class PostDataRepository { /** * Creates a repository using the default XML file location. */ - public PostDataRepository() { + public AchievementDataRepository() { this(DEFAULT_XML_PATH); } /** * Creates a repository storing data at the specified path. */ - public PostDataRepository(String xmlPath) { + public AchievementDataRepository(String xmlPath) { this.xmlPath = xmlPath; xstream = new XStream(new DomDriver()); - xstream.allowTypesByWildcard(new String[] {"com.starlight.models.*", "java.util.*"}); + xstream.allowTypesByWildcard(new String[] {"com.starlight.model.*", "java.util.*"}); xstream.alias("posts", List.class); xstream.alias("post", Post.class); } diff --git a/src/main/java/com/starlight/repository/LocalUserDataRepository.java b/src/main/java/com/starlight/repository/LocalUserDataRepository.java new file mode 100644 index 0000000..bd95ce4 --- /dev/null +++ b/src/main/java/com/starlight/repository/LocalUserDataRepository.java @@ -0,0 +1,191 @@ +package com.starlight.repository; + +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.List; +import java.util.logging.Logger; +import java.util.logging.Level; + +import com.starlight.model.User; +import com.thoughtworks.xstream.XStream; +import com.thoughtworks.xstream.io.xml.DomDriver; + +/** + * Repository for managing local user data from LocalUserData.xml. + * This provides admin and user accounts that are always available. + */ +public class LocalUserDataRepository { + private static final Logger logger = Logger.getLogger(LocalUserDataRepository.class.getName()); + private static final String LOCAL_XML_PATH = "src/main/java/com/starlight/data/LocalUserData.xml"; + + private final XStream xstream; + private List cachedUsers; + + /** + * Creates a new LocalUserDataRepository instance. + */ + public LocalUserDataRepository() { + xstream = new XStream(new DomDriver()); + xstream.allowTypesByWildcard(new String[] {"com.starlight.model.*", "java.util.*"}); + xstream.alias("users", List.class); + xstream.alias("user", User.class); + loadUsers(); + } + + /** + * Loads users from the local XML file. + * @return List of local users (admin and user) + */ + @SuppressWarnings("unchecked") + public List loadUsers() { + if (cachedUsers != null) { + return new ArrayList<>(cachedUsers); + } + + try { + File xmlFile = new File(LOCAL_XML_PATH); + if (!xmlFile.exists()) { + // Try to load from resources if file doesn't exist in src + InputStream resourceStream = getClass().getResourceAsStream("/com/starlight/model/LocalUserData.xml"); + if (resourceStream == null) { + logger.warning("LocalUserData.xml not found in resources, creating default users"); + return createDefaultUsers(); + } + + try (InputStream is = resourceStream) { + Object obj = xstream.fromXML(is); + if (obj instanceof List) { + cachedUsers = (List) obj; + return new ArrayList<>(cachedUsers); + } + } + } else { + try (FileInputStream fis = new FileInputStream(xmlFile)) { + Object obj = xstream.fromXML(fis); + if (obj instanceof List) { + cachedUsers = (List) obj; + return new ArrayList<>(cachedUsers); + } + } + } + } catch (Exception e) { + logger.log(Level.WARNING, "Failed to load LocalUserData.xml, creating default users", e); + } + + return createDefaultUsers(); + } + + /** + * Creates default admin and user accounts if XML loading fails. + */ + private List createDefaultUsers() { + List defaultUsers = new ArrayList<>(); + + // Create admin user + User admin = new User(); + admin.username = "admin"; + admin.email = "admin@tabemonpal.local"; + admin.fullname = "Administrator"; + admin.password = "admin123"; + admin.birthDay = "1990-01-01"; + admin.profilepicture = "src/main/resources/com/starlight/images/dummy/profileman.jpg"; + defaultUsers.add(admin); + + // Create default user + User user = new User(); + user.username = "user"; + user.email = "user@tabemonpal.local"; + user.fullname = "Default User"; + user.password = "user123"; + user.birthDay = "1995-01-01"; + user.profilepicture = "src/main/resources/com/starlight/images/dummy/profiledefault.png"; + defaultUsers.add(user); + + cachedUsers = defaultUsers; + return new ArrayList<>(defaultUsers); + } + + /** + * Saves users to the local XML file. + * @param users List of users to save + */ + public void saveUsers(List users) { + try { + File xmlFile = new File(LOCAL_XML_PATH); + xmlFile.getParentFile().mkdirs(); + + try (FileOutputStream fos = new FileOutputStream(xmlFile)) { + xstream.toXML(users, fos); + cachedUsers = new ArrayList<>(users); + logger.info("Successfully saved " + users.size() + " users to LocalUserData.xml"); + } + } catch (Exception e) { + logger.log(Level.SEVERE, "Failed to save users to LocalUserData.xml", e); + } + } + + /** + * Finds a user by username or email. + * @param usernameOrEmail Username or email to search for + * @return User if found, null otherwise + */ + public User findUser(String usernameOrEmail) { + List users = loadUsers(); + return users.stream() + .filter(u -> (u.username != null && u.username.equals(usernameOrEmail)) || + (u.email != null && u.email.equals(usernameOrEmail))) + .findFirst() + .orElse(null); + } + + /** + * Validates user credentials. + * @param usernameOrEmail Username or email + * @param password Password + * @return User if credentials are valid, null otherwise + */ + public User validateCredentials(String usernameOrEmail, String password) { + List users = loadUsers(); + return users.stream() + .filter(u -> ((u.username != null && u.username.equals(usernameOrEmail)) || + (u.email != null && u.email.equals(usernameOrEmail))) && + u.password != null && u.password.equals(password)) + .findFirst() + .orElse(null); + } + + /** + * Checks if a user is an admin. + * @param user User to check + * @return true if user is admin, false otherwise + */ + public boolean isAdmin(User user) { + return user != null && user.username != null && user.username.toLowerCase().equals("admin"); + } + + /** + * Gets the admin user. + * @return Admin user or null if not found + */ + public User getAdminUser() { + return findUser("admin"); + } + + /** + * Gets the default user. + * @return Default user or null if not found + */ + public User getDefaultUser() { + return findUser("user"); + } + + /** + * Resets cached users to force reload from file. + */ + public void clearCache() { + cachedUsers = null; + } +} diff --git a/src/main/java/com/starlight/repository/PostDataRepository.java b/src/main/java/com/starlight/repository/PostDataRepository.java new file mode 100644 index 0000000..7092daa --- /dev/null +++ b/src/main/java/com/starlight/repository/PostDataRepository.java @@ -0,0 +1,163 @@ +package com.starlight.repository; + +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.util.ArrayList; +import java.util.List; +import com.thoughtworks.xstream.XStream; +import com.thoughtworks.xstream.io.xml.DomDriver; +import com.starlight.model.Post; +import com.starlight.util.FileSystemManager; + +/** + * Repository for loading and saving {@link Post} objects to an XML file. + */ +public class PostDataRepository { + private static final String DEFAULT_XML_PATH = FileSystemManager.getDatabaseDirectory() + File.separator + "PostData.xml"; + private static final String DUMMY_XML_PATH = "src/main/java/com/starlight/data/PostDataDummy.xml"; + + private final String xmlPath; + private final XStream xstream; + + /** + * Creates a repository using the default XML file location. + */ + public PostDataRepository() { + this(DEFAULT_XML_PATH); + } + + /** + * Creates a repository storing data at the specified path. + */ + public PostDataRepository(String xmlPath) { + this.xmlPath = xmlPath; + File parent = new File(xmlPath).getParentFile(); + if (parent != null) { + parent.mkdirs(); + } + xstream = new XStream(new DomDriver()); + xstream.allowTypesByWildcard(new String[] {"com.starlight.model.*", "java.util.*"}); + xstream.alias("posts", List.class); + xstream.alias("post", Post.class); + xstream.alias("nutrition", com.starlight.model.Nutrition.class); + xstream.alias("ingredient", com.starlight.model.Nutrition.NutritionIngredient.class); + xstream.alias("calories", com.starlight.model.Nutrition.Calories.class); + xstream.alias("protein", com.starlight.model.Nutrition.Protein.class); + xstream.alias("fat", com.starlight.model.Nutrition.Fat.class); + xstream.alias("carbohydrates", com.starlight.model.Nutrition.Carbohydrates.class); + xstream.alias("fiber", com.starlight.model.Nutrition.Fiber.class); + xstream.alias("sugar", com.starlight.model.Nutrition.Sugar.class); + xstream.alias("salt", com.starlight.model.Nutrition.Salt.class); + } + + /** + * Loads posts from the default XML file. + */ + public List loadPosts() { + return loadPosts(false, true); + } + + /** + * @param useDummy If true, loads from dummy file. If false, loads from main file. + * @return The list of posts. + */ + public List loadPosts(boolean useDummy) { + return loadPosts(useDummy, false); + } + + @SuppressWarnings("unchecked") + private List loadPosts(boolean useDummy, boolean fallbackToDummyIfMissing) { + File xmlFile = new File(useDummy ? DUMMY_XML_PATH : xmlPath); + if (!xmlFile.exists() || xmlFile.length() == 0) { + if (!useDummy && fallbackToDummyIfMissing) { + xmlFile = new File(DUMMY_XML_PATH); + if (!xmlFile.exists()) { + return new ArrayList<>(); + } + } else { + return new ArrayList<>(); + } + } + try (FileInputStream fis = new FileInputStream(xmlFile)) { + Object obj = xstream.fromXML(fis); + if (obj instanceof List) { + List posts = (List) obj; + // Initialize missing fields for existing posts + for (Post post : posts) { + if (post.commentcount == null) { + post.commentcount = "0"; + } + if (post.isLiked == null) { + post.isLiked = "false"; + } + } + return posts; + } + } catch (Exception e) { + e.printStackTrace(); + } + return new ArrayList<>(); + } + + /** + * Copies the dummy data file to the main XML file if the main file is + * missing or empty. + */ + public void ensureDummyData() { + File xmlFile = new File(xmlPath); + if (!xmlFile.exists() || xmlFile.length() == 0) { + File dummy = new File(DUMMY_XML_PATH); + if (dummy.exists()) { + try (FileInputStream fis = new FileInputStream(dummy); + FileOutputStream fos = new FileOutputStream(xmlFile)) { + fos.write(fis.readAllBytes()); + } catch (Exception e) { + e.printStackTrace(); + } + } + } + } + + /** + * Saves the given list of posts to disk. + */ + public void savePosts(List posts) { + try (FileOutputStream fos = new FileOutputStream(xmlPath)) { + // Ensure all posts have the required fields before saving + for (Post post : posts) { + if (post.commentcount == null) { + post.commentcount = "0"; + } + if (post.isLiked == null) { + post.isLiked = "false"; + } + } + xstream.toXML(posts, fos); + } catch (Exception e) { + throw new RuntimeException("Failed to save posts to file: " + xmlPath + ". Error: " + e.getMessage(), e); + } + } + + /** + * Deletes a post by UUID and saves the updated post list + * @param uuid the UUID of the post to delete + * @return true if post was found and deleted, false otherwise + */ + public boolean deletePost(String uuid) { + List posts = loadPosts(); + int initialSize = posts.size(); + + // Remove the post with the specified UUID + posts.removeIf(post -> post.uuid != null && post.uuid.equals(uuid)); + + // Check if any posts were removed + if (posts.size() < initialSize) { + // Save the updated list + savePosts(posts); + return true; + } + + return false; + } +} diff --git a/src/main/java/com/starlight/models/UserDataRepository.java b/src/main/java/com/starlight/repository/UserDataRepository.java similarity index 83% rename from src/main/java/com/starlight/models/UserDataRepository.java rename to src/main/java/com/starlight/repository/UserDataRepository.java index c4c68db..8c88ca7 100644 --- a/src/main/java/com/starlight/models/UserDataRepository.java +++ b/src/main/java/com/starlight/repository/UserDataRepository.java @@ -1,4 +1,4 @@ -package com.starlight.models; +package com.starlight.repository; import java.io.File; import java.io.FileInputStream; @@ -8,13 +8,15 @@ import com.thoughtworks.xstream.XStream; import com.thoughtworks.xstream.io.xml.DomDriver; +import com.starlight.model.User; +import com.starlight.util.FileSystemManager; /** * Repository for persisting {@link User} objects to an XML file. */ public class UserDataRepository { - private static final String DEFAULT_XML_PATH = "src/main/java/com/starlight/models/UserData.xml"; - private static final String DUMMY_XML_PATH = "src/main/java/com/starlight/models/UserDataDummy.xml"; + private static final String DEFAULT_XML_PATH = FileSystemManager.getDatabaseDirectory() + File.separator + "UserData.xml"; + private static final String DUMMY_XML_PATH = "src/main/java/com/starlight/data/UserDataDummy.xml"; private final String xmlPath; private final XStream xstream; @@ -31,8 +33,12 @@ public UserDataRepository() { */ public UserDataRepository(String xmlPath) { this.xmlPath = xmlPath; + File parent = new File(xmlPath).getParentFile(); + if (parent != null) { + parent.mkdirs(); + } xstream = new XStream(new DomDriver()); - xstream.allowTypesByWildcard(new String[] {"com.starlight.models.*", "java.util.*"}); + xstream.allowTypesByWildcard(new String[] {"com.starlight.model.*", "java.util.*"}); xstream.alias("users", List.class); xstream.alias("user", User.class); } @@ -119,8 +125,17 @@ public void ensureDummyData() { * Saves the provided list of users. */ public void saveUsers(List users) { + // Prevent duplicate users before saving + List distinctUsers = users.stream() + .distinct() + .collect(java.util.stream.Collectors.toCollection(java.util.ArrayList::new)); + try (FileOutputStream fos = new FileOutputStream(xmlPath)) { - xstream.toXML(users, fos); + if (distinctUsers.isEmpty()) { + fos.write("".getBytes(java.nio.charset.StandardCharsets.UTF_8)); + } else { + xstream.toXML(distinctUsers, fos); + } } catch (Exception e) { throw new RuntimeException("Failed to save users", e); } diff --git a/src/main/java/com/starlight/util/ChatHistoryManager.java b/src/main/java/com/starlight/util/ChatHistoryManager.java new file mode 100644 index 0000000..db296a3 --- /dev/null +++ b/src/main/java/com/starlight/util/ChatHistoryManager.java @@ -0,0 +1,236 @@ +package com.starlight.util; + +import java.io.FileOutputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.logging.Level; +import java.util.logging.Logger; + +import com.starlight.model.ChatHistory; +import com.starlight.model.ChatMessage; +import com.thoughtworks.xstream.XStream; +import com.thoughtworks.xstream.io.xml.DomDriver; + +/** + * Utility class for managing chat history persistence. + */ +public class ChatHistoryManager { + private static final Logger logger = Logger.getLogger(ChatHistoryManager.class.getName()); + private static final String CHAT_HISTORY_DIR = "ChatHistory"; + private static final String FILE_EXTENSION = ".xml"; + + private final XStream xstream; + private ChatHistory currentSession; + + public ChatHistoryManager() { + // Initialize XStream for XML serialization + this.xstream = new XStream(new DomDriver()); + this.xstream.allowTypesByWildcard(new String[]{ + "com.starlight.model.*" + }); + this.xstream.alias("chatHistory", ChatHistory.class); + this.xstream.alias("message", ChatMessage.class); + this.xstream.aliasField("messages", ChatHistory.class, "messages"); + } + + /** + * Starts a new chat session for the given user. + * + * @param username The username of the user starting the session + */ + public void startSession(String username) { + // End current session if exists + if (currentSession != null) { + endSession(); + } + + currentSession = new ChatHistory(username); + logger.info("Started new chat session for user: " + username); + } + + /** + * Adds a message to the current session. + * + * @param content The message content + * @param isUser True if this is a user message, false for AI message + * @param username The username (for user messages) + */ + public void addMessage(String content, boolean isUser, String username) { + if (currentSession == null) { + logger.warning("No active session. Starting new session for user: " + username); + startSession(username); + } + + ChatMessage message = new ChatMessage(content, isUser, username); + currentSession.addMessage(message); + + // Auto-save after each message (optional, can be changed to periodic saves) + saveCurrentSession(); + } + + /** + * Ends the current session and saves it to disk. + */ + public void endSession() { + if (currentSession == null) { + return; + } + + currentSession.endSession(); + saveCurrentSession(); + + logger.info("Ended chat session: " + currentSession.getSessionSummary()); + currentSession = null; + } + + /** + * Saves the current session to disk. + */ + private void saveCurrentSession() { + if (currentSession == null) { + return; + } + + try { + String userHome = System.getProperty("user.home"); + Path historyDir = Paths.get(userHome, ".tabemonpal", "Database", CHAT_HISTORY_DIR); + + // Create directory if it doesn't exist + Files.createDirectories(historyDir); + + // Create filename based on session ID + String filename = currentSession.sessionId + FILE_EXTENSION; + Path historyFile = historyDir.resolve(filename); + + // Save to XML file + String xml = xstream.toXML(currentSession); + try (FileOutputStream fos = new FileOutputStream(historyFile.toFile())) { + fos.write(xml.getBytes()); + } + + logger.fine("Saved chat history: " + historyFile); + + } catch (Exception e) { + logger.log(Level.SEVERE, "Failed to save chat history: " + e.getMessage(), e); + } + } + + /** + * Loads all chat history files for a specific user. + * + * @param username The username to load history for + * @return List of chat histories, sorted by date (newest first) + */ + public List loadUserHistory(String username) { + List histories = new ArrayList<>(); + + try { + String userHome = System.getProperty("user.home"); + Path historyDir = Paths.get(userHome, ".tabemonpal", "Database", CHAT_HISTORY_DIR); + + if (!Files.exists(historyDir)) { + return histories; // Empty list if directory doesn't exist + } + + // Load all XML files in the directory + Files.list(historyDir) + .filter(path -> path.toString().endsWith(FILE_EXTENSION)) + .filter(path -> username == null || path.getFileName().toString().startsWith(username + "_")) + .forEach(path -> { + try { + String xml = Files.readString(path); + ChatHistory history = (ChatHistory) xstream.fromXML(xml); + if (history != null) { + histories.add(history); + } + } catch (Exception e) { + logger.log(Level.WARNING, "Failed to load chat history file: " + path, e); + } + }); + + // Sort by session start time (newest first) + histories.sort(Comparator.comparing((ChatHistory h) -> h.sessionStart).reversed()); + + } catch (Exception e) { + logger.log(Level.WARNING, "Failed to load chat histories: " + e.getMessage(), e); + } + + return histories; + } + + /** + * Loads the most recent chat history for a user. + * + * @param username The username to load history for + * @return The most recent chat history, or null if none exists + */ + public ChatHistory loadRecentHistory(String username) { + List histories = loadUserHistory(username); + return histories.isEmpty() ? null : histories.get(0); + } + + /** + * Deletes a specific chat history file. + * + * @param sessionId The session ID to delete + * @return True if deleted successfully, false otherwise + */ + public boolean deleteHistory(String sessionId) { + try { + String userHome = System.getProperty("user.home"); + Path historyFile = Paths.get(userHome, ".tabemonpal", "Database", CHAT_HISTORY_DIR, sessionId + FILE_EXTENSION); + + if (Files.exists(historyFile)) { + Files.delete(historyFile); + logger.info("Deleted chat history: " + sessionId); + return true; + } + } catch (Exception e) { + logger.log(Level.WARNING, "Failed to delete chat history: " + sessionId, e); + } + + return false; + } + + /** + * Clears all chat history for a specific user. + * + * @param username The username to clear history for + * @return Number of files deleted + */ + public int clearUserHistory(String username) { + int deletedCount = 0; + List histories = loadUserHistory(username); + + for (ChatHistory history : histories) { + if (deleteHistory(history.sessionId)) { + deletedCount++; + } + } + + logger.info("Cleared " + deletedCount + " chat history files for user: " + username); + return deletedCount; + } + + /** + * Gets the current active session. + * + * @return Current chat history session, or null if no active session + */ + public ChatHistory getCurrentSession() { + return currentSession; + } + + /** + * Checks if there is an active session. + * + * @return True if there is an active session + */ + public boolean hasActiveSession() { + return currentSession != null; + } +} diff --git a/src/main/java/com/starlight/util/ChatHistoryUtils.java b/src/main/java/com/starlight/util/ChatHistoryUtils.java new file mode 100644 index 0000000..3101514 --- /dev/null +++ b/src/main/java/com/starlight/util/ChatHistoryUtils.java @@ -0,0 +1,205 @@ +package com.starlight.util; + +import com.starlight.model.ChatHistory; +import com.starlight.model.ChatMessage; + +import java.time.format.DateTimeFormatter; +import java.time.LocalDateTime; +import java.util.List; + +/** + * Utility class for chat history operations and display formatting. + */ +public class ChatHistoryUtils { + + /** + * Generates a text summary of all chat sessions for a user. + * + * @param username The username to generate summary for + * @return Formatted text summary of chat history + */ + public static String generateChatSummary(String username) { + ChatHistoryManager manager = new ChatHistoryManager(); + List histories = manager.loadUserHistory(username); + + if (histories.isEmpty()) { + return "No chat history found for user: " + username; + } + + StringBuilder summary = new StringBuilder(); + summary.append("Chat History Summary for ").append(username).append("\n"); + summary.append("=".repeat(50)).append("\n\n"); + + int totalMessages = 0; + for (int i = 0; i < histories.size(); i++) { + ChatHistory history = histories.get(i); + int messageCount = history.messages != null ? history.messages.size() : 0; + totalMessages += messageCount; + + summary.append("Session ").append(i + 1).append(": ").append(history.sessionId).append("\n"); + summary.append("Started: ").append(formatTimestamp(history.sessionStart)).append("\n"); + if (history.sessionEnd != null) { + summary.append("Ended: ").append(formatTimestamp(history.sessionEnd)).append("\n"); + summary.append("Duration: ").append(history.getSessionDuration()).append("\n"); + } else { + summary.append("Status: Active\n"); + } + summary.append("Messages: ").append(messageCount).append("\n"); + + // Show first message as preview + if (messageCount > 0) { + ChatMessage firstMessage = history.messages.get(0); + String preview = firstMessage.content.length() > 50 ? + firstMessage.content.substring(0, 50) + "..." : + firstMessage.content; + summary.append("Preview: \"").append(preview).append("\"\n"); + } + summary.append("\n"); + } + + summary.append("Total Sessions: ").append(histories.size()).append("\n"); + summary.append("Total Messages: ").append(totalMessages).append("\n"); + + return summary.toString(); + } + + /** + * Exports a specific chat session to a readable text format. + * + * @param sessionId The session ID to export + * @param username The username (for loading history) + * @return Formatted text of the chat session + */ + public static String exportSessionToText(String sessionId, String username) { + ChatHistoryManager manager = new ChatHistoryManager(); + List histories = manager.loadUserHistory(username); + + ChatHistory targetHistory = histories.stream() + .filter(h -> h.sessionId.equals(sessionId)) + .findFirst() + .orElse(null); + + if (targetHistory == null) { + return "Session not found: " + sessionId; + } + + StringBuilder export = new StringBuilder(); + export.append("TabemonPal Chat Export\n"); + export.append("=".repeat(30)).append("\n"); + export.append("User: ").append(targetHistory.username).append("\n"); + export.append("Session: ").append(targetHistory.sessionId).append("\n"); + export.append("Date: ").append(formatTimestamp(targetHistory.sessionStart)).append("\n"); + if (targetHistory.sessionEnd != null) { + export.append("Duration: ").append(targetHistory.getSessionDuration()).append("\n"); + } + export.append("\n").append("Conversation:\n"); + export.append("-".repeat(30)).append("\n"); + + if (targetHistory.messages != null) { + for (ChatMessage message : targetHistory.messages) { + String sender = message.isUser ? targetHistory.username : "TabemonPal AI"; + export.append("[").append(message.getFormattedTimestamp()).append("] "); + export.append(sender).append(": "); + export.append(message.content).append("\n\n"); + } + } + + return export.toString(); + } + + /** + * Gets statistics about a user's chat history. + * + * @param username The username to analyze + * @return ChatHistoryStats object with analysis data + */ + public static ChatHistoryStats getHistoryStats(String username) { + ChatHistoryManager manager = new ChatHistoryManager(); + List histories = manager.loadUserHistory(username); + + return new ChatHistoryStats(histories); + } + + /** + * Formats timestamp for display. + * + * @param timestamp ISO timestamp string + * @return Formatted timestamp + */ + private static String formatTimestamp(String timestamp) { + try { + LocalDateTime dateTime = LocalDateTime.parse(timestamp, DateTimeFormatter.ISO_LOCAL_DATE_TIME); + return dateTime.format(DateTimeFormatter.ofPattern("MMM dd, yyyy HH:mm:ss")); + } catch (Exception e) { + return timestamp; + } + } + + /** + * Simple statistics class for chat history analysis. + */ + public static class ChatHistoryStats { + public final int totalSessions; + public final int totalMessages; + public final int userMessages; + public final int aiMessages; + public final String oldestSession; + public final String newestSession; + public final double averageMessagesPerSession; + + public ChatHistoryStats(List histories) { + this.totalSessions = histories.size(); + + int totalMsgs = 0; + int userMsgs = 0; + int aiMsgs = 0; + String oldest = null; + String newest = null; + + for (ChatHistory history : histories) { + if (history.messages != null) { + totalMsgs += history.messages.size(); + for (ChatMessage msg : history.messages) { + if (msg.isUser) userMsgs++; + else aiMsgs++; + } + } + + if (newest == null || (history.sessionStart != null && + history.sessionStart.compareTo(newest) > 0)) { + newest = history.sessionStart; + } + + if (oldest == null || (history.sessionStart != null && + history.sessionStart.compareTo(oldest) < 0)) { + oldest = history.sessionStart; + } + } + + this.totalMessages = totalMsgs; + this.userMessages = userMsgs; + this.aiMessages = aiMsgs; + this.oldestSession = oldest; + this.newestSession = newest; + this.averageMessagesPerSession = totalSessions > 0 ? + (double) totalMessages / totalSessions : 0; + } + + @Override + public String toString() { + return String.format( + "Chat History Statistics:\n" + + "Total Sessions: %d\n" + + "Total Messages: %d\n" + + "User Messages: %d\n" + + "AI Messages: %d\n" + + "Average Messages/Session: %.1f\n" + + "Date Range: %s to %s", + totalSessions, totalMessages, userMessages, aiMessages, + averageMessagesPerSession, + formatTimestamp(oldestSession), + formatTimestamp(newestSession) + ); + } + } +} diff --git a/src/main/java/com/starlight/util/DoughnutChart.java b/src/main/java/com/starlight/util/DoughnutChart.java new file mode 100644 index 0000000..04794e1 --- /dev/null +++ b/src/main/java/com/starlight/util/DoughnutChart.java @@ -0,0 +1,126 @@ +package com.starlight.util; + +import javafx.application.Platform; +import javafx.collections.ObservableList; +import javafx.geometry.Bounds; +import javafx.scene.Node; +import javafx.scene.chart.PieChart; +import javafx.scene.layout.Pane; +import javafx.scene.paint.Color; +import javafx.scene.shape.Circle; + +public class DoughnutChart extends PieChart { + private final Circle innerCircle; + + // Default constructor for FXML + public DoughnutChart() { + super(); + + innerCircle = new Circle(); + + // just styled in code for demo purposes, + // use a style class instead to style via css. + innerCircle.setFill(Color.WHITESMOKE); + innerCircle.setStroke(Color.WHITE); + innerCircle.setStrokeWidth(3); + } + + public DoughnutChart(ObservableList pieData) { + super(pieData); + + innerCircle = new Circle(); + + // just styled in code for demo purposes, + // use a style class instead to style via css. + innerCircle.setFill(Color.WHITESMOKE); + innerCircle.setStroke(Color.WHITE); + innerCircle.setStrokeWidth(3); + } + + /** + * Forces a refresh of the doughnut chart to ensure the inner circle is properly displayed. + * Call this method after data changes to maintain the doughnut appearance. + */ + public void refreshDoughnut() { + // Immediate refresh + Platform.runLater(() -> { + addInnerCircleIfNotPresent(); + updateInnerCircleLayout(); + requestLayout(); + + // Secondary refresh with delay to handle timing issues + Platform.runLater(() -> { + addInnerCircleIfNotPresent(); + updateInnerCircleLayout(); + requestLayout(); + + // Final refresh to ensure everything is properly positioned + Platform.runLater(() -> { + addInnerCircleIfNotPresent(); + updateInnerCircleLayout(); + }); + }); + }); + } + + @Override + protected void layoutChartChildren(double top, double left, double contentWidth, double contentHeight) { + super.layoutChartChildren(top, left, contentWidth, contentHeight); + + addInnerCircleIfNotPresent(); + updateInnerCircleLayout(); + } + + private void addInnerCircleIfNotPresent() { + if (getData().size() > 0) { + Node pie = getData().get(0).getNode(); + if (pie != null && pie.getParent() instanceof Pane) { + Pane parent = (Pane) pie.getParent(); + + // Remove any existing inner circle first to avoid duplicates + parent.getChildren().remove(innerCircle); + + // Ensure inner circle styling is correct + innerCircle.setFill(Color.WHITESMOKE); + innerCircle.setStroke(Color.WHITE); + innerCircle.setStrokeWidth(3); + + // Add the inner circle and bring it to front + parent.getChildren().add(innerCircle); + innerCircle.toFront(); + } + } + } + + private void updateInnerCircleLayout() { + if (getData().isEmpty()) { + return; + } + + double minX = Double.MAX_VALUE, minY = Double.MAX_VALUE; + double maxX = Double.MIN_VALUE, maxY = Double.MIN_VALUE; + for (PieChart.Data data: getData()) { + Node node = data.getNode(); + if (node == null) continue; + + Bounds bounds = node.getBoundsInParent(); + if (bounds.getMinX() < minX) { + minX = bounds.getMinX(); + } + if (bounds.getMinY() < minY) { + minY = bounds.getMinY(); + } + if (bounds.getMaxX() > maxX) { + maxX = bounds.getMaxX(); + } + if (bounds.getMaxY() > maxY) { + maxY = bounds.getMaxY(); + } + } + + innerCircle.setCenterX(minX + (maxX - minX) / 2); + innerCircle.setCenterY(minY + (maxY - minY) / 2); + + innerCircle.setRadius((maxX - minX) / 4); + } +} \ No newline at end of file diff --git a/src/main/java/com/starlight/util/FXMLVerificator.java b/src/main/java/com/starlight/util/FXMLVerificator.java index 6750f84..ff38743 100644 --- a/src/main/java/com/starlight/util/FXMLVerificator.java +++ b/src/main/java/com/starlight/util/FXMLVerificator.java @@ -4,12 +4,14 @@ import java.nio.file.*; import java.util.List; import java.util.stream.Stream; +import java.util.logging.Logger; /** * Utility class for verifying and adjusting FXML files before they are loaded * by JavaFX. */ public class FXMLVerificator { + private static final Logger logger = Logger.getLogger(FXMLVerificator.class.getName()); // You can customize these private static final String EXPECTED_NAMESPACE = "http://javafx.com/javafx/19"; @@ -60,7 +62,7 @@ private static void verifyOneFile(Path fxmlPath) { // Save changes only if content was modified if (!original.equals(content)) { Files.writeString(fxmlPath, content); - System.out.println("Downgraded JavaFX version in: " + fxmlPath.getFileName()); + logger.info("Downgraded JavaFX version in: " + fxmlPath.getFileName()); } } catch (IOException e) { diff --git a/src/main/java/com/starlight/util/FileSystemManager.java b/src/main/java/com/starlight/util/FileSystemManager.java new file mode 100644 index 0000000..73790f9 --- /dev/null +++ b/src/main/java/com/starlight/util/FileSystemManager.java @@ -0,0 +1,339 @@ +package com.starlight.util; + +import java.io.File; +import java.io.IOException; +import java.net.URL; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.nio.file.StandardCopyOption; +import java.util.logging.Level; +import java.util.logging.Logger; + +/** + * Manages the application's file system operations including directory creation, + * file copying, and data migration. Provides cross-platform support for storing + * user data and application files in the user's home directory. + */ +public class FileSystemManager { + + private static final Logger LOGGER = Logger.getLogger(FileSystemManager.class.getName()); + + /** Application data directory path */ + private static final String APP_DATA_DIR = System.getProperty("user.home") + File.separator + ".tabemonpal"; + + /** User data directory for storing user images */ + private static final String USER_DATA_DIR = APP_DATA_DIR + File.separator + "UserData"; + + /** Database directory for storing XML files */ + private static final String DATABASE_DIR = APP_DATA_DIR + File.separator + "Database"; + + /** + * Initializes the application data directory structure. + * Creates .tabemonpal directory with UserData and Database subdirectories. + * Also creates .SECRET_KEY.xml file with default configuration. + * This method is cross-platform compatible and works when running as JAR. + */ + public static void initializeAppDataDirectory() { + try { + // Create main app directory + Path appDir = Paths.get(APP_DATA_DIR); + Files.createDirectories(appDir); + + // Create UserData directory for user images + Path userDataDir = Paths.get(USER_DATA_DIR); + Files.createDirectories(userDataDir); + + // Create Database directory for XML files + Path databaseDir = Paths.get(DATABASE_DIR); + Files.createDirectories(databaseDir); + + // Create .SECRET_KEY.xml file if it doesn't exist + Path secretKeyPath = databaseDir.resolve(".SECRET_KEY.xml"); + if (!Files.exists(secretKeyPath)) { + String secretKeyContent = "\n" + + "\n" + + " PASTE-YOUR-SECRET-KEY-HERE\n" + + ""; + Files.write(secretKeyPath, secretKeyContent.getBytes("UTF-8")); + LOGGER.info("Paste your secret key at: " + secretKeyPath); + } + + LOGGER.info("App data directory initialized at: " + APP_DATA_DIR); + + } catch (IOException e) { + LOGGER.log(Level.SEVERE, "Failed to initialize app data directory: " + e.getMessage(), e); + } + } + + /** + * Gets the path to the application data directory. + * @return absolute path to .tabemonpal directory + */ + public static String getAppDataDirectory() { + return APP_DATA_DIR; + } + + /** + * Gets the path to the user data directory for storing user images. + * @return absolute path to UserData directory + */ + public static String getUserDataDirectory() { + return USER_DATA_DIR; + } + + /** + * Gets the path to the database directory for storing XML files. + * @return absolute path to Database directory + */ + public static String getDatabaseDirectory() { + return DATABASE_DIR; + } + + /** + * Creates a user-specific directory for storing their images. + * @param username the username to create directory for + * @return the created user directory path + */ + public static String createUserImageDirectory(String username) { + try { + Path userDir = Paths.get(USER_DATA_DIR, username); + Files.createDirectories(userDir); + return userDir.toString(); + } catch (IOException e) { + LOGGER.log(Level.SEVERE, "Failed to create user directory for " + username + ": " + e.getMessage(), e); + return null; + } + } + + /** + * Copies a file from source to the user's image directory and returns the new path. + * @param sourceFile the source file to copy + * @param username the username whose directory to copy to + * @param newFileName optional new filename, or null to keep original + * @return the path to the copied file in the user directory + */ + public static String copyFileToUserDirectory(File sourceFile, String username, String newFileName) { + try { + // Ensure user directory exists + String userDirPath = createUserImageDirectory(username); + if (userDirPath == null) { + return null; + } + + // Determine target filename + String targetFileName = newFileName != null ? newFileName : sourceFile.getName(); + Path targetPath = Paths.get(userDirPath, targetFileName); + + // Copy file + Files.copy(sourceFile.toPath(), targetPath, StandardCopyOption.REPLACE_EXISTING); + + LOGGER.info("File copied to: " + targetPath.toString()); + return targetPath.toString(); + + } catch (IOException e) { + LOGGER.log(Level.SEVERE, "Failed to copy file to user directory: " + e.getMessage(), e); + return null; + } + } + + /** + * Copies a file from source to the user's image directory with a unique filename. + * @param sourceFile the source file to copy + * @param username the username whose directory to copy to + * @return the path to the copied file in the user directory + */ + public static String copyFileToUserDirectoryWithUniqueFilename(File sourceFile, String username) throws IOException { + try { + // Get file extension + String originalName = sourceFile.getName(); + String extension = ""; + int lastDot = originalName.lastIndexOf('.'); + if (lastDot > 0) { + extension = originalName.substring(lastDot); + } + + // Generate unique filename using timestamp and a counter to ensure uniqueness + String userDirPath = createUserImageDirectory(username); + if (userDirPath == null) { + return null; + } + + String uniqueFileName; + Path targetPath; + int counter = 0; + + do { + String timestamp = String.valueOf(System.currentTimeMillis()); + uniqueFileName = timestamp + (counter > 0 ? "_" + counter : "") + extension; + targetPath = Paths.get(userDirPath, uniqueFileName); + counter++; + } while (Files.exists(targetPath)); + + return copyFileToUserDirectory(sourceFile, username, uniqueFileName); + } catch (SecurityException e) { + LOGGER.log(Level.SEVERE, "Failed to copy file with unique filename: " + e.getMessage(), e); + return null; + } + } + + /** + * Checks if the application data directory is accessible and writable. + * @return true if the directory is accessible and writable + */ + public static boolean isAppDataDirectoryAccessible() { + try { + Path appDir = Paths.get(APP_DATA_DIR); + + // Check if directory exists and is writable + if (!Files.exists(appDir)) { + return false; + } + + if (!Files.isWritable(appDir)) { + return false; + } + + // Try creating a test file + Path testFile = appDir.resolve("test_access.tmp"); + Files.write(testFile, "test".getBytes()); + Files.deleteIfExists(testFile); + + return true; + + } catch (Exception e) { + LOGGER.log(Level.SEVERE, "App data directory is not accessible: " + e.getMessage(), e); + return false; + } + } + + /** + * Resolves an image path to an absolute path, handling various path formats + * @param imagePath The image path from XML (could be relative, absolute, or resource path) + * @return Resolved absolute path or null if not found + */ + public static Path resolveImagePath(String imagePath) { + if (imagePath == null || imagePath.trim().isEmpty()) { + return null; + } + + // Handle paths starting with "src/main/resources/" - these are project resource paths + if (imagePath.startsWith("src/main/resources/")) { + Path projectResourcesPath = Paths.get(imagePath); + if (Files.exists(projectResourcesPath)) { + return projectResourcesPath; + } + + // Try to convert to classpath resource path and resolve + String resourcePath = imagePath.substring("src/main/resources".length()); + URL resource = FileSystemManager.class.getResource(resourcePath); + if (resource != null) { + try { + return Paths.get(resource.toURI()); + } catch (Exception e) { + // Resource exists but cannot convert to path (might be in JAR) + // Return a special marker indicating it's a valid resource + return Paths.get("resource:" + resourcePath); + } + } + + LOGGER.warning("Could not resolve image path: " + imagePath); + return null; + } + + // Handle resource paths (paths starting with /) - check this before absolute paths + if (imagePath.startsWith("/") && imagePath.contains("com/starlight")) { + // Try to resolve as resource from classpath first + URL resource = FileSystemManager.class.getResource(imagePath); + if (resource != null) { + try { + return Paths.get(resource.toURI()); + } catch (Exception e) { + // Resource exists but cannot convert to path (might be in JAR) + // Return a special marker indicating it's a valid resource + return Paths.get("resource:" + imagePath); + } + } + + // If not found as resource, try relative to project src/main/resources + String resourcePath = imagePath.substring(1); + Path projectResourcesPath = Paths.get("src/main/resources").resolve(resourcePath); + if (Files.exists(projectResourcesPath)) { + return projectResourcesPath; + } + + LOGGER.warning("Could not resolve resource path: " + imagePath); + return null; + } + + // Handle absolute filesystem paths (but not resource paths) + Path absolutePath = Paths.get(imagePath); + if (absolutePath.isAbsolute()) { + if (Files.exists(absolutePath)) { + return absolutePath; + } else { + LOGGER.warning("Could not resolve absolute path: " + imagePath); + return null; + } + } + + // Handle relative paths - try different base directories + + // Try relative to user data directory + Path userDataPath = Paths.get(getUserDataDirectory()).resolve(imagePath); + if (Files.exists(userDataPath)) { + return userDataPath; + } + + // Try relative to user images directory (UserData subdirectory) + Path userImagesPath = Paths.get(getUserDataDirectory()).resolve(imagePath); + if (Files.exists(userImagesPath)) { + return userImagesPath; + } + + // Try relative to project resources + Path projectResourcesPath = Paths.get("src/main/resources").resolve(imagePath); + if (Files.exists(projectResourcesPath)) { + return projectResourcesPath; + } + + LOGGER.warning("Could not resolve image path: " + imagePath); + return null; + } + + /** + * Gets a fallback image path for when the original image cannot be found. + * @param imageType type of image (e.g., "profile", "post", "default") + * @return path to a fallback image, or null if no fallback available + */ + public static String getFallbackImagePath(String imageType) { + String fallbackPath = "src/main/resources/com/starlight/images/missing.png"; + Path resolvedPath = resolveImagePath(fallbackPath); + if (resolvedPath != null) { + return resolvedPath.toString(); + } + return null; + } + + /** + * Resolves an image path with automatic fallback to default images. + * @param imagePath the original image path + * @param imageType the type of image for fallback selection + * @return a valid image path or fallback path + */ + public static String resolveImagePathWithFallback(String imagePath, String imageType) { + Path resolvedPath = resolveImagePath(imagePath); + if (resolvedPath != null) { + return resolvedPath.toString(); + } + + String fallbackPath = getFallbackImagePath(imageType); + if (fallbackPath != null) { + LOGGER.info("Using fallback image for: " + imagePath); + return fallbackPath; + } + + LOGGER.severe("No image found for path: " + imagePath); + return null; + } +} diff --git a/src/main/java/com/starlight/util/ImageUtils.java b/src/main/java/com/starlight/util/ImageUtils.java new file mode 100644 index 0000000..fdbf2a7 --- /dev/null +++ b/src/main/java/com/starlight/util/ImageUtils.java @@ -0,0 +1,143 @@ +package com.starlight.util; + +import java.io.File; +import java.net.URL; +import java.nio.file.Path; + +import javafx.scene.image.Image; +import javafx.scene.image.ImageView; +import javafx.scene.shape.Rectangle; + +/** + * Utility class for common image operations in the application. + * Contains methods for loading and scaling images with proper fallback handling. + */ +public class ImageUtils { + + /** + * Resizes and centers the given {@link ImageView} to fit inside the specified frame size + * while preserving aspect ratio and applying rounded corners. + * This method scales the image to fill the frame, cropping it if necessary. + * + * @param imageView the ImageView to scale + * @param frameWidth the target width of the frame + * @param frameHeight the target height of the frame + * @param arcRadius the radius for rounded corners (0 for no rounding) + */ + public static void scaleToFit(ImageView imageView, double frameWidth, double frameHeight, double arcRadius) { + if (imageView.getImage() == null) return; + Image image = imageView.getImage(); + + double imageWidth = image.getWidth(); + double imageHeight = image.getHeight(); + + // Set the ImageView size to match the frame + imageView.setFitWidth(frameWidth); + imageView.setFitHeight(frameHeight); + + // Calculate aspect ratios + double imageAspect = imageWidth / imageHeight; + double frameAspect = frameWidth / frameHeight; + + double newWidth, newHeight; + double xOffset = 0, yOffset = 0; + + // Determine the cropping area + if (imageAspect > frameAspect) { + // Image is wider than the frame, so we crop the sides + newHeight = imageHeight; + newWidth = newHeight * frameAspect; + xOffset = (imageWidth - newWidth) / 2; + } else { + // Image is taller than the frame, so we crop the top and bottom + newWidth = imageWidth; + newHeight = newWidth / frameAspect; + yOffset = (imageHeight - newHeight) / 2; + } + + // Set the viewport to the centered, cropped portion of the image + imageView.setViewport(new javafx.geometry.Rectangle2D(xOffset, yOffset, newWidth, newHeight)); + + // Apply a clip to round the corners of the ImageView + if (arcRadius > 0) { + Rectangle clip = new Rectangle(frameWidth, frameHeight); + clip.setArcWidth(arcRadius); + clip.setArcHeight(arcRadius); + imageView.setClip(clip); + } + } + + /** + * Loads an image from a given path into an ImageView with proper fallback handling. + * It handles both classpath-relative and absolute file paths. + * + * @param imageView the ImageView to load the image into + * @param path the path to the image (can be classpath-relative or absolute) + * @param fallbackPath the classpath-relative path to a fallback image + */ + public static void loadImage(ImageView imageView, String path, String fallbackPath) { + // Use FileSystemManager to resolve the image path + Path resolvedPath = FileSystemManager.resolveImagePath(path); + + Image imageToLoad = null; + if (resolvedPath != null) { + // Check if it's a special resource marker + if (resolvedPath.toString().startsWith("resource:")) { + String resourcePath = resolvedPath.toString().substring("resource:".length()); + URL resource = ImageUtils.class.getResource(resourcePath); + if (resource != null) { + imageToLoad = new Image(resource.toExternalForm()); + } + } else { + // Treat as a file path + File file = resolvedPath.toFile(); + if (file.exists()) { + imageToLoad = new Image(file.toURI().toString()); + } + } + } + + // If the image failed to load, try the fallback + if (imageToLoad == null && fallbackPath != null) { + Path resolvedFallbackPath = FileSystemManager.resolveImagePath(fallbackPath); + if (resolvedFallbackPath != null) { + if (resolvedFallbackPath.toString().startsWith("resource:")) { + String resourcePath = resolvedFallbackPath.toString().substring("resource:".length()); + URL fallbackResource = ImageUtils.class.getResource(resourcePath); + if (fallbackResource != null) { + imageToLoad = new Image(fallbackResource.toExternalForm()); + } + } else { + File fallbackFile = resolvedFallbackPath.toFile(); + if (fallbackFile.exists()) { + imageToLoad = new Image(fallbackFile.toURI().toString()); + } + } + } + } + + // Final fallback - try to use FileSystemManager's fallback system + if (imageToLoad == null) { + String defaultFallback = FileSystemManager.getFallbackImagePath("default"); + if (defaultFallback != null) { + File defaultFile = new File(defaultFallback); + if (defaultFile.exists()) { + imageToLoad = new Image(defaultFile.toURI().toString()); + } + } + } + + imageView.setImage(imageToLoad); + } + + /** + * Loads an image from a given path into an ImageView with a default fallback. + * Convenience method that uses "/com/starlight/images/missing.png" as the fallback. + * + * @param imageView the ImageView to load the image into + * @param path the path to the image (can be classpath-relative or absolute) + */ + public static void loadImage(ImageView imageView, String path) { + loadImage(imageView, path, "/com/starlight/images/missing.png"); + } +} diff --git a/src/main/java/com/starlight/util/NutritionParser.java b/src/main/java/com/starlight/util/NutritionParser.java new file mode 100644 index 0000000..cfd363a --- /dev/null +++ b/src/main/java/com/starlight/util/NutritionParser.java @@ -0,0 +1,298 @@ +package com.starlight.util; + +import java.util.logging.Logger; +import java.util.logging.Level; +import java.util.regex.Pattern; + +import com.starlight.model.Nutrition; + +import java.util.regex.Matcher; + +/** + * Utility class for parsing nutrition XML data from AI responses. + * Uses regex parsing instead of XStream due to the complexity of the XML structure. + */ +public class NutritionParser { + private static final Logger logger = Logger.getLogger(NutritionParser.class.getName()); + + /** + * Parses nutrition XML from AI response and returns a Nutrition object. + * + * @param aiResponse The full AI response containing XML + * @return Parsed Nutrition object or fallback if parsing fails + */ + public Nutrition parseNutritionFromResponse(String aiResponse) { + if (aiResponse == null || aiResponse.trim().isEmpty()) { + logger.warning("AI response is empty or null"); + return createFallbackNutrition(); + } + + try { + // Extract XML from the response (AI might include extra text) + String xmlContent = extractXmlFromResponse(aiResponse); + if (xmlContent == null) { + logger.warning("No valid XML found in AI response"); + return createFallbackNutrition(); + } + + // Parse the XML using regex + Nutrition nutrition = parseXmlWithRegex(xmlContent); + + return nutrition != null ? nutrition : createFallbackNutrition(); + + } catch (Exception e) { + logger.log(Level.WARNING, "Failed to parse nutrition XML: " + e.getMessage(), e); + return createFallbackNutrition(); + } + } + + /** + * Extracts XML content from AI response. + */ + private String extractXmlFromResponse(String response) { + // Look for nutrition XML tags (with or without attributes) + int startIndex = response.indexOf(""); + + if (startIndex != -1 && endIndex != -1 && endIndex > startIndex) { + return response.substring(startIndex, endIndex + "".length()); + } + + // If no proper XML found, try to find any XML-like content + startIndex = response.indexOf("<"); + endIndex = response.lastIndexOf(">"); + + if (startIndex != -1 && endIndex != -1 && endIndex > startIndex) { + String potentialXml = response.substring(startIndex, endIndex + 1); + if (potentialXml.contains("")) { + return potentialXml; + } + } + + return null; + } + + /** + * Parses nutrition XML using regex patterns. + */ + private Nutrition parseXmlWithRegex(String xmlContent) { + Nutrition nutrition = new Nutrition(); + + // Parse verdict attribute from nutrition tag + Pattern verdictPattern = Pattern.compile("(.*?)", + Pattern.DOTALL + ); + + Matcher ingredientMatcher = ingredientPattern.matcher(xmlContent); + + while (ingredientMatcher.find()) { + String name = ingredientMatcher.group(1); + String amount = ingredientMatcher.group(2); + String content = ingredientMatcher.group(3); + + Nutrition.NutritionIngredient ingredient = new Nutrition.NutritionIngredient(); + ingredient.name = name; + ingredient.amount = amount; + + // Parse nutrition values + ingredient.calories = parseCaloriesValue(content, "calories", "kcal"); + ingredient.protein = parseProteinValue(content, "protein", "g"); + ingredient.fat = parseFatValue(content, "fat", "g"); + ingredient.carbohydrates = parseCarbohydratesValue(content, "carbohydrates", "g"); + ingredient.fiber = parseFiberValue(content, "fiber", "g"); + ingredient.sugar = parseSugarValue(content, "sugar", "g"); + ingredient.salt = parseSaltValue(content, "salt", "mg"); + + nutrition.ingredient.add(ingredient); + } + + return nutrition.ingredient.isEmpty() ? null : nutrition; + } + + /** + * Parses calories values from XML content. + */ + private Nutrition.Calories parseCaloriesValue(String content, String tagName, String defaultUnit) { + Pattern pattern = Pattern.compile("<" + tagName + "\\s+unit=\"([^\"]*?)\"[^>]*>([^<]*)"); + Matcher matcher = pattern.matcher(content); + + if (matcher.find()) { + String unit = matcher.group(1); + String value = matcher.group(2).trim(); + + Nutrition.Calories calories = new Nutrition.Calories(validateNumericValue(value)); + calories.unit = unit; + return calories; + } + + return new Nutrition.Calories("0"); + } + + /** + * Parses protein values from XML content. + */ + private Nutrition.Protein parseProteinValue(String content, String tagName, String defaultUnit) { + Pattern pattern = Pattern.compile("<" + tagName + "\\s+unit=\"([^\"]*?)\"[^>]*>([^<]*)"); + Matcher matcher = pattern.matcher(content); + + if (matcher.find()) { + String unit = matcher.group(1); + String value = matcher.group(2).trim(); + + Nutrition.Protein protein = new Nutrition.Protein(validateNumericValue(value)); + protein.unit = unit; + return protein; + } + + return new Nutrition.Protein("0"); + } + + /** + * Parses fat values from XML content. + */ + private Nutrition.Fat parseFatValue(String content, String tagName, String defaultUnit) { + Pattern pattern = Pattern.compile("<" + tagName + "\\s+unit=\"([^\"]*?)\"[^>]*>([^<]*)"); + Matcher matcher = pattern.matcher(content); + + if (matcher.find()) { + String unit = matcher.group(1); + String value = matcher.group(2).trim(); + + Nutrition.Fat fat = new Nutrition.Fat(validateNumericValue(value)); + fat.unit = unit; + return fat; + } + + return new Nutrition.Fat("0"); + } + + /** + * Parses carbohydrates values from XML content. + */ + private Nutrition.Carbohydrates parseCarbohydratesValue(String content, String tagName, String defaultUnit) { + Pattern pattern = Pattern.compile("<" + tagName + "\\s+unit=\"([^\"]*?)\"[^>]*>([^<]*)"); + Matcher matcher = pattern.matcher(content); + + if (matcher.find()) { + String unit = matcher.group(1); + String value = matcher.group(2).trim(); + + Nutrition.Carbohydrates carbohydrates = new Nutrition.Carbohydrates(validateNumericValue(value)); + carbohydrates.unit = unit; + return carbohydrates; + } + + return new Nutrition.Carbohydrates("0"); + } + + /** + * Parses fiber values from XML content. + */ + private Nutrition.Fiber parseFiberValue(String content, String tagName, String defaultUnit) { + Pattern pattern = Pattern.compile("<" + tagName + "\\s+unit=\"([^\"]*?)\"[^>]*>([^<]*)"); + Matcher matcher = pattern.matcher(content); + + if (matcher.find()) { + String unit = matcher.group(1); + String value = matcher.group(2).trim(); + + Nutrition.Fiber fiber = new Nutrition.Fiber(validateNumericValue(value)); + fiber.unit = unit; + return fiber; + } + + return new Nutrition.Fiber("0"); + } + + /** + * Parses sugar values from XML content. + */ + private Nutrition.Sugar parseSugarValue(String content, String tagName, String defaultUnit) { + Pattern pattern = Pattern.compile("<" + tagName + "\\s+unit=\"([^\"]*?)\"[^>]*>([^<]*)"); + Matcher matcher = pattern.matcher(content); + + if (matcher.find()) { + String unit = matcher.group(1); + String value = matcher.group(2).trim(); + + Nutrition.Sugar sugar = new Nutrition.Sugar(validateNumericValue(value)); + sugar.unit = unit; + return sugar; + } + + return new Nutrition.Sugar("0"); + } + + /** + * Parses salt values from XML content. + */ + private Nutrition.Salt parseSaltValue(String content, String tagName, String defaultUnit) { + Pattern pattern = Pattern.compile("<" + tagName + "\\s+unit=\"([^\"]*?)\"[^>]*>([^<]*)"); + Matcher matcher = pattern.matcher(content); + + if (matcher.find()) { + String unit = matcher.group(1); + String value = matcher.group(2).trim(); + + Nutrition.Salt salt = new Nutrition.Salt(validateNumericValue(value)); + salt.unit = unit; + return salt; + } + + return new Nutrition.Salt("0"); + } + + /** + * Validates and cleans numeric values. + */ + private String validateNumericValue(String value) { + if (value == null || value.trim().isEmpty()) { + return "0"; + } + + try { + // Try to parse as double to validate + Double.parseDouble(value.trim()); + return value.trim(); + } catch (NumberFormatException e) { + return "0"; + } + } + + /** + * Creates a fallback nutrition object when parsing fails. + */ + private Nutrition createFallbackNutrition() { + Nutrition nutrition = new Nutrition(); + nutrition.verdict = "Unknown"; // Default verdict for unanalyzed recipes + + Nutrition.NutritionIngredient fallback = new Nutrition.NutritionIngredient(); + fallback.name = "Recipe"; + fallback.amount = "1 serving"; + fallback.calories = new Nutrition.Calories("0"); + fallback.protein = new Nutrition.Protein("0"); + fallback.fat = new Nutrition.Fat("0"); + fallback.carbohydrates = new Nutrition.Carbohydrates("0"); + fallback.fiber = new Nutrition.Fiber("0"); + fallback.sugar = new Nutrition.Sugar("0"); + fallback.salt = new Nutrition.Salt("0"); + + nutrition.ingredient.add(fallback); + return nutrition; + } +} diff --git a/src/main/java/com/starlight/util/Session.java b/src/main/java/com/starlight/util/Session.java index 9f45bcf..4efb981 100644 --- a/src/main/java/com/starlight/util/Session.java +++ b/src/main/java/com/starlight/util/Session.java @@ -1,6 +1,6 @@ package com.starlight.util; -import com.starlight.models.User; +import com.starlight.model.User; /** * Simple holder for the currently logged in user. diff --git a/src/main/java/module-info.java b/src/main/java/module-info.java index d9bff58..6a8d98f 100644 --- a/src/main/java/module-info.java +++ b/src/main/java/module-info.java @@ -10,15 +10,17 @@ requires transitive MaterialFX; requires xstream; requires jdk.httpserver; + requires java.net.http; opens com.starlight to javafx.fxml; - opens com.starlight.controller to javafx.fxml; - opens com.starlight.models; + opens com.starlight.controller; + opens com.starlight.model; opens com.starlight.util; + opens com.starlight.api; exports com.starlight; exports com.starlight.controller; exports com.starlight.util; exports com.starlight.api; - exports com.starlight.models; + exports com.starlight.model; } \ No newline at end of file diff --git a/src/main/resources/com/starlight/icon/AI_checker.png b/src/main/resources/com/starlight/icon/AI_checker.png new file mode 100644 index 0000000..e99cf7a Binary files /dev/null and b/src/main/resources/com/starlight/icon/AI_checker.png differ diff --git a/src/main/resources/com/starlight/icon/Achivements.png b/src/main/resources/com/starlight/icon/Achivements.png new file mode 100644 index 0000000..ef66e10 Binary files /dev/null and b/src/main/resources/com/starlight/icon/Achivements.png differ diff --git a/src/main/resources/com/starlight/icon/Arrow left.png b/src/main/resources/com/starlight/icon/Arrow left.png new file mode 100644 index 0000000..47afbd7 Binary files /dev/null and b/src/main/resources/com/starlight/icon/Arrow left.png differ diff --git a/src/main/resources/com/starlight/icon/Community.png b/src/main/resources/com/starlight/icon/Community.png new file mode 100644 index 0000000..9aa5d25 Binary files /dev/null and b/src/main/resources/com/starlight/icon/Community.png differ diff --git a/src/main/resources/com/starlight/icon/Game.png b/src/main/resources/com/starlight/icon/Game.png new file mode 100644 index 0000000..c7df50a Binary files /dev/null and b/src/main/resources/com/starlight/icon/Game.png differ diff --git a/src/main/resources/com/starlight/icon/Mission.png b/src/main/resources/com/starlight/icon/Mission.png new file mode 100644 index 0000000..42f54b2 Binary files /dev/null and b/src/main/resources/com/starlight/icon/Mission.png differ diff --git a/src/main/resources/com/starlight/icon/RecipeManager.png b/src/main/resources/com/starlight/icon/RecipeManager.png new file mode 100644 index 0000000..e5f2286 Binary files /dev/null and b/src/main/resources/com/starlight/icon/RecipeManager.png differ diff --git a/src/main/resources/com/starlight/icon/RecipeManager_1.png b/src/main/resources/com/starlight/icon/RecipeManager_1.png new file mode 100644 index 0000000..272bcec Binary files /dev/null and b/src/main/resources/com/starlight/icon/RecipeManager_1.png differ diff --git a/src/main/resources/com/starlight/icon/UserManager.png b/src/main/resources/com/starlight/icon/UserManager.png new file mode 100644 index 0000000..52c0fb1 Binary files /dev/null and b/src/main/resources/com/starlight/icon/UserManager.png differ diff --git a/src/main/resources/com/starlight/icon/UserManager_1.png b/src/main/resources/com/starlight/icon/UserManager_1.png new file mode 100644 index 0000000..e26e91d Binary files /dev/null and b/src/main/resources/com/starlight/icon/UserManager_1.png differ diff --git a/src/main/resources/com/starlight/icon/Wiki.png b/src/main/resources/com/starlight/icon/Wiki.png new file mode 100644 index 0000000..5502af2 Binary files /dev/null and b/src/main/resources/com/starlight/icon/Wiki.png differ diff --git a/src/main/resources/com/starlight/icon/attach_file.png b/src/main/resources/com/starlight/icon/attach_file.png new file mode 100644 index 0000000..ccef1be Binary files /dev/null and b/src/main/resources/com/starlight/icon/attach_file.png differ diff --git a/src/main/resources/com/starlight/icon/delete.png b/src/main/resources/com/starlight/icon/delete.png new file mode 100644 index 0000000..9e98b4b Binary files /dev/null and b/src/main/resources/com/starlight/icon/delete.png differ diff --git a/src/main/resources/com/starlight/icon/like_1.png b/src/main/resources/com/starlight/icon/like_1.png new file mode 100644 index 0000000..2dbda0f Binary files /dev/null and b/src/main/resources/com/starlight/icon/like_1.png differ diff --git a/src/main/resources/com/starlight/icon/photo.png b/src/main/resources/com/starlight/icon/photo.png new file mode 100644 index 0000000..0090c71 Binary files /dev/null and b/src/main/resources/com/starlight/icon/photo.png differ diff --git a/src/main/resources/com/starlight/icon/send.png b/src/main/resources/com/starlight/icon/send.png new file mode 100644 index 0000000..c4008b2 Binary files /dev/null and b/src/main/resources/com/starlight/icon/send.png differ diff --git a/src/main/resources/com/starlight/images/AppLogo.png b/src/main/resources/com/starlight/images/AppLogo.png new file mode 100644 index 0000000..a4f98fc Binary files /dev/null and b/src/main/resources/com/starlight/images/AppLogo.png differ diff --git a/src/main/resources/com/starlight/images/AppLogo_HD.png b/src/main/resources/com/starlight/images/AppLogo_HD.png new file mode 100644 index 0000000..2f00726 Binary files /dev/null and b/src/main/resources/com/starlight/images/AppLogo_HD.png differ diff --git a/src/main/resources/com/starlight/images/art.png b/src/main/resources/com/starlight/images/art.png new file mode 100644 index 0000000..638507e Binary files /dev/null and b/src/main/resources/com/starlight/images/art.png differ diff --git a/src/main/resources/com/starlight/images/botIcon.png b/src/main/resources/com/starlight/images/botIcon.png new file mode 100644 index 0000000..85da004 Binary files /dev/null and b/src/main/resources/com/starlight/images/botIcon.png differ diff --git a/src/main/resources/com/starlight/images/dummy/profiledefault b/src/main/resources/com/starlight/images/dummy/profiledefault.png similarity index 100% rename from src/main/resources/com/starlight/images/dummy/profiledefault rename to src/main/resources/com/starlight/images/dummy/profiledefault.png diff --git a/src/main/resources/com/starlight/main.fxml.old b/src/main/resources/com/starlight/main.fxml.old deleted file mode 100644 index 54e5462..0000000 --- a/src/main/resources/com/starlight/main.fxml.old +++ /dev/null @@ -1,144 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/main/resources/com/starlight/models/LocalUserData.xml b/src/main/resources/com/starlight/models/LocalUserData.xml new file mode 100644 index 0000000..07ed865 --- /dev/null +++ b/src/main/resources/com/starlight/models/LocalUserData.xml @@ -0,0 +1,19 @@ + + + + admin + admin@tabemonpal.local + Administrator + admin123 + 1990-01-01 + src/main/resources/com/starlight/images/dummy/profileman.jpg + + + user + user@tabemonpal.local + Default User + user123 + 1995-01-01 + src/main/resources/com/starlight/images/dummy/profiledefault.png + + diff --git a/src/main/resources/com/starlight/style.css b/src/main/resources/com/starlight/style.css index 5e67b6e..57e6676 100644 --- a/src/main/resources/com/starlight/style.css +++ b/src/main/resources/com/starlight/style.css @@ -15,7 +15,7 @@ .sideBar{ -fx-background-color: #EED8AF; background-color: #EED8AF; - -fx-background-radius: 0 50 50 0; + -fx-background-radius: 0 30 30 0; } .button-sidebar { @@ -59,10 +59,24 @@ } .button-active-bar { - -fx-background-color: #3F3F5B; - background-color: #3F3F5B; + -fx-background-color: #3F3F5B !important; + background-color: #3F3F5B !important; + -fx-border-color: #3F3F5B !important; + border-color: #3F3F5B !important; } +/* Ensure activeBar maintains its color regardless of position */ +.sideBar .button-active-bar { + -fx-background-color: #3F3F5B !important; + background-color: #3F3F5B !important; + -fx-border-color: #3F3F5B !important; + border-color: #3F3F5B !important; +} + +.black { + -fx-background-color: #000000; + background-color: #000000; +} .icon{ -fx-padding: 0px 26px 0px 0px; padding: 0px 26px 0px 0px; @@ -73,11 +87,11 @@ font-family: Poppins; -fx-font-style: normal; font-style: normal; - -fx-font-size: 15; - font-size: 15; - -fx-text-fill: #5b543f; - -fx-background-color:#EED8AF; - background-color:#EED8AF; + -fx-font-size: 16; + font-size: 16; + -fx-text-fill: white; + -fx-background-color:#92598C; + background-color:#92598C; -fx-background-radius: 10 10 10 10; } @@ -93,11 +107,17 @@ font-weight: 100; } +.Popup-post { .Popup-post { -fx-background-color: #FEF7E7; background-color: #FEF7E7; } +.Popup-post-title { + -fx-font-size: 20; + font-size: 20; +} +.Popup-post-header { .Popup-post-title { -fx-font-size: 20; font-size: 20; @@ -108,6 +128,7 @@ } +.Popup-post-desc { .Popup-post-desc { -fx-font-size: 7; font-size: 7; @@ -125,6 +146,18 @@ border-radius: 10 10 10 10; } +.Popup-image { + -fx-background-color: #f5f5f5; + background-color: #f5f5f5; + -fx-background-radius: 20 20 20 20; + -fx-padding: 10px; + padding: 10px; + -fx-border-color: #EED8AF; + border-color: #EED8AF; + -fx-border-radius: 10 10 10 10; + border-radius: 10 10 10 10; +} + .interaction-button { -fx-font-family: Poppons; font-family: Poppons; @@ -268,6 +301,271 @@ } + +.profile-attribute-number { + -fx-font-family: Poppins; + font-family: Poppins; + -fx-font-style: normal; + font-style: normal; + -fx-font-size: 32; + font-size: 32; +} + +.profile-attribute-text { + -fx-font-family: Poppins; + font-family: Poppins; + -fx-font-style: normal; + font-style: normal; + -fx-font-size: 20; + font-size: 20; +} + +.badge-box { + -fx-background-color: #FEF7E7; + background-color: #FEF7E7; + -fx-background-radius: 30 30 30 30; +} + +.delete-button { + -fx-background-color: #FF6B6B; + background-color: #FF6B6B; + -fx-background-radius: 20 20 20 20; + -fx-text-fill: white; + -fx-font-family: Poppins; + font-family: Poppins; + -fx-font-style: normal; + font-style: normal; + -fx-font-size: 15; + font-size: 15; +} + +.normal-button { + -fx-background-color: #92598C; + background-color: #92598C; + -fx-background-radius: 20 20 20 20; + -fx-text-fill: white; + -fx-font-family: Poppins; + font-family: Poppins; + -fx-font-style: normal; + font-style: normal; + -fx-font-size: 15; + font-size: 15; + -fx-background-radius: 25 25 25 25; + -fx-background-radius: 25 25 25 25; + -fx-border-radius: 25 25 25 25; + border-radius: 25 25 25 25; +} + +<<<<<<< HEAD +.text-area, .text-area .viewport, .text-area .content { + -fx-background-color: transparent; + background-color: transparent; +} + +.text-prompt { + -fx-font-size: 18; + font-size: 18; +} + +.button-topic { + -fx-background-radius: 10; + -fx-text-fill: #5b543f; + -fx-font-family: Poppins; + font-family: Poppins; + -fx-font-style: normal; + font-style: normal; + -fx-font-size: 16; + font-size: 16; +} + +.food-tag { + -fx-background-color: #EED8AF; + background-color: #EED8AF; + -fx-background-radius: 40; + -fx-text-fill: #000000; + -fx-font-family: Poppins; + font-family: Poppins; + -fx-font-style: normal; + font-style: normal; + -fx-font-size: 24; + font-size: 24; +} + +/* Verdict-specific styling */ +.verdict-healthy { + -fx-background-color: #4CAF50 !important; + background-color: #4CAF50 !important; + -fx-text-fill: white !important; +} + +.verdict-moderate { + -fx-background-color: #FF9800 !important; + background-color: #FF9800 !important; + -fx-text-fill: white !important; +} + +.verdict-unhealthy { + -fx-background-color: #F44336 !important; + background-color: #F44336 !important; + -fx-text-fill: white !important; +} + +.verdict-junk { + -fx-background-color: #9C27B0 !important; + background-color: #9C27B0 !important; + -fx-text-fill: white !important; +} + +.verdict-unknown { + -fx-background-color: #9E9E9E !important; + background-color: #9E9E9E !important; + -fx-text-fill: white !important; +} + +.post-username { + -fx-font-family: Poppins; + font-family: Poppins; + -fx-font-style: normal; + font-style: normal; + -fx-font-size: 20; + font-size: 20; + -fx-text-fill: #3F3F5B; +} + +.post-date { + -fx-font-family: Poppins; + font-family: Poppins; + -fx-font-style: normal; + font-style: normal; + -fx-font-size: 14; + font-size: 14; + -fx-text-fill: #3F3F5B; +} + +.post-desc { + -fx-font-family: Poppins; + font-family: Poppins; + -fx-font-style: normal; + font-style: normal; + -fx-font-size: 28; + font-size: 28; + -fx-text-fill: #3F3F5B; +} + +.post-recipe { + -fx-font-family: Poppins; + font-family: Poppins; + -fx-font-style: normal; + font-style: normal; + -fx-font-size: 24; + font-size: 24; + -fx-text-fill: #3F3F5B; +} + +.post-recipe-background { + -fx-background-color: #FBFAFA; + background-color: #FBFAFA; + -fx-background-radius: 20 20 20 20; +} + +.app-title { + -fx-font-family: Poppins; + font-family: Poppins; + -fx-font-style: normal; + font-style: normal; + -fx-font-size: 22; + font-size: 22; + -fx-text-fill: #3F3F5B; +} + +/* Recipe text formatting styles */ +.recipe-title { + -fx-font-family: Poppins; + font-family: Poppins; + -fx-font-style: normal; + font-style: normal; + -fx-font-size: 26; + font-size: 26; + -fx-font-weight: bold; + font-weight: bold; + font-weight: bold; + -fx-text-fill: #3F3F5B; +} + +.recipe-content { + -fx-font-family: Poppins; + font-family: Poppins; + -fx-font-style: normal; + font-style: normal; + -fx-font-size: 24; + font-size: 24; + -fx-text-fill: #3F3F5B; + -fx-line-spacing: 2px; +} +======= +/* TabeStreak */ + +.button-Mission { + -fx-background-color: #FEF7E7; + background-color: #FEF7E7; + -fx-background-radius: 20 20 20 20; +} + +.Card-Check { + -fx-background-color: #81C784; + background-color: #81C784; + -fx-background-radius: 20 20 20 20; + -fx-border-color: #4CAF50; + border-color: #4CAF50; + -fx-border-radius: 20 20 20 20; + border-radius: 20 20 20 20; + +} + +.card-uncheck { + -fx-background-color: #FFFFFF; + background-color: #FFFFFF; + -fx-background-radius: 20 20 20 20; + -fx-border-color: #dee0e3; + border-color: #dee0e3; + -fx-border-radius: 20 20 20 20; + border-radius: 20 20 20 20; +} + +.card-current { + -fx-background-color: #4CAF50; + background-color: #4CAF50; + -fx-background-radius: 20 20 20 20; + -fx-border-color: #E5E7EB; + border-color: #E5E7EB; + -fx-border-radius: 20 20 20 20; + border-radius: 20 20 20 20; +} + +.cardReward-check { + -fx-background-color: #F7F7F7; + background-color: #F7F7F7; + -fx-background-radius: 20 20 20 20; + -fx-border-color: #FFD700; + border-color: #FFD700; + -fx-border-radius: 20 20 20 20; + border-radius: 20 20 20 20; +} + +.cardReward-uncheck { + -fx-background-color: #F7F7F7; + background-color: #F7F7F7; + -fx-background-radius: 20 20 20 20; +} + +.card-Achieved { + -fx-background-color: #FEF7E7; + background-color: #FEF7E7; + -fx-background-radius: 20 20 20 20; +} + +>>>>>>> origin/mission + /* debugging only .VBox { @@ -275,4 +573,243 @@ -fx-border-width: 1; } -*/ \ No newline at end of file +*/ + +/* Nutrition Facts Chart Styling */ +.nutrition-chart { + -fx-background-color: transparent; + background-color: transparent; + -fx-padding: 20px; + padding: 20px; +} + +.nutrition-chart .chart-title { + -fx-font-family: "Poppins"; + font-family: "Poppins"; + -fx-font-size: 24px; + font-size: 24px; + -fx-font-weight: bold; + font-weight: bold; + -fx-text-fill: #3F3F5B; +} + +.nutrition-chart .chart-legend { + -fx-background-color: transparent; + background-color: transparent; + -fx-padding: 10px; + padding: 10px; +} + +.nutrition-chart .chart-legend .legend-item { + -fx-font-family: "Poppins"; + font-family: "Poppins"; + -fx-font-size: 14px; + font-size: 14px; + -fx-text-fill: #3F3F5B; +} + +.nutrition-chart .pie-slice[data-name="Protein"] { + -fx-pie-color: #FF6B6B; +} + +.nutrition-chart .pie-slice[data-name^="Protein"] { + -fx-pie-color: #FF6B6B; +} + +.nutrition-chart .pie-slice[data-name="Fat"] { + -fx-pie-color: #4ECDC4; +} + +.nutrition-chart .pie-slice[data-name^="Fat"] { + -fx-pie-color: #4ECDC4; +} + +.nutrition-chart .pie-slice[data-name="Carbohydrates"] { + -fx-pie-color: #45B7D1; +} + +.nutrition-chart .pie-slice[data-name^="Carbohydrates"] { + -fx-pie-color: #45B7D1; +} + +.nutrition-chart .pie-slice[data-name="Fiber"] { + -fx-pie-color: #96CEB4; +} + +.nutrition-chart .pie-slice[data-name^="Fiber"] { + -fx-pie-color: #96CEB4; +} + +.nutrition-chart .pie-slice[data-name="Calories"] { + -fx-pie-color: #FFEAA7; +} + +.nutrition-chart .pie-slice[data-name^="Calories"] { + -fx-pie-color: #FFEAA7; +} + +/* Admin Panel Styles */ +.admin-header { + -fx-background-color: #2C3E50; + background-color: #2C3E50; + -fx-text-fill: white; +} + +.admin-title { + -fx-text-fill: #FFFFFF; + -fx-font-weight: bold; + font-weight: bold; +} + +.admin-subtitle { + -fx-text-fill: #BDC3C7; +} + +.admin-controls { + -fx-background-color: #ECF0F1; + background-color: #ECF0F1; + -fx-border-color: #BDC3C7; + border-color: #BDC3C7; + -fx-border-width: 0 0 1 0; + border-width: 0 0 1 0; +} + +.stats-label { + -fx-text-fill: #2C3E50; + -fx-font-size: 12px; + font-size: 12px; + -fx-font-weight: bold; + font-weight: bold; +} + +.admin-table { + -fx-background-color: #FFFFFF; + background-color: #FFFFFF; + -fx-pref-height: 400; + -fx-min-height: 300; + min-height: 300; +} + +/* MFXTableView specific styling */ +.admin-table .mfx-table-view { + -fx-background-color: #FFFFFF; + background-color: #FFFFFF; + -fx-border-color: #E1E8ED; + border-color: #E1E8ED; + -fx-border-width: 1; + border-width: 1; + -fx-border-radius: 8; + border-radius: 8; +} + +.admin-table .mfx-table-column-header { + -fx-background-color: #34495E; + background-color: #34495E; + -fx-text-fill: white; + -fx-font-weight: bold; + font-weight: bold; + -fx-padding: 10; + padding: 10; + -fx-font-size: 14; + font-size: 14; +} + +.admin-table .mfx-table-row-cell { + -fx-padding: 8; + padding: 8; + -fx-border-color: #E1E8ED; + border-color: #E1E8ED; + -fx-border-width: 0 0 1 0; + border-width: 0 0 1 0; +} + +.admin-table .mfx-table-row-cell:odd { + -fx-background-color: #F8F9FA; + background-color: #F8F9FA; +} + +.admin-table .mfx-table-row-cell:hover { + -fx-background-color: #E3F2FD; + background-color: #E3F2FD; +} + +.admin-table .mfx-table-row-cell:selected { + -fx-background-color: #3498DB; + background-color: #3498DB; + -fx-text-fill: white; +} + +/* Legacy TableView styling (keep for compatibility) */ +.admin-table .table-view { + -fx-background-color: transparent; + background-color: transparent; +} + +.admin-table .table-view .column-header { + -fx-background-color: #34495E; + background-color: #34495E; + -fx-text-fill: white; + -fx-font-weight: bold; + font-weight: bold; +} + +.admin-table .table-view .column-header-background { + -fx-background-color: #34495E; + background-color: #34495E; +} + +.admin-table .table-row-cell { + -fx-background-color: transparent; + background-color: transparent; +} + +.admin-table .table-row-cell:odd { + -fx-background-color: #F8F9FA; + background-color: #F8F9FA; +} + +.admin-table .table-row-cell:selected { + -fx-background-color: #3498DB; + background-color: #3498DB; + -fx-text-fill: white; +} + +.admin-table .table-cell { + -fx-border-color: #E1E8ED; + border-color: #E1E8ED; + -fx-border-width: 0 0 1 0; + border-width: 0 0 1 0; + -fx-padding: 8; + padding: 8; +} + +.mfx-button-primary { + -fx-background-color: #3498DB; + background-color: #3498DB; + -fx-text-fill: white; + -fx-background-radius: 6; + -fx-border-radius: 6; + border-radius: 6; + -fx-font-weight: bold; + font-weight: bold; +} + +.mfx-button-primary:hover { + -fx-background-color: #2980B9; + background-color: #2980B9; +} + +.admin-footer { + -fx-background-color: #ECF0F1; + background-color: #ECF0F1; + -fx-border-color: #BDC3C7; + border-color: #BDC3C7; + -fx-border-width: 1 0 0 0; + border-width: 1 0 0 0; +} + +.footer-text { + -fx-text-fill: #7F8C8D; + -fx-font-style: italic; + font-style: italic; +} \ No newline at end of file diff --git a/src/main/resources/com/starlight/view/Authorization.fxml b/src/main/resources/com/starlight/view/Authorization.fxml index ed9464f..5a39800 100644 --- a/src/main/resources/com/starlight/view/Authorization.fxml +++ b/src/main/resources/com/starlight/view/Authorization.fxml @@ -1,9 +1,19 @@ + + + - - - \ No newline at end of file + + + + + + + + + + + + diff --git a/src/main/resources/com/starlight/view/Post.fxml b/src/main/resources/com/starlight/view/Post.fxml new file mode 100644 index 0000000..0175c70 --- /dev/null +++ b/src/main/resources/com/starlight/view/Post.fxml @@ -0,0 +1,149 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/main/resources/com/starlight/view/Tabestreak.fxml b/src/main/resources/com/starlight/view/Tabestreak.fxml new file mode 100644 index 0000000..a6947ac --- /dev/null +++ b/src/main/resources/com/starlight/view/Tabestreak.fxml @@ -0,0 +1,844 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+
+
+
+
+
+
+ +
+ + + + + + + + +
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + +
+ +
+ + + + + + + + + + + + + + + + + +
+ +
+ + + + + + + + + + + + + + + + + +
+ +
+ + + + + + + + + + + + + + + + + +
+
+
+
+
+
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + +
+
+ +
+ +
+ + + + + + + + + + + + + + + + + +
+
+
+
+ +
+ +
+ + + + + + + + + + + + + + + + + +
+
+
+
+ +
+ +
+ + + + + + + + + + + + + + + + + +
+
+
+
+ +
+ +
+ + + + + + + + + + + + + + + + + +
+
+
+ +
+ +
+ + + + + + + + + + + + + + + + + +
+
+
+ +
+ +
+ + + + + + + + + + + + + + + + + +
+
+
+ + + + + + + + + + + + +
+
+
+
+
+
+
+
diff --git a/src/main/resources/com/starlight/view/achievement.fxml b/src/main/resources/com/starlight/view/achievement.fxml index 8b32487..58ee90d 100644 --- a/src/main/resources/com/starlight/view/achievement.fxml +++ b/src/main/resources/com/starlight/view/achievement.fxml @@ -1,12 +1,440 @@ - - + + + + + + + + + - + - - diff --git a/src/main/resources/com/starlight/view/community.fxml b/src/main/resources/com/starlight/view/community.fxml index cc5b197..760aa1b 100644 --- a/src/main/resources/com/starlight/view/community.fxml +++ b/src/main/resources/com/starlight/view/community.fxml @@ -14,150 +14,43 @@ - + - - + + - + - - + + - + - - - - + + + + - + - - + + - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + @@ -333,7 +226,6 @@ - @@ -347,11 +239,11 @@ - + - + @@ -395,7 +287,7 @@ - @@ -414,7 +309,7 @@ - + @@ -424,7 +319,7 @@ - @@ -477,7 +375,7 @@ - + @@ -487,7 +385,7 @@ - @@ -540,7 +441,7 @@ - + @@ -584,7 +485,7 @@ - diff --git a/src/main/resources/com/starlight/view/community1.fxml,old b/src/main/resources/com/starlight/view/community1.fxml,old deleted file mode 100644 index 955f113..0000000 --- a/src/main/resources/com/starlight/view/community1.fxml,old +++ /dev/null @@ -1,299 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/main/resources/com/starlight/view/community2.fxml.old b/src/main/resources/com/starlight/view/community2.fxml.old deleted file mode 100644 index ddde7d1..0000000 --- a/src/main/resources/com/starlight/view/community2.fxml.old +++ /dev/null @@ -1,510 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/main/resources/com/starlight/view/consult.fxml b/src/main/resources/com/starlight/view/consult.fxml index 3ee6704..8012669 100644 --- a/src/main/resources/com/starlight/view/consult.fxml +++ b/src/main/resources/com/starlight/view/consult.fxml @@ -1,13 +1,118 @@ - + + + + - + + + + + + + + - - + + + + + + + + - - - + diff --git a/src/main/resources/com/starlight/view/createPost.fxml b/src/main/resources/com/starlight/view/createPost.fxml index 42c9a42..ca840d8 100644 --- a/src/main/resources/com/starlight/view/createPost.fxml +++ b/src/main/resources/com/starlight/view/createPost.fxml @@ -5,20 +5,22 @@ + + - + - - \ No newline at end of file + diff --git a/src/main/resources/com/starlight/view/main.fxml b/src/main/resources/com/starlight/view/main.fxml index da18de6..d4c4fe7 100644 --- a/src/main/resources/com/starlight/view/main.fxml +++ b/src/main/resources/com/starlight/view/main.fxml @@ -12,17 +12,16 @@ - + - + - - - - + + + @@ -30,33 +29,30 @@ - - - + + + + -
- + - + - - + + diff --git a/src/main/resources/com/starlight/view/main.fxml.old b/src/main/resources/com/starlight/view/main.fxml.old new file mode 100644 index 0000000..d9e89a0 --- /dev/null +++ b/src/main/resources/com/starlight/view/main.fxml.old @@ -0,0 +1,176 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + + +
+
diff --git a/src/main/resources/com/starlight/view/mission.fxml b/src/main/resources/com/starlight/view/mission.fxml index 7d97f40..5c41acd 100644 --- a/src/main/resources/com/starlight/view/mission.fxml +++ b/src/main/resources/com/starlight/view/mission.fxml @@ -1,71 +1,597 @@ + + + + - + - + - + - + + - + - + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - + - - + diff --git a/src/main/resources/com/starlight/view/navbar.fxml b/src/main/resources/com/starlight/view/navbar.fxml index da66d69..e24c186 100644 --- a/src/main/resources/com/starlight/view/navbar.fxml +++ b/src/main/resources/com/starlight/view/navbar.fxml @@ -9,10 +9,13 @@ - + + + + @@ -52,9 +55,9 @@ - + - + diff --git a/src/main/resources/com/starlight/view/popupDialog.fxml b/src/main/resources/com/starlight/view/popupDialog.fxml new file mode 100644 index 0000000..e2c8d67 --- /dev/null +++ b/src/main/resources/com/starlight/view/popupDialog.fxml @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + diff --git a/src/main/resources/com/starlight/view/postItem.fxml b/src/main/resources/com/starlight/view/postItem.fxml index 2205f46..615b463 100644 --- a/src/main/resources/com/starlight/view/postItem.fxml +++ b/src/main/resources/com/starlight/view/postItem.fxml @@ -9,111 +9,111 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/main/resources/com/starlight/view/processingDialog.fxml b/src/main/resources/com/starlight/view/processingDialog.fxml new file mode 100644 index 0000000..143677b --- /dev/null +++ b/src/main/resources/com/starlight/view/processingDialog.fxml @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + diff --git a/src/main/resources/com/starlight/view/profile.fxml b/src/main/resources/com/starlight/view/profile.fxml index 62b048d..ae88dea 100644 --- a/src/main/resources/com/starlight/view/profile.fxml +++ b/src/main/resources/com/starlight/view/profile.fxml @@ -1,8 +1,7 @@ - - + @@ -10,77 +9,165 @@ + - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - + + + + + + +
diff --git a/src/main/resources/com/starlight/view/recipeItem.fxml b/src/main/resources/com/starlight/view/recipeItem.fxml new file mode 100644 index 0000000..1080ccf --- /dev/null +++ b/src/main/resources/com/starlight/view/recipeItem.fxml @@ -0,0 +1,120 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/main/resources/com/starlight/view/recipeManager.fxml b/src/main/resources/com/starlight/view/recipeManager.fxml new file mode 100644 index 0000000..c09be7c --- /dev/null +++ b/src/main/resources/com/starlight/view/recipeManager.fxml @@ -0,0 +1,75 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/main/resources/com/starlight/view/setting.fxml b/src/main/resources/com/starlight/view/setting.fxml new file mode 100644 index 0000000..9c523a2 --- /dev/null +++ b/src/main/resources/com/starlight/view/setting.fxml @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/main/resources/com/starlight/view/splashScreen.fxml b/src/main/resources/com/starlight/view/splashScreen.fxml index d600fbf..b15cfc6 100644 --- a/src/main/resources/com/starlight/view/splashScreen.fxml +++ b/src/main/resources/com/starlight/view/splashScreen.fxml @@ -4,38 +4,42 @@ + - + - + - + - + - + - + - + + + + diff --git a/src/main/resources/com/starlight/view/userManager.fxml b/src/main/resources/com/starlight/view/userManager.fxml new file mode 100644 index 0000000..21cafdf --- /dev/null +++ b/src/main/resources/com/starlight/view/userManager.fxml @@ -0,0 +1,74 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/test/java/com/starlight/api/ApiClientTest.java b/src/test/java/com/starlight/api/ApiClientTest.java new file mode 100644 index 0000000..960edd5 --- /dev/null +++ b/src/test/java/com/starlight/api/ApiClientTest.java @@ -0,0 +1,192 @@ +package com.starlight.api; + +import static org.junit.jupiter.api.Assertions.*; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import com.starlight.api.ApiClient.ApiError; +import com.starlight.api.ApiClient.ApiException; +import com.starlight.model.User; + +/** + * Unit tests for {@link ApiClient}. + */ +public class ApiClientTest { + + private ApiClient apiClient; + + @BeforeEach + void setUp() { + apiClient = new ApiClient(); + } + + @Test + void testApiError_DefaultConstructor() { + ApiError error = new ApiError(); + assertEquals("Unknown error", error.getMessage()); + } + + @Test + void testApiError_WithMessage() { + String message = "Test error message"; + ApiError error = new ApiError(message); + assertEquals(message, error.getMessage()); + } + + @Test + void testApiError_SetMessage() { + ApiError error = new ApiError(); + String message = "New error message"; + error.setMessage(message); + assertEquals(message, error.getMessage()); + } + + @Test + void testApiError_NullMessage() { + ApiError error = new ApiError(); + error.setMessage(null); + assertEquals("Unknown error", error.getMessage()); + } + + @Test + void testApiException_Constructor() { + int statusCode = 400; + String message = "Bad Request"; + ApiException exception = new ApiException(statusCode, message); + + assertEquals(statusCode, exception.getStatusCode()); + assertEquals(message, exception.getMessage()); + } + + @Test + void testApiException_InheritanceFromException() { + ApiException exception = new ApiException(500, "Internal Server Error"); + assertTrue(exception instanceof Exception); + } + + // Note: Testing the actual HTTP operations would require either: + // 1. A mock HTTP server for integration tests + // 2. More sophisticated mocking of URL/HttpURLConnection + // 3. Refactoring ApiClient to use dependency injection for HTTP client + + // For now, we'll test the basic structure and exception handling + @Test + void testLoginMethodExists() { + // Verify that the method exists and can be called + // This will throw an exception due to network connectivity, but that's expected + assertThrows(ApiException.class, () -> { + apiClient.login("test@example.com", "password"); + }); + } + + @Test + void testRegisterMethodExists() { + // Verify that the method exists and can be called + // This will throw an exception due to network connectivity, but that's expected + assertThrows(ApiException.class, () -> { + apiClient.register("testuser", "test@example.com", "password"); + }); + } + + @Test + void testApiClientConstructor() { + // Test that ApiClient can be instantiated without issues + assertDoesNotThrow(() -> { + ApiClient client = new ApiClient(); + assertNotNull(client); + }); + } + + // Test XML handling preparation + @Test + void testUserObjectForSerialization() { + // Test that User objects can be prepared for XML serialization + User user = new User(); + user.username = "testuser"; + user.email = "test@example.com"; + user.password = "password"; + + // This tests that the object creation works without serialization errors + assertDoesNotThrow(() -> { + assertNotNull(user.username); + assertNotNull(user.email); + assertNotNull(user.password); + }); + } + + @Test + void testApiExceptionWithVariousStatusCodes() { + int[] statusCodes = {400, 401, 403, 404, 409, 500, 502, 503}; + String[] messages = { + "Bad Request", "Unauthorized", "Forbidden", "Not Found", + "Conflict", "Internal Server Error", "Bad Gateway", "Service Unavailable" + }; + + for (int i = 0; i < statusCodes.length; i++) { + ApiException exception = new ApiException(statusCodes[i], messages[i]); + assertEquals(statusCodes[i], exception.getStatusCode()); + assertEquals(messages[i], exception.getMessage()); + } + } + + @Test + void testApiErrorEdgeCases() { + ApiError error = new ApiError(""); + assertEquals("", error.getMessage()); + + error = new ApiError(" "); + assertEquals(" ", error.getMessage()); + + error = new ApiError("Multi\nline\nerror"); + assertEquals("Multi\nline\nerror", error.getMessage()); + } + + // Test that the static inner classes can be instantiated independently + @Test + void testStaticInnerClassInstantiation() { + assertDoesNotThrow(() -> { + ApiError error = new ApiError("Test"); + assertNotNull(error); + + ApiException exception = new ApiException(200, "Success"); + assertNotNull(exception); + }); + } + + @Test + void testLoginWithNullParameters() { + // Test behavior with null parameters + assertThrows(ApiException.class, () -> { + apiClient.login(null, "password"); + }); + + assertThrows(ApiException.class, () -> { + apiClient.login("test@example.com", null); + }); + } + + @Test + void testRegisterWithNullParameters() { + // Test behavior with null parameters + assertThrows(ApiException.class, () -> { + apiClient.register(null, "test@example.com", "password"); + }); + + assertThrows(ApiException.class, () -> { + apiClient.register("testuser", null, "password"); + }); + + assertThrows(ApiException.class, () -> { + apiClient.register("testuser", "test@example.com", null); + }); + } + + @Test + void testApiExceptionStatusCodeZero() { + // Test the case where network error results in status code 0 + ApiException exception = new ApiException(0, "Network error: Connection refused"); + assertEquals(0, exception.getStatusCode()); + assertTrue(exception.getMessage().contains("Network error")); + } +} diff --git a/src/test/java/com/starlight/api/ChatbotAPITest.java b/src/test/java/com/starlight/api/ChatbotAPITest.java new file mode 100644 index 0000000..2f69744 --- /dev/null +++ b/src/test/java/com/starlight/api/ChatbotAPITest.java @@ -0,0 +1,153 @@ +package com.starlight.api; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.BeforeEach; +import static org.junit.jupiter.api.Assertions.*; + +/** + * Test class for ChatbotAPI. + */ +public class ChatbotAPITest { + + @BeforeEach + public void setUp() { + // Note: This test won't actually create the ChatbotAPI since it requires a valid API key + // We'll test the structure and error handling instead + } + + @Test + public void testChatbotExceptionCreation() { + // Test exception creation + ChatbotAPI.ChatbotException exception = new ChatbotAPI.ChatbotException("Test message"); + assertEquals("Test message", exception.getMessage()); + + Exception cause = new RuntimeException("Cause"); + ChatbotAPI.ChatbotException exceptionWithCause = new ChatbotAPI.ChatbotException("Test message", cause); + assertEquals("Test message", exceptionWithCause.getMessage()); + assertEquals(cause, exceptionWithCause.getCause()); + } + + @Test + public void testConfigCreation() { + // Test config object creation + ChatbotAPI.Config config = new ChatbotAPI.Config(); + assertNull(config.getOpenaiKey()); + + config.setOpenaiKey("test-key"); + assertEquals("test-key", config.getOpenaiKey()); + } + + @Test + public void testChatbotInitializationWithoutValidKey() { + // The ChatbotAPI should initialize successfully + ChatbotAPI chatbot = new ChatbotAPI(); + + // Test that it can be used (either returns demo message, API key error, or actual response) + try { + String response = chatbot.sendMessage("Hello"); + // If we get here, either demo mode is working or API key is configured + assertTrue(response.contains("demo mode") || + response.contains("configure your OpenAI API key") || + response.length() > 0); // Any non-empty response is acceptable + } catch (ChatbotAPI.ChatbotException e) { + // This is also acceptable - it means the API key is missing or there's an error + assertTrue(e.getMessage().contains("demo mode") || + e.getMessage().contains("API key") || + e.getMessage().length() > 0); // Any error message is acceptable + } + } + + @Test + public void testJsonResponseParsing() throws ChatbotAPI.ChatbotException { + // Test the improved JSON parsing with a mock OpenAI API response + ChatbotAPI chatbot = new ChatbotAPI(); + + // Mock OpenAI API response format + String mockResponse = "{\n" + + " \"id\": \"chatcmpl-123\",\n" + + " \"object\": \"chat.completion\",\n" + + " \"created\": 1677652288,\n" + + " \"model\": \"gpt-3.5-turbo\",\n" + + " \"choices\": [\n" + + " {\n" + + " \"index\": 0,\n" + + " \"message\": {\n" + + " \"role\": \"assistant\",\n" + + " \"content\": \"Hello! How can I help you today?\"\n" + + " },\n" + + " \"finish_reason\": \"stop\"\n" + + " }\n" + + " ],\n" + + " \"usage\": {\n" + + " \"prompt_tokens\": 10,\n" + + " \"completion_tokens\": 9,\n" + + " \"total_tokens\": 19\n" + + " }\n" + + "}"; + + String result = chatbot.parseResponse(mockResponse); + assertEquals("Hello! How can I help you today?", result); + } + + @Test + public void testJsonResponseParsingWithEscapedCharacters() throws ChatbotAPI.ChatbotException { + // Test JSON parsing with escaped characters + ChatbotAPI chatbot = new ChatbotAPI(); + + String mockResponse = "{\n" + + " \"choices\": [\n" + + " {\n" + + " \"message\": {\n" + + " \"content\": \"Hello! I can help with \\\"nutrition\\\" and \\n food advice.\"\n" + + " }\n" + + " }\n" + + " ]\n" + + "}"; + + String result = chatbot.parseResponse(mockResponse); + assertEquals("Hello! I can help with \"nutrition\" and \n food advice.", result); + } + + @Test + public void testJsonResponseParsingInvalidFormat() { + // Test error handling with invalid JSON format + ChatbotAPI chatbot = new ChatbotAPI(); + + String invalidResponse = "{\"invalid\": \"format\"}"; + + ChatbotAPI.ChatbotException exception = assertThrows(ChatbotAPI.ChatbotException.class, () -> { + chatbot.parseResponse(invalidResponse); + }); + + assertTrue(exception.getMessage().contains("Invalid response format")); + } + + @Test + public void testNutritionXMLValidationWithVerdict() { + // Test that XML validation works with verdict attributes + // Test valid XML with verdict attribute (like what the AI returns) + String validResponseWithVerdict = "\n" + + " \n" + + " 100\n" + + " 10\n" + + " 5\n" + + " 15\n" + + " 2\n" + + " 3\n" + + " 200\n" + + " \n" + + ""; + + // This should not throw an exception (simulating internal validation) + // Since analyzeNutritionFacts requires API key, we'll test the validation logic indirectly + // by checking that the response contains the expected tags + assertTrue(validResponseWithVerdict.contains("")); + assertTrue(validResponseWithVerdict.contains("verdict=\"Healthy\"")); + + // Test invalid XML (should be rejected) + String invalidXMLResponse = "content"; + assertFalse(invalidXMLResponse.contains("")); + } +} diff --git a/src/test/java/com/starlight/api/UserApiServerTest.java b/src/test/java/com/starlight/api/UserApiServerTest.java new file mode 100644 index 0000000..7acdc2d --- /dev/null +++ b/src/test/java/com/starlight/api/UserApiServerTest.java @@ -0,0 +1,244 @@ +package com.starlight.api; + +import static org.junit.jupiter.api.Assertions.*; + +import java.io.IOException; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +/** + * Unit tests for {@link UserApiServer}. + */ +public class UserApiServerTest { + + private UserApiServer server; + private static final int TEST_PORT = 9000; // Use a different port range to avoid conflicts + + @BeforeEach + void setUp() throws IOException { + // Create server on a test port with some randomization to avoid conflicts + int basePort = TEST_PORT + (int)(Math.random() * 1000); + server = new UserApiServer(basePort); + } + + @AfterEach + void tearDown() { + if (server != null) { + server.stop(); + } + } + + @Test + void testServerConstruction() throws IOException { + // Test that server can be constructed without throwing + assertDoesNotThrow(() -> { + int testPort = 9100 + (int)(Math.random() * 500); + UserApiServer testServer = new UserApiServer(testPort); + assertNotNull(testServer); + testServer.stop(); // Clean up + }); + } + + @Test + void testServerConstructionWithPortInUse() throws IOException { + // Start first server + int basePort = 9200 + (int)(Math.random() * 100); + UserApiServer firstServer = new UserApiServer(basePort); + firstServer.start(); + + try { + // Creating second server on same port should work (it will find next available port) + assertDoesNotThrow(() -> { + UserApiServer secondServer = new UserApiServer(basePort); + assertNotNull(secondServer); + secondServer.stop(); + }); + } finally { + firstServer.stop(); + } + } + + @Test + void testStartServer() { + assertDoesNotThrow(() -> { + server.start(); + }); + } + + @Test + void testStopServer() { + server.start(); + assertDoesNotThrow(() -> { + server.stop(); + }); + } + + @Test + void testStartStopCycle() { + // Test start/stop cycle (Note: HttpServer cannot be restarted once stopped) + assertDoesNotThrow(() -> { + server.start(); + Thread.sleep(10); // Give server time to start + server.stop(); + Thread.sleep(10); // Give server time to stop + // Cannot restart the same HttpServer instance - this is a Java HttpServer limitation + }); + } + + @Test + void testDoubleStart() { + // Starting an already started server should not cause issues + assertDoesNotThrow(() -> { + server.start(); + server.start(); // Second start should be ignored + }); + } + + @Test + void testDoubleStop() { + // Stopping an already stopped server should not cause issues + server.start(); + assertDoesNotThrow(() -> { + server.stop(); + server.stop(); // Second stop should be ignored + }); + } + + @Test + void testStopWithoutStart() { + // Stopping a server that was never started should not cause issues + assertDoesNotThrow(() -> { + server.stop(); + }); + } + + @Test + void testServerState() { + // Test that server can handle state changes properly + assertDoesNotThrow(() -> { + // Initial state - not started + server.stop(); // Should not throw + + // Start server + server.start(); + Thread.sleep(10); // Give server time to start + + // Stop server + server.stop(); + Thread.sleep(10); // Give server time to stop + + // Note: HttpServer cannot be restarted once stopped - this is a Java limitation + // So we don't test restart here + }); + } + + @Test + void testRestartWithNewInstance() throws IOException { + // Test restart functionality by creating new server instances + // (since HttpServer cannot be restarted once stopped) + int testPort = 9800 + (int)(Math.random() * 100); + + assertDoesNotThrow(() -> { + // First instance + UserApiServer server1 = new UserApiServer(testPort); + server1.start(); + server1.stop(); + + // Second instance on same port (should work after first is stopped) + UserApiServer server2 = new UserApiServer(testPort); + server2.start(); + server2.stop(); + }); + } + + @Test + void testMultipleServerInstances() throws IOException { + // Test that multiple server instances can coexist (on different ports) + int port1 = 9300 + (int)(Math.random() * 50); + int port2 = 9350 + (int)(Math.random() * 50); + UserApiServer server1 = new UserApiServer(port1); + UserApiServer server2 = new UserApiServer(port2); + + try { + assertDoesNotThrow(() -> { + server1.start(); + server2.start(); + }); + + assertDoesNotThrow(() -> { + server1.stop(); + server2.stop(); + }); + } finally { + server1.stop(); + server2.stop(); + } + } + + @Test + void testServerConstructionPortRange() { + // Test that server handles port binding failures gracefully + // by trying multiple ports (this tests the port binding retry logic) + assertDoesNotThrow(() -> { + // This should work even if some ports are in use + int testPort = 9400 + (int)(Math.random() * 100); + UserApiServer testServer = new UserApiServer(testPort); + testServer.stop(); + }); + } + + @Test + void testServerResourceCleanup() throws IOException { + // Test that server properly cleans up resources + int testPort = 9500 + (int)(Math.random() * 100); + UserApiServer testServer = new UserApiServer(testPort); + + testServer.start(); + testServer.stop(); + + // Should be able to create another server on same port after proper cleanup + assertDoesNotThrow(() -> { + UserApiServer secondServer = new UserApiServer(testPort); + secondServer.stop(); + }); + } + + @Test + void testServerStartAfterException() throws IOException { + // Test that server can be started after handling construction exceptions + try { + // This should succeed since we handle port conflicts + int testPort = 9600 + (int)(Math.random() * 100); + UserApiServer testServer = new UserApiServer(testPort); + testServer.start(); + testServer.stop(); + } catch (Exception e) { + fail("Server construction should handle port conflicts gracefully"); + } + } + + // Note: Testing the actual HTTP endpoints would require integration tests + // with actual HTTP requests. For unit tests, we focus on the lifecycle + // and basic functionality of the server. + + @Test + void testServerLifecycle() { + // Test complete server lifecycle + assertDoesNotThrow(() -> { + // Construction phase + int testPort = 9700 + (int)(Math.random() * 100); + UserApiServer lifecycleServer = new UserApiServer(testPort); + + // Startup phase + lifecycleServer.start(); + + // Runtime phase (server is running) + // In a real scenario, this is where HTTP requests would be handled + + // Shutdown phase + lifecycleServer.stop(); + }); + } +} diff --git a/src/test/java/com/starlight/controller/AuthorizationControllerTest.java b/src/test/java/com/starlight/controller/AuthorizationControllerTest.java new file mode 100644 index 0000000..2becb96 --- /dev/null +++ b/src/test/java/com/starlight/controller/AuthorizationControllerTest.java @@ -0,0 +1,160 @@ +package com.starlight.controller; + +import static org.junit.jupiter.api.Assertions.*; + +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.testfx.framework.junit5.ApplicationTest; + +import javafx.application.Platform; +import javafx.scene.Scene; +import javafx.scene.layout.BorderPane; +import javafx.stage.Stage; + +/** + * Unit tests for {@link AuthorizationController}. + */ +public class AuthorizationControllerTest extends ApplicationTest { + + private AuthorizationController controller; + private BorderPane testPane; + + @BeforeAll + static void setUpClass() { + // Ensure JavaFX is initialized for headless testing + System.setProperty("java.awt.headless", "true"); + System.setProperty("testfx.robot", "glass"); + System.setProperty("testfx.headless", "true"); + System.setProperty("prism.order", "sw"); + System.setProperty("prism.text", "t2k"); + System.setProperty("glass.platform", "Monocle"); + System.setProperty("monocle.platform", "Headless"); + } + + @Override + public void start(Stage stage) throws Exception { + // Create a minimal test scene + testPane = new BorderPane(); + Scene scene = new Scene(testPane, 800, 600); + stage.setScene(scene); + + // Create the controller + controller = new AuthorizationController(); + + stage.show(); + } + + @BeforeEach + void setUp() { + // Reset the test pane for each test + Platform.runLater(() -> { + testPane.setRight(null); + testPane.setLeft(null); + testPane.setTop(null); + testPane.setBottom(null); + testPane.setCenter(null); + }); + } + + @Test + void testControllerInstantiation() { + assertNotNull(controller); + } + + @Test + void testShowLoginViewMethodExists() { + // Test that the method exists and can be called + // Note: This will fail due to FXML resource loading in actual implementation + // but tests that the method signature is correct + assertDoesNotThrow(() -> { + try { + controller.showLoginView(); + } catch (NullPointerException e) { + // Expected in unit test environment due to missing FXML resources + assertTrue(e.getMessage() == null || e.getMessage().isEmpty() || + e.getMessage().contains("null")); + } + }); + } + + @Test + void testShowRegisterViewMethodExists() { + // Test that the method exists and can be called + // Note: This will fail due to FXML resource loading in actual implementation + // but tests that the method signature is correct + assertDoesNotThrow(() -> { + try { + controller.showRegisterView(); + } catch (NullPointerException e) { + // Expected in unit test environment due to missing FXML resources + assertTrue(e.getMessage() == null || e.getMessage().isEmpty() || + e.getMessage().contains("null")); + } + }); + } + + @Test + void testControllerImplementsInitializable() { + // Test that AuthorizationController implements Initializable + assertTrue(controller instanceof javafx.fxml.Initializable); + } + + @Test + void testInitializeMethodExists() { + // Test that the initialize method exists using reflection + assertDoesNotThrow(() -> { + var initializeMethod = AuthorizationController.class.getMethod("initialize", + java.net.URL.class, java.util.ResourceBundle.class); + assertNotNull(initializeMethod); + }); + } + + @Test + void testMethodsExist() { + // Test that the required methods exist using reflection + assertDoesNotThrow(() -> { + var showLoginMethod = AuthorizationController.class.getMethod("showLoginView"); + assertNotNull(showLoginMethod); + + var showRegisterMethod = AuthorizationController.class.getMethod("showRegisterView"); + assertNotNull(showRegisterMethod); + }); + } + + @Test + void testControllerCanBeCreated() { + // Test that multiple controller instances can be created + assertDoesNotThrow(() -> { + AuthorizationController controller1 = new AuthorizationController(); + AuthorizationController controller2 = new AuthorizationController(); + + assertNotNull(controller1); + assertNotNull(controller2); + assertNotSame(controller1, controller2); + }); + } + + // Note: Full testing of this controller would require: + // 1. Mock FXML resources + // 2. Mock LoginViewController and RegisterViewController + // 3. Integration test environment with actual FXML loading + // + // For unit testing purposes, we focus on testing the controller structure + // and ensuring it can be instantiated without errors. + + @Test + void testInitializeWithNullParameters() { + // Test initialize method with null parameters + assertDoesNotThrow(() -> { + try { + controller.initialize(null, null); + } catch (Exception e) { + // Expected to fail due to resource loading, but method should exist + assertTrue(e instanceof NullPointerException || + e instanceof java.io.IOException || + e.getCause() instanceof java.io.IOException); + } + }); + } +} diff --git a/src/test/java/com/starlight/controller/ConsultControllerTest.java b/src/test/java/com/starlight/controller/ConsultControllerTest.java new file mode 100644 index 0000000..6d89e00 --- /dev/null +++ b/src/test/java/com/starlight/controller/ConsultControllerTest.java @@ -0,0 +1,48 @@ +package com.starlight.controller; + +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.*; + +import com.starlight.util.FileSystemManager; +import java.nio.file.Path; + +/** + * Test class for ConsultController avatar path resolution. + */ +public class ConsultControllerTest { + + @Test + public void testAvatarPathResolution() { + // Test that the avatar paths can be resolved by FileSystemManager + String botAvatarPath = "src/main/resources/com/starlight/images/dummy/profilewoman.jpg"; + String userAvatarPath = "src/main/resources/com/starlight/images/dummy/profilewoman.jpg"; + + // Test bot avatar path resolution + Path resolvedBotPath = FileSystemManager.resolveImagePath(botAvatarPath); + assertNotNull(resolvedBotPath, "Bot avatar path should be resolvable"); + + // Test user avatar path resolution + Path resolvedUserPath = FileSystemManager.resolveImagePath(userAvatarPath); + assertNotNull(resolvedUserPath, "User avatar path should be resolvable"); + + // Test fallback path resolution + String fallbackPath = "src/main/resources/com/starlight/images/missing.png"; + Path resolvedFallback = FileSystemManager.resolveImagePath(fallbackPath); + assertNotNull(resolvedFallback, "Fallback image path should be resolvable"); + } + + @Test + public void testResourcePathFormat() { + // Test that our path format works with the FileSystemManager + String resourcePath = "src/main/resources/com/starlight/images/dummy/profilewoman.jpg"; + + // Should not be null and should not generate warnings + Path resolved = FileSystemManager.resolveImagePath(resourcePath); + assertNotNull(resolved, "Resource path should be resolvable"); + + // Check that it's either a valid file path or a resource marker + assertTrue(resolved.toString().contains("profilewoman.jpg") || + resolved.toString().startsWith("resource:"), + "Resolved path should contain the image name or be a resource marker"); + } +} diff --git a/src/test/java/com/starlight/controller/LoadingControllerTest.java b/src/test/java/com/starlight/controller/LoadingControllerTest.java new file mode 100644 index 0000000..db2d062 --- /dev/null +++ b/src/test/java/com/starlight/controller/LoadingControllerTest.java @@ -0,0 +1,118 @@ +package com.starlight.controller; + +import static org.junit.jupiter.api.Assertions.*; + +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.testfx.framework.junit5.ApplicationTest; + +import javafx.scene.Scene; +import javafx.scene.layout.VBox; +import javafx.stage.Stage; + +/** + * Unit tests for {@link LoadingController}. + */ +public class LoadingControllerTest extends ApplicationTest { + + private LoadingController controller; + + @BeforeAll + static void setUpClass() { + // Ensure JavaFX is initialized for headless testing + System.setProperty("java.awt.headless", "true"); + System.setProperty("testfx.robot", "glass"); + System.setProperty("testfx.headless", "true"); + System.setProperty("prism.order", "sw"); + System.setProperty("prism.text", "t2k"); + System.setProperty("glass.platform", "Monocle"); + System.setProperty("monocle.platform", "Headless"); + } + + @Override + public void start(Stage stage) throws Exception { + // Create a minimal test scene + VBox root = new VBox(); + Scene scene = new Scene(root, 400, 300); + stage.setScene(scene); + + // Create the controller + controller = new LoadingController(); + + stage.show(); + } + + @Test + void testControllerInstantiation() { + assertNotNull(controller); + } + + @Test + void testControllerCanBeCreated() { + // Test that multiple controller instances can be created + assertDoesNotThrow(() -> { + LoadingController controller1 = new LoadingController(); + LoadingController controller2 = new LoadingController(); + + assertNotNull(controller1); + assertNotNull(controller2); + assertNotSame(controller1, controller2); + }); + } + + @Test + void testInitializeMethodExists() { + // Test that the initialize method exists using reflection + assertDoesNotThrow(() -> { + var initializeMethod = LoadingController.class.getDeclaredMethod("initialize"); + assertNotNull(initializeMethod); + + // Check that it's annotated with @FXML + assertTrue(initializeMethod.isAnnotationPresent(javafx.fxml.FXML.class)); + }); + } + + @Test + void testInitializeMethod() { + // Test that the initialize method can be called + assertDoesNotThrow(() -> { + // Use reflection to call the private initialize method + var initializeMethod = LoadingController.class.getDeclaredMethod("initialize"); + initializeMethod.setAccessible(true); + initializeMethod.invoke(controller); + }); + } + + @Test + void testControllerStructure() { + // Test that the controller has the expected structure + Class controllerClass = LoadingController.class; + + // Should be in the correct package + assertEquals("com.starlight.controller", controllerClass.getPackage().getName()); + + // Should be a public class + assertTrue(java.lang.reflect.Modifier.isPublic(controllerClass.getModifiers())); + } + + @Test + void testMultipleInitializeCalls() { + // Test that initialize can be called multiple times without issues + assertDoesNotThrow(() -> { + var initializeMethod = LoadingController.class.getDeclaredMethod("initialize"); + initializeMethod.setAccessible(true); + + // Call initialize multiple times + initializeMethod.invoke(controller); + initializeMethod.invoke(controller); + initializeMethod.invoke(controller); + }); + } + + @Test + void testControllerHasNoPublicFields() { + // Ensure the controller doesn't expose any public fields (good encapsulation) + var fields = LoadingController.class.getFields(); + assertEquals(0, fields.length, "Controller should not have public fields"); + } +} diff --git a/src/test/java/com/starlight/controller/SettingControllerTest.java b/src/test/java/com/starlight/controller/SettingControllerTest.java new file mode 100644 index 0000000..33ea61d --- /dev/null +++ b/src/test/java/com/starlight/controller/SettingControllerTest.java @@ -0,0 +1,85 @@ +package com.starlight.controller; + +import static org.junit.jupiter.api.Assertions.*; + +import java.io.File; +import java.nio.file.Files; +import java.nio.file.Path; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import com.starlight.api.ChatbotAPI; +import com.thoughtworks.xstream.XStream; +import com.thoughtworks.xstream.io.xml.DomDriver; + +/** + * Test class for SettingController. + */ +public class SettingControllerTest { + + @TempDir + Path tempDir; + + private XStream xstream; + + @BeforeEach + public void setUp() { + this.xstream = new XStream(new DomDriver()); + this.xstream.allowTypesByWildcard(new String[]{"com.starlight.api.*"}); + this.xstream.alias("config", ChatbotAPI.Config.class); + this.xstream.aliasField("openai-key", ChatbotAPI.Config.class, "openaiKey"); + } + + @Test + public void testConfigCreationAndSerialization() throws Exception { + // Create config object + ChatbotAPI.Config config = new ChatbotAPI.Config(); + config.setOpenaiKey("test-api-key-123"); + + // Test serialization + String xml = xstream.toXML(config); + assertTrue(xml.contains("test-api-key-123")); + assertTrue(xml.contains("")); + assertTrue(xml.contains("")); + + // Test deserialization + ChatbotAPI.Config deserializedConfig = (ChatbotAPI.Config) xstream.fromXML(xml); + assertEquals("test-api-key-123", deserializedConfig.getOpenaiKey()); + } + + @Test + public void testConfigFileCreation() throws Exception { + // Create the directory structure + Path configDir = tempDir.resolve(".tabemonpal").resolve("Database"); + Files.createDirectories(configDir); + + // Create config object + ChatbotAPI.Config config = new ChatbotAPI.Config(); + config.setOpenaiKey("sk-test-key-123"); + + // Save to file + File configFile = configDir.resolve(".SECRET_KEY.xml").toFile(); + xstream.toXML(config, Files.newOutputStream(configFile.toPath())); + + // Verify file exists and has correct content + assertTrue(configFile.exists()); + + // Load back and verify + ChatbotAPI.Config loadedConfig = (ChatbotAPI.Config) xstream.fromXML(configFile); + assertEquals("sk-test-key-123", loadedConfig.getOpenaiKey()); + } + + @Test + public void testEmptyApiKeyValidation() { + ChatbotAPI.Config config = new ChatbotAPI.Config(); + assertNull(config.getOpenaiKey()); + + config.setOpenaiKey(""); + assertEquals("", config.getOpenaiKey()); + + config.setOpenaiKey(" "); + assertEquals(" ", config.getOpenaiKey()); + } +} diff --git a/src/test/java/com/starlight/models/PostDataRepositoryTest.java b/src/test/java/com/starlight/model/PostDataRepositoryTest.java similarity index 87% rename from src/test/java/com/starlight/models/PostDataRepositoryTest.java rename to src/test/java/com/starlight/model/PostDataRepositoryTest.java index 87bac83..294fd38 100644 --- a/src/test/java/com/starlight/models/PostDataRepositoryTest.java +++ b/src/test/java/com/starlight/model/PostDataRepositoryTest.java @@ -1,4 +1,4 @@ -package com.starlight.models; +package com.starlight.model; import static org.junit.jupiter.api.Assertions.*; @@ -12,6 +12,8 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import com.starlight.repository.PostDataRepository; + /** * Unit tests for {@link PostDataRepository}. */ @@ -42,6 +44,8 @@ void testSaveAndLoadPosts() { p.rating = "5"; p.uploadtime = "now"; p.likecount = "0"; + p.commentcount = "0"; + p.isLiked = "false"; posts.add(p); repository.savePosts(posts); @@ -58,5 +62,7 @@ void testSaveAndLoadPosts() { assertEquals(p.rating, lp.rating); assertEquals(p.uploadtime, lp.uploadtime); assertEquals(p.likecount, lp.likecount); + assertEquals(p.commentcount, lp.commentcount); + assertEquals(p.isLiked, lp.isLiked); } } diff --git a/src/test/java/com/starlight/model/PostLikedStateTest.java b/src/test/java/com/starlight/model/PostLikedStateTest.java new file mode 100644 index 0000000..614b882 --- /dev/null +++ b/src/test/java/com/starlight/model/PostLikedStateTest.java @@ -0,0 +1,132 @@ +package com.starlight.model; + +import org.junit.jupiter.api.Test; + +import com.starlight.repository.PostDataRepository; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.AfterEach; +import static org.junit.jupiter.api.Assertions.*; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; + +/** + * Test class to verify that liked state is properly saved and loaded from XML. + */ +public class PostLikedStateTest { + + private Path tempFile; + private PostDataRepository repository; + + @BeforeEach + void setUp() throws IOException { + tempFile = Files.createTempFile("testPosts", ".xml"); + repository = new PostDataRepository(tempFile.toString()); + } + + @AfterEach + void tearDown() throws IOException { + Files.deleteIfExists(tempFile); + } + + @Test + void testLikedStateSavedAndLoaded() { + // Create a post with liked state + Post post = new Post(); + post.uuid = "test-uuid-123"; + post.title = "Test Post"; + post.description = "Test Description"; + post.likecount = "5"; + post.commentcount = "3"; + post.isLiked = "true"; + + List posts = new ArrayList<>(); + posts.add(post); + + // Save the post + repository.savePosts(posts); + + // Load the posts back + List loadedPosts = repository.loadPosts(); + + // Verify that the liked state is preserved + assertNotNull(loadedPosts); + assertEquals(1, loadedPosts.size()); + + Post loadedPost = loadedPosts.get(0); + assertEquals("test-uuid-123", loadedPost.uuid); + assertEquals("Test Post", loadedPost.title); + assertEquals("Test Description", loadedPost.description); + assertEquals("5", loadedPost.likecount); + assertEquals("3", loadedPost.commentcount); + assertEquals("true", loadedPost.isLiked); + } + + @Test + void testMissingFieldsInitialized() { + // Create a post without commentcount and isLiked fields + Post post = new Post(); + post.uuid = "test-uuid-456"; + post.title = "Test Post Without New Fields"; + post.likecount = "10"; + // Note: commentcount and isLiked are null + + List posts = new ArrayList<>(); + posts.add(post); + + // Save the post + repository.savePosts(posts); + + // Load the posts back + List loadedPosts = repository.loadPosts(); + + // Verify that missing fields are initialized + assertNotNull(loadedPosts); + assertEquals(1, loadedPosts.size()); + + Post loadedPost = loadedPosts.get(0); + assertEquals("test-uuid-456", loadedPost.uuid); + assertEquals("Test Post Without New Fields", loadedPost.title); + assertEquals("10", loadedPost.likecount); + assertEquals("0", loadedPost.commentcount); // Should be initialized to "0" + assertEquals("false", loadedPost.isLiked); // Should be initialized to "false" + } + + @Test + void testLikeStateToggle() { + // Create a post with false liked state + Post post = new Post(); + post.uuid = "test-uuid-789"; + post.title = "Toggle Test Post"; + post.likecount = "0"; + post.commentcount = "0"; + post.isLiked = "false"; + + List posts = new ArrayList<>(); + posts.add(post); + + // Save the post + repository.savePosts(posts); + + // Load, modify, and save again + List loadedPosts = repository.loadPosts(); + Post loadedPost = loadedPosts.get(0); + + // Toggle the liked state + loadedPost.isLiked = "true"; + loadedPost.likecount = "1"; + + repository.savePosts(loadedPosts); + + // Load again and verify the change persisted + List reloadedPosts = repository.loadPosts(); + Post reloadedPost = reloadedPosts.get(0); + + assertEquals("true", reloadedPost.isLiked); + assertEquals("1", reloadedPost.likecount); + } +} diff --git a/src/test/java/com/starlight/model/PostTest.java b/src/test/java/com/starlight/model/PostTest.java new file mode 100644 index 0000000..6f6b934 --- /dev/null +++ b/src/test/java/com/starlight/model/PostTest.java @@ -0,0 +1,247 @@ +package com.starlight.model; + +import static org.junit.jupiter.api.Assertions.*; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +/** + * Unit tests for {@link Post} data model. + */ +public class PostTest { + private Post post; + + @BeforeEach + void setUp() { + post = new Post(); + } + + @Test + void testPostCreation() { + assertNotNull(post); + assertNull(post.uuid); + assertNull(post.username); + assertNull(post.profilepicture); + assertNull(post.title); + assertNull(post.description); + assertNull(post.ingredients); + assertNull(post.directions); + assertNull(post.image); + assertNull(post.rating); + assertNull(post.uploadtime); + assertNull(post.likecount); + assertNull(post.commentcount); + assertNull(post.isLiked); + } + + @Test + void testSetAndGetUuid() { + String testUuid = "123e4567-e89b-12d3-a456-426614174000"; + post.uuid = testUuid; + assertEquals(testUuid, post.uuid); + } + + @Test + void testSetAndGetUsername() { + String testUsername = "chef123"; + post.username = testUsername; + assertEquals(testUsername, post.username); + } + + @Test + void testSetAndGetProfilePicture() { + String testProfilePicture = "/images/profiles/chef123.jpg"; + post.profilepicture = testProfilePicture; + assertEquals(testProfilePicture, post.profilepicture); + } + + @Test + void testSetAndGetTitle() { + String testTitle = "Delicious Chocolate Cake"; + post.title = testTitle; + assertEquals(testTitle, post.title); + } + + @Test + void testSetAndGetDescription() { + String testDescription = "A moist and rich chocolate cake perfect for any occasion."; + post.description = testDescription; + assertEquals(testDescription, post.description); + } + + @Test + void testSetAndGetIngredients() { + String testIngredients = "2 cups flour, 1 cup sugar, 3 eggs, 1/2 cup cocoa powder"; + post.ingredients = testIngredients; + assertEquals(testIngredients, post.ingredients); + } + + @Test + void testSetAndGetDirections() { + String testDirections = "1. Mix dry ingredients. 2. Add wet ingredients. 3. Bake at 350°F for 30 minutes."; + post.directions = testDirections; + assertEquals(testDirections, post.directions); + } + + @Test + void testSetAndGetImage() { + String testImage = "/images/posts/chocolate_cake.jpg"; + post.image = testImage; + assertEquals(testImage, post.image); + } + + @Test + void testSetAndGetRating() { + String testRating = "4.5"; + post.rating = testRating; + assertEquals(testRating, post.rating); + } + + @Test + void testSetAndGetUploadTime() { + String testUploadTime = "2025-07-07T10:30:00Z"; + post.uploadtime = testUploadTime; + assertEquals(testUploadTime, post.uploadtime); + } + + @Test + void testSetAndGetLikeCount() { + String testLikeCount = "42"; + post.likecount = testLikeCount; + assertEquals(testLikeCount, post.likecount); + } + + @Test + void testSetAndGetCommentCount() { + String testCommentCount = "15"; + post.commentcount = testCommentCount; + assertEquals(testCommentCount, post.commentcount); + } + + @Test + void testSetAndGetIsLiked() { + String testIsLiked = "true"; + post.isLiked = testIsLiked; + assertEquals(testIsLiked, post.isLiked); + } + + @Test + void testPostWithAllFieldsSet() { + post.uuid = "550e8400-e29b-41d4-a716-446655440000"; + post.username = "masterchef"; + post.profilepicture = "/images/profiles/masterchef.png"; + post.title = "Perfect Pasta Carbonara"; + post.description = "Traditional Italian carbonara with eggs, cheese, and pancetta."; + post.ingredients = "400g spaghetti, 200g pancetta, 4 eggs, 100g Pecorino Romano, black pepper"; + post.directions = "1. Cook pasta. 2. Fry pancetta. 3. Mix eggs and cheese. 4. Combine all together off heat."; + post.image = "/images/posts/carbonara.jpg"; + post.rating = "4.8"; + post.uploadtime = "2025-07-07T15:45:30Z"; + post.likecount = "127"; + post.commentcount = "23"; + post.isLiked = "false"; + + assertEquals("550e8400-e29b-41d4-a716-446655440000", post.uuid); + assertEquals("masterchef", post.username); + assertEquals("/images/profiles/masterchef.png", post.profilepicture); + assertEquals("Perfect Pasta Carbonara", post.title); + assertEquals("Traditional Italian carbonara with eggs, cheese, and pancetta.", post.description); + assertEquals("400g spaghetti, 200g pancetta, 4 eggs, 100g Pecorino Romano, black pepper", post.ingredients); + assertEquals("1. Cook pasta. 2. Fry pancetta. 3. Mix eggs and cheese. 4. Combine all together off heat.", post.directions); + assertEquals("/images/posts/carbonara.jpg", post.image); + assertEquals("4.8", post.rating); + assertEquals("2025-07-07T15:45:30Z", post.uploadtime); + assertEquals("127", post.likecount); + assertEquals("23", post.commentcount); + assertEquals("false", post.isLiked); + } + + @Test + void testPostFieldsCanBeNull() { + // Set some values first + post.uuid = "test-uuid"; + post.username = "testuser"; + post.title = "Test Title"; + + // Then set to null + post.uuid = null; + post.username = null; + post.profilepicture = null; + post.title = null; + post.description = null; + post.ingredients = null; + post.directions = null; + post.image = null; + post.rating = null; + post.uploadtime = null; + post.likecount = null; + post.commentcount = null; + post.isLiked = null; + + assertNull(post.uuid); + assertNull(post.username); + assertNull(post.profilepicture); + assertNull(post.title); + assertNull(post.description); + assertNull(post.ingredients); + assertNull(post.directions); + assertNull(post.image); + assertNull(post.rating); + assertNull(post.uploadtime); + assertNull(post.likecount); + assertNull(post.commentcount); + assertNull(post.isLiked); + } + + @Test + void testPostFieldsCanBeEmpty() { + post.uuid = ""; + post.username = ""; + post.profilepicture = ""; + post.title = ""; + post.description = ""; + post.ingredients = ""; + post.directions = ""; + post.image = ""; + post.rating = ""; + post.uploadtime = ""; + post.likecount = ""; + post.commentcount = ""; + post.isLiked = ""; + + assertEquals("", post.uuid); + assertEquals("", post.username); + assertEquals("", post.profilepicture); + assertEquals("", post.title); + assertEquals("", post.description); + assertEquals("", post.ingredients); + assertEquals("", post.directions); + assertEquals("", post.image); + assertEquals("", post.rating); + assertEquals("", post.uploadtime); + assertEquals("", post.likecount); + assertEquals("", post.commentcount); + assertEquals("", post.isLiked); + } + + @Test + void testPostWithSpecialCharacters() { + post.title = "Café au Lait & Croissants"; + post.description = "A French breakfast with café au lait ☕ and buttery croissants 🥐"; + post.ingredients = "Café beans (organic), milk, butter, flour, eggs"; + post.directions = "1. Brew coffee ☕ 2. Heat milk 🥛 3. Bake croissants @ 200°C"; + post.rating = "4.9"; + post.likecount = "1,234"; + post.commentcount = "87"; + post.isLiked = "true"; + + assertEquals("Café au Lait & Croissants", post.title); + assertEquals("A French breakfast with café au lait ☕ and buttery croissants 🥐", post.description); + assertEquals("Café beans (organic), milk, butter, flour, eggs", post.ingredients); + assertEquals("1. Brew coffee ☕ 2. Heat milk 🥛 3. Bake croissants @ 200°C", post.directions); + assertEquals("4.9", post.rating); + assertEquals("1,234", post.likecount); + assertEquals("87", post.commentcount); + assertEquals("true", post.isLiked); + } +} diff --git a/src/test/java/com/starlight/model/UserDataRepositoryTest.java b/src/test/java/com/starlight/model/UserDataRepositoryTest.java new file mode 100644 index 0000000..2a9f14c --- /dev/null +++ b/src/test/java/com/starlight/model/UserDataRepositoryTest.java @@ -0,0 +1,200 @@ +package com.starlight.model; + +import static org.junit.jupiter.api.Assertions.*; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import com.starlight.repository.UserDataRepository; + +/** + * Unit tests for {@link UserDataRepository}. + */ +public class UserDataRepositoryTest { + private Path tempFile; + private Path tempDummyFile; + private UserDataRepository repository; + + @BeforeEach + void setUp() throws IOException { + tempFile = Files.createTempFile("users", ".xml"); + tempDummyFile = Files.createTempFile("usersDummy", ".xml"); + repository = new UserDataRepository(tempFile.toString()); + + // Create some dummy data for testing + createDummyData(); + } + + @AfterEach + void tearDown() throws IOException { + Files.deleteIfExists(tempFile); + Files.deleteIfExists(tempDummyFile); + } + + private void createDummyData() throws IOException { + String dummyXml = "\n" + + " \n" + + " dummy1\n" + + " dummy1@example.com\n" + + " Dummy User 1\n" + + " password1\n" + + " 1990-01-01\n" + + " \n" + + " \n" + + " dummy2\n" + + " dummy2@example.com\n" + + " Dummy User 2\n" + + " password2\n" + + " 1985-05-15\n" + + " \n" + + ""; + Files.writeString(tempDummyFile, dummyXml); + } + + @Test + void testSaveAndLoadUsers() { + List users = new ArrayList<>(); + + User user1 = new User(); + user1.username = "testuser1"; + user1.email = "test1@example.com"; + user1.fullname = "Test User 1"; + user1.password = "password123"; + user1.birthDay = "1995-03-10"; + users.add(user1); + + User user2 = new User(); + user2.username = "testuser2"; + user2.email = "test2@example.com"; + user2.fullname = "Test User 2"; + user2.password = "password456"; + user2.birthDay = "1992-07-20"; + users.add(user2); + + repository.saveUsers(users); + assertTrue(Files.exists(tempFile) && tempFile.toFile().length() > 0); + + List loaded = repository.loadUsers(false); // Don't include dummy data + assertEquals(2, loaded.size()); + + User loadedUser1 = loaded.get(0); + assertEquals(user1.username, loadedUser1.username); + assertEquals(user1.email, loadedUser1.email); + assertEquals(user1.fullname, loadedUser1.fullname); + assertEquals(user1.password, loadedUser1.password); + assertEquals(user1.birthDay, loadedUser1.birthDay); + + User loadedUser2 = loaded.get(1); + assertEquals(user2.username, loadedUser2.username); + assertEquals(user2.email, loadedUser2.email); + assertEquals(user2.fullname, loadedUser2.fullname); + assertEquals(user2.password, loadedUser2.password); + assertEquals(user2.birthDay, loadedUser2.birthDay); + } + + @Test + void testLoadUsersWithEmptyFile() { + // Ensure the file is empty + try { + Files.writeString(tempFile, ""); + } catch (IOException e) { + fail("Failed to create empty file: " + e.getMessage()); + } + + List users = repository.loadUsers(false); + assertNotNull(users); + // When the main file is empty, the repository falls back to dummy data + // even when includeDummy is false (this is the intended behavior) + assertEquals(4, users.size()); // Should load dummy users as fallback + } + + @Test + void testLoadUsersIncludingDummy() { + // Since we're using a custom path, this won't actually load dummy data + // but we can test the method call + List users = repository.loadUsers(true); + assertNotNull(users); + } + + @Test + void testDeleteUser() { + // First, save some users + List users = new ArrayList<>(); + + User user1 = new User(); + user1.username = "userToDelete"; + user1.email = "delete@example.com"; + user1.fullname = "Delete Me"; + user1.password = "password"; + user1.birthDay = "1990-01-01"; + users.add(user1); + + User user2 = new User(); + user2.username = "userToKeep"; + user2.email = "keep@example.com"; + user2.fullname = "Keep Me"; + user2.password = "password"; + user2.birthDay = "1990-01-01"; + users.add(user2); + + repository.saveUsers(users); + + // Delete one user + boolean deleted = repository.deleteUser("userToDelete"); + assertTrue(deleted); + + // Verify only one user remains + List remaining = repository.loadUsers(false); + assertEquals(1, remaining.size()); + assertEquals("userToKeep", remaining.get(0).username); + } + + @Test + void testDeleteNonExistentUser() { + // Save one user + List users = new ArrayList<>(); + User user = new User(); + user.username = "existingUser"; + user.email = "existing@example.com"; + users.add(user); + repository.saveUsers(users); + + // Try to delete non-existent user + boolean deleted = repository.deleteUser("nonExistentUser"); + assertFalse(deleted); + + // Verify original user still exists + List remaining = repository.loadUsers(false); + assertEquals(1, remaining.size()); + assertEquals("existingUser", remaining.get(0).username); + } + + @Test + void testSaveUsersThrowsExceptionOnInvalidPath() { + UserDataRepository invalidRepo = new UserDataRepository("/invalid/path/that/does/not/exist.xml"); + List users = new ArrayList<>(); + User user = new User(); + user.username = "test"; + users.add(user); + + assertThrows(RuntimeException.class, () -> invalidRepo.saveUsers(users)); + } + + @Test + void testDefaultConstructor() { + // Test that we can create a repository with default constructor + UserDataRepository defaultRepo = new UserDataRepository(); + assertNotNull(defaultRepo); + + // Should be able to call methods without throwing exceptions + List users = defaultRepo.loadUsers(false); + assertNotNull(users); + } +} diff --git a/src/test/java/com/starlight/model/UserTest.java b/src/test/java/com/starlight/model/UserTest.java new file mode 100644 index 0000000..a83332d --- /dev/null +++ b/src/test/java/com/starlight/model/UserTest.java @@ -0,0 +1,130 @@ +package com.starlight.model; + +import static org.junit.jupiter.api.Assertions.*; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +/** + * Unit tests for {@link User} data model. + */ +public class UserTest { + private User user; + + @BeforeEach + void setUp() { + user = new User(); + } + + @Test + void testUserCreation() { + assertNotNull(user); + assertNull(user.username); + assertNull(user.email); + assertNull(user.fullname); + assertNull(user.password); + assertNull(user.birthDay); + } + + @Test + void testSetAndGetUsername() { + String testUsername = "testuser123"; + user.username = testUsername; + assertEquals(testUsername, user.username); + } + + @Test + void testSetAndGetEmail() { + String testEmail = "test@example.com"; + user.email = testEmail; + assertEquals(testEmail, user.email); + } + + @Test + void testSetAndGetFullname() { + String testFullname = "John Doe"; + user.fullname = testFullname; + assertEquals(testFullname, user.fullname); + } + + @Test + void testSetAndGetPassword() { + String testPassword = "securePassword123"; + user.password = testPassword; + assertEquals(testPassword, user.password); + } + + @Test + void testSetAndGetBirthDay() { + String testBirthDay = "1990-05-15"; + user.birthDay = testBirthDay; + assertEquals(testBirthDay, user.birthDay); + } + + @Test + void testUserWithAllFieldsSet() { + user.username = "johndoe"; + user.email = "john.doe@example.com"; + user.fullname = "John Doe"; + user.password = "myPassword123"; + user.birthDay = "1985-12-25"; + + assertEquals("johndoe", user.username); + assertEquals("john.doe@example.com", user.email); + assertEquals("John Doe", user.fullname); + assertEquals("myPassword123", user.password); + assertEquals("1985-12-25", user.birthDay); + } + + @Test + void testUserFieldsCanBeNull() { + // Ensure that setting fields to null works + user.username = "test"; + user.email = "test@example.com"; + user.fullname = "Test User"; + user.password = "password"; + user.birthDay = "1990-01-01"; + + user.username = null; + user.email = null; + user.fullname = null; + user.password = null; + user.birthDay = null; + + assertNull(user.username); + assertNull(user.email); + assertNull(user.fullname); + assertNull(user.password); + assertNull(user.birthDay); + } + + @Test + void testUserFieldsCanBeEmpty() { + user.username = ""; + user.email = ""; + user.fullname = ""; + user.password = ""; + user.birthDay = ""; + + assertEquals("", user.username); + assertEquals("", user.email); + assertEquals("", user.fullname); + assertEquals("", user.password); + assertEquals("", user.birthDay); + } + + @Test + void testUserWithSpecialCharacters() { + user.username = "user_with-special.chars"; + user.email = "test+tag@example-domain.co.uk"; + user.fullname = "José María O'Connor"; + user.password = "P@ssw0rd!#$"; + user.birthDay = "2000-02-29"; // Leap year + + assertEquals("user_with-special.chars", user.username); + assertEquals("test+tag@example-domain.co.uk", user.email); + assertEquals("José María O'Connor", user.fullname); + assertEquals("P@ssw0rd!#$", user.password); + assertEquals("2000-02-29", user.birthDay); + } +} diff --git a/src/test/java/com/starlight/util/ChatHistoryManagerTest.java b/src/test/java/com/starlight/util/ChatHistoryManagerTest.java new file mode 100644 index 0000000..79c30c7 --- /dev/null +++ b/src/test/java/com/starlight/util/ChatHistoryManagerTest.java @@ -0,0 +1,119 @@ +package com.starlight.util; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import com.starlight.model.ChatHistory; +import com.starlight.model.ChatMessage; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Unit tests for ChatHistoryManager. + */ +public class ChatHistoryManagerTest { + + private ChatHistoryManager historyManager; + private final String testUsername = "testuser"; + + @BeforeEach + void setUp() { + historyManager = new ChatHistoryManager(); + } + + @Test + void testSessionLifecycle() { + // Start a session + assertFalse(historyManager.hasActiveSession()); + historyManager.startSession(testUsername); + assertTrue(historyManager.hasActiveSession()); + + ChatHistory currentSession = historyManager.getCurrentSession(); + assertNotNull(currentSession); + assertEquals(testUsername, currentSession.username); + assertNotNull(currentSession.sessionId); + assertNotNull(currentSession.sessionStart); + assertNull(currentSession.sessionEnd); + + // End the session + historyManager.endSession(); + assertFalse(historyManager.hasActiveSession()); + assertNull(historyManager.getCurrentSession()); + } + + @Test + void testAddMessage() { + historyManager.startSession(testUsername); + + // Add a user message + historyManager.addMessage("Hello, TabemonPal!", true, testUsername); + + ChatHistory currentSession = historyManager.getCurrentSession(); + assertNotNull(currentSession); + assertEquals(1, currentSession.messages.size()); + + ChatMessage message = currentSession.messages.get(0); + assertEquals("Hello, TabemonPal!", message.content); + assertTrue(message.isUser); + assertEquals(testUsername, message.username); + assertNotNull(message.timestamp); + + // Add an AI response + historyManager.addMessage("Hello! How can I help you today?", false, "TabemonPal AI"); + + assertEquals(2, currentSession.messages.size()); + ChatMessage aiMessage = currentSession.messages.get(1); + assertEquals("Hello! How can I help you today?", aiMessage.content); + assertFalse(aiMessage.isUser); + assertEquals("TabemonPal AI", aiMessage.username); + } + + @Test + void testAutoStartSession() { + // Should auto-start session when adding message without active session + assertFalse(historyManager.hasActiveSession()); + + historyManager.addMessage("Test message", true, testUsername); + + assertTrue(historyManager.hasActiveSession()); + ChatHistory currentSession = historyManager.getCurrentSession(); + assertNotNull(currentSession); + assertEquals(1, currentSession.messages.size()); + } + + @Test + void testChatMessage() { + ChatMessage userMessage = new ChatMessage("Test content", true, "testuser"); + + assertEquals("Test content", userMessage.content); + assertTrue(userMessage.isUser); + assertEquals("testuser", userMessage.username); + assertNotNull(userMessage.timestamp); + assertNotNull(userMessage.getFormattedTimestamp()); + + ChatMessage aiMessage = new ChatMessage("AI response", false, "TabemonPal AI"); + assertFalse(aiMessage.isUser); + assertEquals("TabemonPal AI", aiMessage.username); + } + + @Test + void testChatHistoryMethods() { + ChatHistory history = new ChatHistory(testUsername); + + assertEquals(testUsername, history.username); + assertNotNull(history.sessionStart); + assertNull(history.sessionEnd); + assertNotNull(history.sessionId); + assertTrue(history.sessionId.contains(testUsername)); + + // Test session ending + history.endSession(); + assertNotNull(history.sessionEnd); + assertNotNull(history.getSessionDuration()); + + // Test session summary + String summary = history.getSessionSummary(); + assertTrue(summary.contains(testUsername)); + assertTrue(summary.contains("0 messages")); + } +} diff --git a/src/test/java/com/starlight/util/DataRepositoryIntegrationTest.java b/src/test/java/com/starlight/util/DataRepositoryIntegrationTest.java new file mode 100644 index 0000000..7f4f7d7 --- /dev/null +++ b/src/test/java/com/starlight/util/DataRepositoryIntegrationTest.java @@ -0,0 +1,62 @@ +package com.starlight.util; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.BeforeEach; +import static org.junit.jupiter.api.Assertions.*; + +import com.starlight.repository.PostDataRepository; +import com.starlight.repository.UserDataRepository; + +/** + * Integration test to verify that data repositories work correctly with the new file system. + */ +public class DataRepositoryIntegrationTest { + + @BeforeEach + void setUp() { + // Initialize the app data directory before each test + FileSystemManager.initializeAppDataDirectory(); + } + + @Test + void testUserDataRepositoryInitialization() { + // Test that UserDataRepository can initialize and access the new directory structure + UserDataRepository userRepo = new UserDataRepository(); + assertNotNull(userRepo, "UserDataRepository should initialize successfully"); + + // Try to load users (should not throw exception) + assertDoesNotThrow(() -> { + userRepo.loadUsers(); + }, "Loading users should not throw an exception"); + } + + @Test + void testPostDataRepositoryInitialization() { + // Test that PostDataRepository can initialize and access the new directory structure + PostDataRepository postRepo = new PostDataRepository(); + assertNotNull(postRepo, "PostDataRepository should initialize successfully"); + + // Try to load posts (should not throw exception) + assertDoesNotThrow(() -> { + postRepo.loadPosts(); + }, "Loading posts should not throw an exception"); + } + + @Test + void testRepositoriesUseNewDirectoryStructure() { + // Test that repositories are actually using the new directory structure + String databaseDir = FileSystemManager.getDatabaseDirectory(); + + // Verify that the database directory is in the user home .tabemonpal folder + assertTrue(databaseDir.contains(".tabemonpal"), + "Database directory should be in .tabemonpal folder"); + assertTrue(databaseDir.contains("Database"), + "Database directory should contain 'Database' folder"); + + // Verify the repositories can be created without errors + assertDoesNotThrow(() -> { + new UserDataRepository(); + new PostDataRepository(); + }, "Repository creation should not throw exceptions"); + } +} diff --git a/src/test/java/com/starlight/util/FXMLLoadingTest.java b/src/test/java/com/starlight/util/FXMLLoadingTest.java index 379bd57..0946be9 100644 --- a/src/test/java/com/starlight/util/FXMLLoadingTest.java +++ b/src/test/java/com/starlight/util/FXMLLoadingTest.java @@ -10,7 +10,6 @@ import javafx.fxml.FXMLLoader; import javafx.scene.Parent; -import javafx.stage.Stage; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.MethodSource; @@ -23,10 +22,20 @@ public class FXMLLoadingTest extends ApplicationTest { static { - // Headless mode for TestFX (helpful for CI and local dev) + // Enable TestFX headless mode so JavaFX doesn't require a display System.setProperty("testfx.robot", "glass"); + System.setProperty("testfx.headless", "true"); + System.setProperty("glass.platform", "Monocle"); + System.setProperty("monocle.platform", "Headless"); + System.setProperty("prism.order", "sw"); + System.setProperty("prism.text", "t2k"); System.setProperty("java.awt.headless", "true"); - System.setProperty("headless", "true"); + + // Required for proper module access with Monocle in JavaFX 19 + System.setProperty("javafx.platform", "monocle"); + System.setProperty("monocle.headless.update", "false"); + System.setProperty("monocle.keyboard", "null"); + System.setProperty("monocle.mouse", "null"); } // Collect all FXML file resource paths in the view package @@ -37,12 +46,6 @@ static Stream fxmlPaths() throws IOException { .map(p -> "/com/starlight/view/" + p.getFileName().toString()); } - @Override - public void start(Stage stage) { - // Not needed for FXML loading tests, but required by ApplicationTest - // You can use stage.setScene(new Scene(...)) if you want to show nodes. - } - @ParameterizedTest @MethodSource("fxmlPaths") void testLoadFXMLWithLoader(String path) throws Exception { diff --git a/src/test/java/com/starlight/util/FXMLVerificatorTest.java b/src/test/java/com/starlight/util/FXMLVerificatorTest.java new file mode 100644 index 0000000..1f59644 --- /dev/null +++ b/src/test/java/com/starlight/util/FXMLVerificatorTest.java @@ -0,0 +1,218 @@ +package com.starlight.util; + +import static org.junit.jupiter.api.Assertions.*; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +/** + * Unit tests for {@link FXMLVerificator} utility class. + */ +public class FXMLVerificatorTest { + private Path tempDir; + private Path tempFxmlFile; + + @BeforeEach + void setUp() throws IOException { + tempDir = Files.createTempDirectory("fxml-test"); + tempFxmlFile = tempDir.resolve("test.fxml"); + } + + @AfterEach + void tearDown() throws IOException { + Files.deleteIfExists(tempFxmlFile); + Files.deleteIfExists(tempDir); + } + + @Test + void testVerifyAllWithNoFxmlFiles() { + // Test with empty directory - should not throw exception + assertDoesNotThrow(() -> FXMLVerificator.verifyAll()); + } + + @Test + void testFxmlWithCorrectNamespace() throws IOException { + String validFxml = "\n" + + "\n" + + " \n" + + " \n" + + ""; + + Files.writeString(tempFxmlFile, validFxml); + + // This should not throw any exceptions + assertDoesNotThrow(() -> FXMLVerificator.verifyAll()); + } + + @Test + void testFxmlWithIncorrectNamespace() throws IOException { + String fxmlWithOldNamespace = "\n" + + "\n" + + " \n" + + " \n" + + ""; + + Files.writeString(tempFxmlFile, fxmlWithOldNamespace); + + // Should process without throwing exceptions (it should normalize the namespace) + assertDoesNotThrow(() -> FXMLVerificator.verifyAll()); + } + + @Test + void testFxmlWithIllegalAttribute() throws IOException { + // Create a temporary FXML file in the resources directory + Path resourcesDir = Paths.get("src/main/resources"); + Files.createDirectories(resourcesDir); + Path testFxmlFile = resourcesDir.resolve("test_illegal.fxml"); + + try { + String fxmlWithIllegalAttr = "\n" + + "\n" + + " \n" + + " \n" + + ""; + + Files.writeString(testFxmlFile, fxmlWithIllegalAttr); + + // Should throw RuntimeException due to illegal attribute + assertThrows(RuntimeException.class, () -> FXMLVerificator.verifyAll()); + } finally { + // Clean up + Files.deleteIfExists(testFxmlFile); + } + } + + @Test + void testFxmlWithUnsupportedTag() throws IOException { + // Create a temporary FXML file in the resources directory + Path resourcesDir = Paths.get("src/main/resources"); + Files.createDirectories(resourcesDir); + Path testFxmlFile = resourcesDir.resolve("test_unsupported.fxml"); + + try { + String fxmlWithUnsupportedTag = "\n" + + "\n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + ""; + + Files.writeString(testFxmlFile, fxmlWithUnsupportedTag); + + // Should throw RuntimeException due to unsupported tag + assertThrows(RuntimeException.class, () -> FXMLVerificator.verifyAll()); + } finally { + // Clean up + Files.deleteIfExists(testFxmlFile); + } + } + + @Test + void testFxmlWithCustomComponents() throws IOException { + String fxmlWithCustom = "\n" + + "\n" + + " \n" + + "
\n" + + " \n" + + " \n" + + "
"; + + Files.writeString(tempFxmlFile, complexFxml); + + // Should process complex structure successfully + assertDoesNotThrow(() -> FXMLVerificator.verifyAll()); + } + + @Test + void testEmptyFxmlFile() throws IOException { + Files.writeString(tempFxmlFile, ""); + + // Should handle empty file gracefully + assertDoesNotThrow(() -> FXMLVerificator.verifyAll()); + } + + @Test + void testInvalidXmlFormat() throws IOException { + String invalidXml = "\n" + + "\n" + + " \n" + + " "; + + Files.writeString(tempFxmlFile, invalidXml); + + // Should handle malformed XML gracefully or throw appropriate exception + // The behavior depends on implementation - it might process or throw + assertDoesNotThrow(() -> FXMLVerificator.verifyAll()); + } +} diff --git a/src/test/java/com/starlight/util/FileSystemAccessTest.java b/src/test/java/com/starlight/util/FileSystemAccessTest.java new file mode 100644 index 0000000..e2f089a --- /dev/null +++ b/src/test/java/com/starlight/util/FileSystemAccessTest.java @@ -0,0 +1,183 @@ +package com.starlight.util; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.AfterEach; +import static org.junit.jupiter.api.Assertions.*; + +import java.io.File; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; + +/** + * Test class for verifying the application's ability to access and use + * the file system for storing user data and application files. + */ +public class FileSystemAccessTest { + + private static final String TEST_USERNAME = "testuser"; + private static final String TEST_FILENAME = "test_image.png"; + + @BeforeEach + void setUp() { + // Initialize the app data directory before each test + FileSystemManager.initializeAppDataDirectory(); + } + + @AfterEach + void tearDown() { + // Clean up test files after each test + try { + Path testUserDir = Paths.get(FileSystemManager.getUserDataDirectory(), TEST_USERNAME); + if (Files.exists(testUserDir)) { + Files.walk(testUserDir) + .sorted(java.util.Comparator.reverseOrder()) // Delete files before directories + .forEach(path -> { + try { + Files.deleteIfExists(path); + } catch (Exception e) { + // Ignore cleanup errors + } + }); + } + } catch (Exception e) { + // Ignore cleanup errors + } + } + + @Test + void testAppDataDirectoryIsAccessible() { + // Test that the app data directory is accessible and writable + assertTrue(FileSystemManager.isAppDataDirectoryAccessible(), + "App data directory should be accessible and writable"); + } + + @Test + void testDirectoryCreation() { + // Test that required directories are created + assertTrue(Files.exists(Paths.get(FileSystemManager.getAppDataDirectory())), + "Main app data directory should exist"); + assertTrue(Files.exists(Paths.get(FileSystemManager.getUserDataDirectory())), + "User data directory should exist"); + assertTrue(Files.exists(Paths.get(FileSystemManager.getDatabaseDirectory())), + "Database directory should exist"); + } + + @Test + void testUserImageDirectoryCreation() { + // Test creating a user-specific directory + String userDir = FileSystemManager.createUserImageDirectory(TEST_USERNAME); + + assertNotNull(userDir, "User directory creation should return a path"); + assertTrue(Files.exists(Paths.get(userDir)), + "User directory should exist after creation"); + assertEquals(Paths.get(FileSystemManager.getUserDataDirectory(), TEST_USERNAME).toString(), userDir, + "User directory should be in the correct location"); + } + + @Test + void testFileCopyToUserDirectory() throws Exception { + // Create a temporary test file + Path tempFile = Files.createTempFile("test", ".png"); + Files.write(tempFile, "test image data".getBytes()); + + try { + // Test copying file to user directory + String copiedFilePath = FileSystemManager.copyFileToUserDirectory( + tempFile.toFile(), TEST_USERNAME, TEST_FILENAME); + + assertNotNull(copiedFilePath, "File copy should return a path"); + assertTrue(Files.exists(Paths.get(copiedFilePath)), + "Copied file should exist"); + + // Verify file contents + byte[] originalContent = Files.readAllBytes(tempFile); + byte[] copiedContent = Files.readAllBytes(Paths.get(copiedFilePath)); + assertArrayEquals(originalContent, copiedContent, + "Copied file should have same content as original"); + + } finally { + // Clean up temp file + Files.deleteIfExists(tempFile); + } + } + + @Test + void testFileCopyWithUniqueFilename() throws Exception { + // Create a temporary test file + Path tempFile = Files.createTempFile("test", ".jpg"); + Files.write(tempFile, "test image data".getBytes()); + + try { + // Test copying file with unique filename + String copiedFilePath1 = FileSystemManager.copyFileToUserDirectoryWithUniqueFilename( + tempFile.toFile(), TEST_USERNAME); + String copiedFilePath2 = FileSystemManager.copyFileToUserDirectoryWithUniqueFilename( + tempFile.toFile(), TEST_USERNAME); + + assertNotNull(copiedFilePath1, "First file copy should return a path"); + assertNotNull(copiedFilePath2, "Second file copy should return a path"); + assertNotEquals(copiedFilePath1, copiedFilePath2, + "Two copies should have different filenames"); + + assertTrue(Files.exists(Paths.get(copiedFilePath1)), + "First copied file should exist"); + assertTrue(Files.exists(Paths.get(copiedFilePath2)), + "Second copied file should exist"); + + } finally { + // Clean up temp file + Files.deleteIfExists(tempFile); + } + } + + @Test + void testCrossplatformPath() { + // Test that paths work correctly on different platforms + String appDataDir = FileSystemManager.getAppDataDirectory(); + String userDataDir = FileSystemManager.getUserDataDirectory(); + String databaseDir = FileSystemManager.getDatabaseDirectory(); + + // Verify paths contain the correct separators for the current platform + assertTrue(appDataDir.contains(File.separator), + "App data directory should use correct file separator"); + assertTrue(userDataDir.startsWith(appDataDir), + "User data directory should be under app data directory"); + assertTrue(databaseDir.startsWith(appDataDir), + "Database directory should be under app data directory"); + } + + @Test + void testDirectoryPermissions() throws Exception { + // Test that directories have correct permissions + Path appDir = Paths.get(FileSystemManager.getAppDataDirectory()); + Path userDir = Paths.get(FileSystemManager.getUserDataDirectory()); + Path dbDir = Paths.get(FileSystemManager.getDatabaseDirectory()); + + assertTrue(Files.isWritable(appDir), "App directory should be writable"); + assertTrue(Files.isWritable(userDir), "User data directory should be writable"); + assertTrue(Files.isWritable(dbDir), "Database directory should be writable"); + + assertTrue(Files.isReadable(appDir), "App directory should be readable"); + assertTrue(Files.isReadable(userDir), "User data directory should be readable"); + assertTrue(Files.isReadable(dbDir), "Database directory should be readable"); + } + + @Test + void testFileSystemAccessFromJar() { + // Test that the file system access works the same way when running from JAR + // This test verifies that we're not using any development-only file access methods + + // The app data directory should be in user home, not in project directory + String appDataDir = FileSystemManager.getAppDataDirectory(); + String userHome = System.getProperty("user.home"); + + assertTrue(appDataDir.startsWith(userHome), + "App data directory should be in user home directory"); + assertFalse(appDataDir.contains("src"), + "App data directory should not be in project source directory"); + assertFalse(appDataDir.contains("target"), + "App data directory should not be in project target directory"); + } +} diff --git a/src/test/java/com/starlight/util/NutritionParserTest.java b/src/test/java/com/starlight/util/NutritionParserTest.java new file mode 100644 index 0000000..cc3fbae --- /dev/null +++ b/src/test/java/com/starlight/util/NutritionParserTest.java @@ -0,0 +1,155 @@ +package com.starlight.util; + +import static org.junit.jupiter.api.Assertions.*; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import com.starlight.model.Nutrition; + +/** + * Unit tests for the NutritionParser utility class. + */ +public class NutritionParserTest { + + private NutritionParser parser; + + @BeforeEach + void setUp() { + parser = new NutritionParser(); + } + + @Test + void testParseValidNutritionXML() { + String aiResponse = "Here's the nutrition analysis:\n\n" + + "\n" + + " \n" + + " 331\n" + + " 62.2\n" + + " 7.2\n" + + " 0\n" + + " 0\n" + + " 0\n" + + " 146\n" + + " \n" + + " \n" + + " 205\n" + + " 4.2\n" + + " 0.4\n" + + " 45\n" + + " 0.6\n" + + " 0.1\n" + + " 2\n" + + " \n" + + "\n\n" + + "This provides a balanced meal with good protein content."; + + Nutrition nutrition = parser.parseNutritionFromResponse(aiResponse); + + assertNotNull(nutrition, "Nutrition object should not be null"); + assertNotNull(nutrition.ingredient, "Ingredient list should not be null"); + assertEquals(2, nutrition.ingredient.size(), "Should have 2 ingredients"); + assertEquals("Healthy", nutrition.verdict, "Should have correct verdict"); + + // Check first ingredient + Nutrition.NutritionIngredient chicken = nutrition.ingredient.get(0); + assertEquals("Chicken Breast", chicken.name); + assertEquals("200g", chicken.amount); + assertEquals("331", chicken.calories.value); + assertEquals("62.2", chicken.protein.value); + assertEquals("7.2", chicken.fat.value); + assertEquals("0", chicken.carbohydrates.value); + assertEquals("0", chicken.fiber.value); + assertEquals("0", chicken.sugar.value); + assertEquals("146", chicken.salt.value); + + // Check second ingredient + Nutrition.NutritionIngredient rice = nutrition.ingredient.get(1); + assertEquals("Rice", rice.name); + assertEquals("1 cup cooked", rice.amount); + assertEquals("205", rice.calories.value); + assertEquals("4.2", rice.protein.value); + assertEquals("0.4", rice.fat.value); + assertEquals("45", rice.carbohydrates.value); + assertEquals("0.6", rice.fiber.value); + assertEquals("0.1", rice.sugar.value); + assertEquals("2", rice.salt.value); + + // Test totals + assertEquals(536.0, nutrition.getTotalCalories(), 0.1); + assertEquals(66.4, nutrition.getTotalProtein(), 0.1); + assertEquals(7.6, nutrition.getTotalFat(), 0.1); + assertEquals(45.0, nutrition.getTotalCarbohydrates(), 0.1); + assertEquals(0.6, nutrition.getTotalFiber(), 0.1); + assertEquals(0.1, nutrition.getTotalSugar(), 0.1); + assertEquals(148.0, nutrition.getTotalSalt(), 0.1); + } + + @Test + void testParseInvalidResponse() { + String invalidResponse = "Sorry, I couldn't analyze the nutrition facts."; + + Nutrition nutrition = parser.parseNutritionFromResponse(invalidResponse); + + assertNotNull(nutrition, "Should return fallback nutrition object"); + assertNotNull(nutrition.ingredient, "Should have fallback ingredient"); + assertEquals(1, nutrition.ingredient.size(), "Should have one fallback ingredient"); + assertEquals("Recipe", nutrition.ingredient.get(0).name); + assertEquals("Unknown", nutrition.verdict, "Should have Unknown verdict for fallback"); + } + + @Test + void testParseNullResponse() { + Nutrition nutrition = parser.parseNutritionFromResponse(null); + + assertNotNull(nutrition, "Should return fallback nutrition object"); + assertNotNull(nutrition.ingredient, "Should have fallback ingredient"); + assertEquals(1, nutrition.ingredient.size(), "Should have one fallback ingredient"); + } + + @Test + void testParseEmptyResponse() { + Nutrition nutrition = parser.parseNutritionFromResponse(""); + + assertNotNull(nutrition, "Should return fallback nutrition object"); + assertNotNull(nutrition.ingredient, "Should have fallback ingredient"); + assertEquals(1, nutrition.ingredient.size(), "Should have one fallback ingredient"); + } + + @Test + void testParseMalformedXML() { + String malformedResponse = "\n" + + " \n" + + " 52\n" + + " \n" + + ""; + + Nutrition nutrition = parser.parseNutritionFromResponse(malformedResponse); + + assertNotNull(nutrition, "Should return fallback nutrition object on parsing error"); + } + + @Test + void testParseUnknownVerdict() { + String aiResponse = "Here's the nutrition analysis:\n\n" + + "\n" + + " \n" + + " 100\n" + + " 5\n" + + " 3\n" + + " 15\n" + + " 2\n" + + " 8\n" + + " 100\n" + + " \n" + + "\n\n" + + "Nutritional analysis could not be fully determined."; + + Nutrition nutrition = parser.parseNutritionFromResponse(aiResponse); + + assertNotNull(nutrition, "Nutrition object should not be null"); + assertEquals("Unknown", nutrition.verdict, "Should have Unknown verdict"); + assertEquals(1, nutrition.ingredient.size(), "Should have 1 ingredient"); + assertEquals("Mystery Ingredient", nutrition.ingredient.get(0).name); + } +} diff --git a/src/test/java/com/starlight/util/SessionTest.java b/src/test/java/com/starlight/util/SessionTest.java new file mode 100644 index 0000000..5988bed --- /dev/null +++ b/src/test/java/com/starlight/util/SessionTest.java @@ -0,0 +1,163 @@ +package com.starlight.util; + +import static org.junit.jupiter.api.Assertions.*; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import com.starlight.model.User; + +/** + * Unit tests for {@link Session} utility class. + */ +public class SessionTest { + private User testUser; + + @BeforeEach + void setUp() { + // Clear session before each test + Session.setCurrentUser(null); + + // Create a test user + testUser = new User(); + testUser.username = "testuser"; + testUser.email = "test@example.com"; + testUser.fullname = "Test User"; + testUser.password = "password123"; + testUser.birthDay = "1990-01-01"; + } + + @AfterEach + void tearDown() { + // Clean up session after each test + Session.setCurrentUser(null); + } + + @Test + void testGetCurrentUserInitiallyNull() { + User currentUser = Session.getCurrentUser(); + assertNull(currentUser); + } + + @Test + void testSetAndGetCurrentUser() { + Session.setCurrentUser(testUser); + User currentUser = Session.getCurrentUser(); + + assertNotNull(currentUser); + assertEquals(testUser, currentUser); + assertEquals("testuser", currentUser.username); + assertEquals("test@example.com", currentUser.email); + assertEquals("Test User", currentUser.fullname); + assertEquals("password123", currentUser.password); + assertEquals("1990-01-01", currentUser.birthDay); + } + + @Test + void testSetCurrentUserToNull() { + // First set a user + Session.setCurrentUser(testUser); + assertNotNull(Session.getCurrentUser()); + + // Then set to null + Session.setCurrentUser(null); + assertNull(Session.getCurrentUser()); + } + + @Test + void testUpdateCurrentUser() { + // Set initial user + Session.setCurrentUser(testUser); + assertEquals("testuser", Session.getCurrentUser().username); + + // Create a different user + User newUser = new User(); + newUser.username = "newuser"; + newUser.email = "new@example.com"; + newUser.fullname = "New User"; + newUser.password = "newpassword"; + newUser.birthDay = "1985-05-15"; + + // Update current user + Session.setCurrentUser(newUser); + User currentUser = Session.getCurrentUser(); + + assertNotNull(currentUser); + assertEquals(newUser, currentUser); + assertEquals("newuser", currentUser.username); + assertEquals("new@example.com", currentUser.email); + assertEquals("New User", currentUser.fullname); + assertEquals("newpassword", currentUser.password); + assertEquals("1985-05-15", currentUser.birthDay); + } + + @Test + void testSessionPersistsAcrossMultipleCalls() { + Session.setCurrentUser(testUser); + + // Multiple calls should return the same user + User user1 = Session.getCurrentUser(); + User user2 = Session.getCurrentUser(); + User user3 = Session.getCurrentUser(); + + assertEquals(user1, user2); + assertEquals(user2, user3); + assertEquals(testUser, user1); + assertEquals(testUser, user2); + assertEquals(testUser, user3); + } + + @Test + void testSessionIsStaticSingleton() { + // Test that Session behaves as a singleton across different instances + Session.setCurrentUser(testUser); + + // Even though we're calling static methods, the state should be consistent + assertEquals(testUser, Session.getCurrentUser()); + + // Modify user through session reference + User sessionUser = Session.getCurrentUser(); + sessionUser.fullname = "Modified Name"; + + // The change should be reflected when getting the user again + assertEquals("Modified Name", Session.getCurrentUser().fullname); + } + + @Test + void testSetCurrentUserWithPartiallyPopulatedUser() { + User partialUser = new User(); + partialUser.username = "partial"; + // Leave other fields null + + Session.setCurrentUser(partialUser); + User currentUser = Session.getCurrentUser(); + + assertNotNull(currentUser); + assertEquals("partial", currentUser.username); + assertNull(currentUser.email); + assertNull(currentUser.fullname); + assertNull(currentUser.password); + assertNull(currentUser.birthDay); + } + + @Test + void testSetCurrentUserWithEmptyFieldsUser() { + User emptyUser = new User(); + emptyUser.username = ""; + emptyUser.email = ""; + emptyUser.fullname = ""; + emptyUser.password = ""; + emptyUser.birthDay = ""; + + Session.setCurrentUser(emptyUser); + User currentUser = Session.getCurrentUser(); + + assertNotNull(currentUser); + assertEquals("", currentUser.username); + assertEquals("", currentUser.email); + assertEquals("", currentUser.fullname); + assertEquals("", currentUser.password); + assertEquals("", currentUser.birthDay); + } +}