diff --git a/src/main/java/com/example/HelloFX.java b/src/main/java/com/example/HelloFX.java
index 96bdc5ca..87b344a6 100644
--- a/src/main/java/com/example/HelloFX.java
+++ b/src/main/java/com/example/HelloFX.java
@@ -1,25 +1,38 @@
package com.example;
+import com.example.util.EnvLoader;
import javafx.application.Application;
import javafx.fxml.FXMLLoader;
-import javafx.scene.Parent;
import javafx.scene.Scene;
import javafx.stage.Stage;
+import java.io.IOException;
+
public class HelloFX extends Application {
+ /**
+ * Initializes the primary application window from the ChatView.fxml layout and displays it.
+ *
+ * @param stage the primary stage to initialize and show
+ * @throws IOException if the FXML resource cannot be loaded
+ */
@Override
- public void start(Stage stage) throws Exception {
- FXMLLoader fxmlLoader = new FXMLLoader(HelloFX.class.getResource("hello-view.fxml"));
- Parent root = fxmlLoader.load();
- Scene scene = new Scene(root, 640, 480);
- stage.setTitle("Hello MVC");
+ public void start(Stage stage) throws IOException {
+ FXMLLoader fxmlLoader = new FXMLLoader(HelloFX.class.getResource("ChatView.fxml"));
+ Scene scene = new Scene(fxmlLoader.load());
+
+ stage.setTitle("Java25 Chat App");
stage.setScene(scene);
stage.show();
}
+ /**
+ * Application entry point that loads environment configuration and launches the JavaFX application.
+ *
+ * @param args command-line arguments passed to the application
+ */
public static void main(String[] args) {
+ EnvLoader.load();
launch();
}
-
}
\ No newline at end of file
diff --git a/src/main/java/com/example/HelloModel.java b/src/main/java/com/example/HelloModel.java
index 385cfd10..fe58e68e 100644
--- a/src/main/java/com/example/HelloModel.java
+++ b/src/main/java/com/example/HelloModel.java
@@ -1,15 +1,18 @@
package com.example;
-/**
- * Model layer: encapsulates application data and business logic.
- */
public class HelloModel {
+
/**
- * Returns a greeting based on the current Java and JavaFX versions.
+ * Builds a greeting string that includes the JavaFX and Java runtime versions.
+ *
+ *
The method reads the system properties "javafx.version" and "java.version" and inserts their
+ * values into the greeting text.
+ *
+ * @return the greeting in the form "Hello, JavaFX {javafxVersion}, running on Java {javaVersion}."
*/
public String getGreeting() {
String javaVersion = System.getProperty("java.version");
String javafxVersion = System.getProperty("javafx.version");
return "Hello, JavaFX " + javafxVersion + ", running on Java " + javaVersion + ".";
}
-}
+}
\ No newline at end of file
diff --git a/src/main/java/com/example/controller/ChatController.java b/src/main/java/com/example/controller/ChatController.java
new file mode 100644
index 00000000..81fc85cf
--- /dev/null
+++ b/src/main/java/com/example/controller/ChatController.java
@@ -0,0 +1,176 @@
+package com.example.controller;
+
+import com.example.model.ChatModel;
+import com.example.model.NtfyMessage;
+import javafx.fxml.FXML;
+import javafx.scene.control.ListView;
+import javafx.scene.control.TextField;
+import javafx.scene.control.Label;
+import javafx.scene.control.ListCell;
+import javafx.concurrent.Task;
+import javafx.geometry.Pos;
+import javafx.stage.FileChooser;
+
+import java.io.File;
+
+public class ChatController {
+
+ @FXML
+ private ListView messageListView;
+
+ @FXML
+ private TextField messageInput;
+
+ @FXML
+ private Label statusLabel;
+
+ private ChatModel model;
+
+ /**
+ * Initializes controller state and configures the message list view and its listeners.
+ *
+ * Instantiates the ChatModel, updates the status display to online, installs a custom cell
+ * factory to control presentation of message strings, and registers a listener on the model's
+ * message list that appends newly received NtfyMessage text (prefixed with an emoji) to the
+ * ListView and scrolls to the newest item.
+ */
+ @FXML
+ public void initialize() {
+ this.model = new ChatModel();
+
+ updateStatusOnline();
+
+ messageListView.setCellFactory(listView -> new ListCell() {
+ @Override
+ protected void updateItem(String item, boolean empty) {
+ super.updateItem(item, empty);
+ if (empty || item == null) {
+ setText(null);
+ setGraphic(null);
+ setStyle("");
+ } else {
+ setText(item);
+ setAlignment(Pos.CENTER_LEFT);
+ setStyle("-fx-background-color: rgba(255, 250, 240, 0.6); " +
+ "-fx-text-fill: #3d3d3d; " +
+ "-fx-padding: 14 18 14 18; " +
+ "-fx-border-color: rgba(214, 69, 69, 0.15); " +
+ "-fx-border-width: 0 0 0 3; " +
+ "-fx-font-size: 13px; " +
+ "-fx-background-radius: 0; " +
+ "-fx-border-radius: 0; " +
+ "-fx-effect: dropshadow(gaussian, rgba(0,0,0,0.05), 3, 0, 0, 2);");
+ }
+ }
+ });
+
+ model.getMessages().addListener((javafx.collections.ListChangeListener.Change extends NtfyMessage> change) -> {
+ while (change.next()) {
+ if (change.wasAdded()) {
+ for (NtfyMessage msg : change.getAddedSubList()) {
+ String formatted = "πΈ " + msg.message();
+ messageListView.getItems().add(formatted);
+ }
+ messageListView.scrollTo(messageListView.getItems().size() - 1);
+ }
+ }
+ });
+ }
+
+ /**
+ * Sends the current text from the message input through the model.
+ *
+ * If the input contains non-empty text, initiates a background send operation.
+ * On success the input is cleared and the status is updated to online.
+ * On failure an error message is written to standard error and the status is updated to offline.
+ */
+ @FXML
+ private void handleSendButtonAction() {
+ String text = messageInput.getText();
+ if (text != null && !text.trim().isEmpty()) {
+ Task task = new Task<>() {
+ @Override
+ protected Void call() throws Exception {
+ model.sendMessage(text.trim());
+ return null;
+ }
+ };
+
+ task.setOnSucceeded(e -> {
+ messageInput.clear();
+ updateStatusOnline();
+ });
+
+ task.setOnFailed(e -> {
+ System.err.println("Send failed: " + task.getException().getMessage());
+ updateStatusOffline();
+ });
+
+ new Thread(task).start();
+ }
+ }
+
+ /**
+ * Opens a file chooser for the user to pick a file and sends the selected file via the model.
+ *
+ * If the user selects a file, the file is sent on a background thread; on success a confirmation
+ * message is printed to standard output and on failure an error message is printed to standard error.
+ * If the user cancels the dialog, no action is taken.
+ */
+ @FXML
+ private void handleAttachFileAction() {
+ FileChooser fileChooser = new FileChooser();
+ fileChooser.setTitle("Select File");
+ File file = fileChooser.showOpenDialog(messageInput.getScene().getWindow());
+
+ if (file != null) {
+ Task task = new Task<>() {
+ @Override
+ protected Void call() throws Exception {
+ model.sendFile(file);
+ return null;
+ }
+ };
+
+ task.setOnSucceeded(e -> {
+ System.out.println("β
File info sent: " + file.getName());
+ });
+
+ task.setOnFailed(e -> {
+ System.err.println("β File send failed: " + task.getException().getMessage());
+ });
+
+ new Thread(task).start();
+ }
+ }
+
+ /**
+ * Updates the status label to show an "online" state.
+ *
+ * If a status label is present, sets its text to "β online" and applies a red text color,
+ * 10px font size, and a subtle drop shadow; does nothing if the label is null.
+ */
+ private void updateStatusOnline() {
+ if (statusLabel != null) {
+ statusLabel.setText("β online");
+ statusLabel.setStyle("-fx-text-fill: #c93939; " +
+ "-fx-font-size: 10px; " +
+ "-fx-effect: dropshadow(gaussian, rgba(0,0,0,0.3), 2, 0, 0, 1);");
+ }
+ }
+
+ /**
+ * Update the status label to indicate the application is offline and apply muted styling.
+ *
+ * If the status label exists, sets its text to "β offline" and applies a muted brownish color,
+ * a small font size, and a subtle drop shadow.
+ */
+ private void updateStatusOffline() {
+ if (statusLabel != null) {
+ statusLabel.setText("β offline");
+ statusLabel.setStyle("-fx-text-fill: #6b5d54; " +
+ "-fx-font-size: 10px; " +
+ "-fx-effect: dropshadow(gaussian, rgba(0,0,0,0.3), 2, 0, 0, 1);");
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/example/model/ChatModel.java b/src/main/java/com/example/model/ChatModel.java
new file mode 100644
index 00000000..3709058a
--- /dev/null
+++ b/src/main/java/com/example/model/ChatModel.java
@@ -0,0 +1,114 @@
+package com.example.model;
+
+import com.example.network.ChatNetworkClient;
+import com.example.network.NtfyHttpClient;
+import javafx.application.Platform;
+import javafx.collections.FXCollections;
+import javafx.collections.ObservableList;
+
+import java.io.File;
+
+public class ChatModel {
+
+ private final ObservableList messages = FXCollections.observableArrayList();
+ private final ChatNetworkClient networkClient;
+ private ChatNetworkClient.Subscription subscription;
+ private final String baseUrl;
+ private final String topic;
+
+ /**
+ * Constructs a ChatModel, initializing the network client, selecting the NTFY base URL,
+ * setting the topic to "myChatTopic", and establishing the subscription.
+ *
+ * The base URL is chosen in this order of precedence: the system property
+ * "NTFY_BASE_URL", the environment variable "NTFY_BASE_URL", and finally the default
+ * "http://localhost:8080". This constructor prints the chosen URL and calls {@code connect()}
+ * to begin receiving messages.
+ */
+ public ChatModel() {
+ this.networkClient = new NtfyHttpClient();
+
+ String url = System.getProperty("NTFY_BASE_URL");
+ if (url == null || url.isBlank()) {
+ url = System.getenv("NTFY_BASE_URL");
+ }
+ if (url == null || url.isBlank()) {
+ url = "http://localhost:8080";
+ }
+
+ this.baseUrl = url;
+ this.topic = "myChatTopic";
+
+ System.out.println("Using NTFY URL: " + this.baseUrl);
+ connect();
+ }
+
+ /**
+ * Provides the observable list of chat messages.
+ *
+ * @return the observable list of NtfyMessage instances; changes to this list are observable and reflect the model's current messages
+ */
+ public ObservableList getMessages() {
+ return messages;
+ }
+
+ /**
+ * Establishes a subscription to the configured Ntfy topic and begins receiving messages.
+ *
+ * Incoming messages are appended to the model's observable messages list on the JavaFX
+ * application thread. Subscription errors are written to standard error.
+ */
+ public void connect() {
+ this.subscription = networkClient.subscribe(
+ baseUrl,
+ topic,
+ msg -> Platform.runLater(() -> messages.add(msg)),
+ error -> System.err.println("Error: " + error.getMessage())
+ );
+ }
+
+ /**
+ * Publish a text message to the configured chat topic.
+ *
+ * @param text the message content to send
+ * @throws Exception if sending the message fails
+ */
+ public void sendMessage(String text) throws Exception {
+ NtfyMessage message = new NtfyMessage(topic, text);
+ networkClient.send(baseUrl, message);
+ }
+
+ /**
+ * Sends the given file to the configured chat topic and posts a chat message describing the file.
+ *
+ * Validates that the file exists, uploads it via the network client, then sends a message containing
+ * the file name and a human-readable file size.
+ *
+ * @param file the file to send; must be non-null and exist on disk
+ * @throws IllegalArgumentException if {@code file} is null or does not exist
+ * @throws Exception if the upload or subsequent message send fails
+ */
+ public void sendFile(File file) throws Exception {
+ if (file == null || !file.exists()) {
+ throw new IllegalArgumentException("File not found");
+ }
+
+ networkClient.sendFile(baseUrl, topic, file);
+
+ String fileMessage = "π " + file.getName() + " (" + formatFileSize(file.length()) + ")";
+ sendMessage(fileMessage);
+ }
+
+ /**
+ * Format a byte count into a human-readable string using B, KB, MB, or GB.
+ *
+ * @param size the size in bytes
+ * @return a formatted size string (e.g., "512 B", "1.5 KB", "2.0 MB", or "3.2 GB")
+ */
+ private String formatFileSize(long size) {
+ if (size < 1024) return size + " B";
+ if (size < 1024 * 1024) return String.format("%.1f KB", size / 1024.0);
+ if (size < 1024 * 1024 * 1024) return String.format("%.1f MB", size / (1024.0 * 1024));
+ return String.format("%.1f GB", size / (1024.0 * 1024 * 1024));
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/example/model/NtfyMessage.java b/src/main/java/com/example/model/NtfyMessage.java
new file mode 100644
index 00000000..2d1c5cd8
--- /dev/null
+++ b/src/main/java/com/example/model/NtfyMessage.java
@@ -0,0 +1,35 @@
+package com.example.model;
+
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+
+@JsonIgnoreProperties(ignoreUnknown = true)
+public record NtfyMessage(
+ String id,
+ Long time,
+ String event,
+ String topic,
+ String message,
+ String attachment // Nytt fΓ€lt fΓΆr filer
+) {
+ /**
+ * Creates a NtfyMessage with the specified topic and message, using default values for id, time, event, and attachment.
+ *
+ * @param topic the topic for the message
+ * @param message the message body
+ */
+ public NtfyMessage(String topic, String message) {
+ this(null, null, "message", topic, message, null);
+ }
+
+ /**
+ * Creates a NtfyMessage for the given topic containing the provided message and attachment,
+ * with `id` and `time` set to null and `event` set to "message".
+ *
+ * @param topic the message topic
+ * @param message the message payload
+ * @param attachment optional attachment data (e.g., serialized file content or reference)
+ */
+ public NtfyMessage(String topic, String message, String attachment) {
+ this(null, null, "message", topic, message, attachment);
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/example/network/ChatNetworkClient.java b/src/main/java/com/example/network/ChatNetworkClient.java
new file mode 100644
index 00000000..f9751927
--- /dev/null
+++ b/src/main/java/com/example/network/ChatNetworkClient.java
@@ -0,0 +1,49 @@
+package com.example.network;
+
+import com.example.model.NtfyMessage;
+import java.io.File;
+import java.io.IOException;
+import java.util.function.Consumer;
+
+public interface ChatNetworkClient {
+ /**
+ * Sends an NtfyMessage to the service at the specified base URL.
+ *
+ * @param baseUrl the base URL of the ntfy service (e.g., "https://ntfy.example.com")
+ * @param message the message to deliver
+ * @throws IOException if a network or I/O error occurs while sending the message
+ * @throws InterruptedException if the calling thread is interrupted while sending
+ */
+void send(String baseUrl, NtfyMessage message) throws IOException, InterruptedException;
+
+ /**
+ * Sends a file to the specified topic at the given base URL.
+ *
+ * @param baseUrl the server base URL to send the file to
+ * @param topic the destination topic or channel on the server
+ * @param file the file to upload; it must exist and be readable
+ * @throws IOException if an I/O error occurs while sending the file
+ * @throws InterruptedException if the operation is interrupted
+ */
+void sendFile(String baseUrl, String topic, File file) throws IOException, InterruptedException;
+
+ /**
+ * Subscribes to messages for a topic at the specified base URL.
+ *
+ * @param baseUrl the base URL of the server to connect to
+ * @param topic the topic to subscribe to
+ * @param onMessage callback invoked for each received {@link com.example.model.NtfyMessage}
+ * @param onError callback invoked with a {@link Throwable} if an error occurs while receiving messages
+ * @return an active {@link Subscription} whose {@code close()} method terminates the subscription
+ */
+Subscription subscribe(String baseUrl, String topic, Consumer onMessage, Consumer onError);
+
+ interface Subscription {
+ /**
+ * Terminates the active subscription.
+ *
+ * Closes any underlying resources and stops delivery of further messages for this subscription.
+ */
+void close();
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/example/network/NtfyHttpClient.java b/src/main/java/com/example/network/NtfyHttpClient.java
new file mode 100644
index 00000000..d70641d1
--- /dev/null
+++ b/src/main/java/com/example/network/NtfyHttpClient.java
@@ -0,0 +1,148 @@
+package com.example.network;
+
+import com.example.model.NtfyMessage;
+import com.fasterxml.jackson.databind.ObjectMapper;
+
+import java.io.BufferedReader;
+import java.io.File;
+import java.io.IOException;
+import java.io.InputStreamReader;
+import java.net.URI;
+import java.net.URLEncoder;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.util.concurrent.CompletableFuture;
+import java.util.function.Consumer;
+
+public class NtfyHttpClient implements ChatNetworkClient {
+
+ private final HttpClient http;
+ private final ObjectMapper objectMapper;
+
+ /**
+ * Creates a new NtfyHttpClient configured for HTTP requests and JSON (de)serialization.
+ *
+ * Initializes an internal HttpClient for network operations and a Jackson ObjectMapper
+ * for JSON serialization and deserialization.
+ */
+ public NtfyHttpClient() {
+ this.http = HttpClient.newHttpClient();
+ this.objectMapper = new ObjectMapper();
+ }
+
+ /**
+ * Sends the given NtfyMessage to the specified base URL using an HTTP POST.
+ *
+ * The message is serialized to JSON and posted to the base URL (any trailing slash is removed).
+ *
+ * @param baseUrl the destination base URL for the POST request
+ * @param message the message to serialize and send
+ * @throws IOException if the request fails or the response status code is not 200
+ * @throws InterruptedException if the thread is interrupted while waiting for the response
+ */
+ @Override
+ public void send(String baseUrl, NtfyMessage message) throws IOException, InterruptedException {
+ String json = objectMapper.writeValueAsString(message);
+
+ HttpRequest request = HttpRequest.newBuilder()
+ .uri(URI.create(ensureNoTrailingSlash(baseUrl)))
+ .header("Content-Type", "application/json")
+ .POST(HttpRequest.BodyPublishers.ofString(json, StandardCharsets.UTF_8))
+ .build();
+
+ HttpResponse response = http.send(request, HttpResponse.BodyHandlers.ofString());
+
+ if (response.statusCode() != 200) {
+ throw new IOException("Failed to send message. Status: " + response.statusCode());
+ }
+ }
+
+ /**
+ * Uploads the given file to the Ntfy server by sending its bytes with an HTTP PUT to {@code baseUrl}/{@code topic}.
+ *
+ * @param baseUrl the base URL of the Ntfy server (may include or omit a trailing slash)
+ * @param topic the topic path segment to upload the file to
+ * @param file the file to upload
+ * @throws IOException if the file does not exist or the server responds with a non-200 status (response body included in the message)
+ * @throws InterruptedException if the HTTP request is interrupted
+ */
+ @Override
+ public void sendFile(String baseUrl, String topic, File file) throws IOException, InterruptedException {
+ if (!file.exists()) {
+ throw new IOException("File not found: " + file.getName());
+ }
+
+ byte[] fileBytes = Files.readAllBytes(file.toPath());
+ String url = ensureNoTrailingSlash(baseUrl) + "/" + topic;
+
+ HttpRequest request = HttpRequest.newBuilder()
+ .uri(URI.create(url))
+ .header("Filename", file.getName())
+ .PUT(HttpRequest.BodyPublishers.ofByteArray(fileBytes))
+ .build();
+
+ HttpResponse response = http.send(request, HttpResponse.BodyHandlers.ofString());
+
+ if (response.statusCode() != 200) {
+ String errorBody = response.body();
+ throw new IOException("Failed to send file. Status: " + response.statusCode() + ", Error: " + errorBody);
+ }
+
+ System.out.println("β
File uploaded successfully: " + file.getName());
+ }
+
+ /**
+ * Subscribes to a topic's JSON event stream and dispatches incoming "message" events to a consumer.
+ *
+ * @param baseUrl the base URL of the ntfy server (may include scheme and host)
+ * @param topic the topic to subscribe to
+ * @param onMessage consumer invoked for each received `NtfyMessage` whose `event` equals `"message"`
+ * @param onError consumer invoked for any error encountered while receiving or parsing events
+ * @return a Subscription that cancels the underlying request and stops delivery when invoked
+ */
+ @Override
+ public Subscription subscribe(String baseUrl, String topic, Consumer onMessage, Consumer onError) {
+ String url = ensureNoTrailingSlash(baseUrl) + "/" + topic + "/json";
+
+ HttpRequest request = HttpRequest.newBuilder()
+ .uri(URI.create(url))
+ .GET()
+ .build();
+
+ CompletableFuture future = http.sendAsync(request, HttpResponse.BodyHandlers.ofInputStream())
+ .thenAccept(response -> {
+ try (BufferedReader reader = new BufferedReader(new InputStreamReader(response.body()))) {
+ String line;
+ while ((line = reader.readLine()) != null) {
+ if (!line.trim().isEmpty()) {
+ NtfyMessage message = objectMapper.readValue(line.trim(), NtfyMessage.class);
+ if ("message".equals(message.event())) {
+ onMessage.accept(message);
+ }
+ }
+ }
+ } catch (Exception e) {
+ onError.accept(e);
+ }
+ })
+ .exceptionally(error -> {
+ onError.accept(error);
+ return null;
+ });
+
+ return () -> future.cancel(true);
+ }
+
+ /**
+ * Normalize a URL by removing a trailing slash if present.
+ *
+ * @param url the URL string to normalize; may end with a slash
+ * @return the URL without a trailing slash if one was present, otherwise the original URL
+ */
+ private String ensureNoTrailingSlash(String url) {
+ return url.endsWith("/") ? url.substring(0, url.length() - 1) : url;
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/example/util/EnvLoader.java b/src/main/java/com/example/util/EnvLoader.java
new file mode 100644
index 00000000..45c67cb1
--- /dev/null
+++ b/src/main/java/com/example/util/EnvLoader.java
@@ -0,0 +1,48 @@
+package com.example.util;
+
+import java.io.BufferedReader;
+import java.io.FileReader;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+
+public class EnvLoader {
+
+ /**
+ * Loads key=value pairs from a .env file in the current working directory into Java system properties.
+ *
+ * Each non-empty line that does not start with '#' and contains a single '=' is parsed; both key and
+ * value are trimmed before the key/value pair is stored with System.setProperty.
+ * If a .env file is not present, existing system properties are left unchanged.
+ */
+ public static void load() {
+ Path envPath = Paths.get(".env");
+
+ if (!Files.exists(envPath)) {
+ System.out.println("No .env file found, using system environment variables");
+ return;
+ }
+
+ try (BufferedReader reader = new BufferedReader(new FileReader(envPath.toFile()))) {
+ String line;
+ while ((line = reader.readLine()) != null) {
+ line = line.trim();
+
+ if (line.isEmpty() || line.startsWith("#")) {
+ continue;
+ }
+
+ String[] parts = line.split("=", 2);
+ if (parts.length == 2) {
+ String key = parts[0].trim();
+ String value = parts[1].trim();
+ System.setProperty(key, value);
+ System.out.println("Loaded env: " + key + "=" + value);
+ }
+ }
+ } catch (IOException e) {
+ System.err.println("Failed to load .env file: " + e.getMessage());
+ }
+ }
+}
\ No newline at end of file