diff --git a/.gitignore b/.gitignore
index 6ac465db..5a54815d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,2 +1,3 @@
target/
/.idea/
+.env
\ No newline at end of file
diff --git a/HelloFX.iml b/HelloFX.iml
new file mode 100644
index 00000000..9fa68966
--- /dev/null
+++ b/HelloFX.iml
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/mvnw b/mvnw
old mode 100644
new mode 100755
diff --git a/pom.xml b/pom.xml
index c40f667e..1d194229 100644
--- a/pom.xml
+++ b/pom.xml
@@ -5,18 +5,30 @@
4.0.0
com.example
- javafx
+ HelloFX
1.0-SNAPSHOT
25
UTF-8
- 6.0.0
+ 5.10.0
3.27.6
5.20.0
25
+
+
+
+ org.openjfx
+ javafx-controls
+ ${javafx.version}
+
+
+ org.openjfx
+ javafx-fxml
+ ${javafx.version}
+
org.junit.jupiter
junit-jupiter
@@ -36,16 +48,33 @@
test
- org.openjfx
- javafx-controls
- ${javafx.version}
+ io.github.cdimascio
+ dotenv-java
+ 3.2.0
+
+
+ com.fasterxml.jackson.core
+ jackson-databind
+ 2.17.2
+
+
+ com.fasterxml.jackson.core
+ jackson-annotations
+ 2.17.2
+
+
+ org.wiremock
+ wiremock
+ 4.0.0-beta.15
+ test
org.openjfx
- javafx-fxml
+ javafx-swing
${javafx.version}
+
@@ -54,15 +83,28 @@
0.0.8
com.example.HelloFX
-
-
-
- javafx
- true
- true
- true
+
+
+
+ org.apache.maven.plugins
+ maven-compiler-plugin
+ 3.11.0
+
+ 25
+
+
+
+ org.apache.maven.plugins
+ maven-surefire-plugin
+ 3.1.2
+
+ false
+
+ **/*Test.java
+
+
diff --git a/src/main/java/com/example/HelloController.java b/src/main/java/com/example/HelloController.java
index fdd160a0..626b330c 100644
--- a/src/main/java/com/example/HelloController.java
+++ b/src/main/java/com/example/HelloController.java
@@ -1,22 +1,176 @@
package com.example;
+import javafx.application.Platform;
+import javafx.collections.ListChangeListener;
import javafx.fxml.FXML;
-import javafx.scene.control.Label;
+import javafx.geometry.Insets;
+import javafx.geometry.Pos;
+import javafx.scene.Scene;
+import javafx.scene.control.*;
+import javafx.scene.image.Image;
+import javafx.scene.image.ImageView;
+import javafx.scene.layout.HBox;
+import javafx.scene.layout.VBox;
+import javafx.stage.FileChooser;
+import javafx.stage.Stage;
+import javafx.stage.Window;
+
+import java.io.File;
+import java.time.Instant;
+import java.time.ZoneId;
-/**
- * Controller layer: mediates between the view (FXML) and the model.
- */
public class HelloController {
- private final HelloModel model = new HelloModel();
+ private HelloModel model;
+
+ @FXML private TextField messageField;
+ @FXML private VBox chatBox;
+ @FXML private ScrollPane chatScroll;
+ @FXML private Label statusLabel;
+
+ // Används av HelloFX för injection
+ public void setModel(HelloModel model) {
+ this.model = model;
+ attachListeners();
+ }
+
+ public void setConnection(NtfyConnection connection) {
+ if (connection == null) throw new IllegalArgumentException("Connection cannot be null");
+ this.model = new HelloModel(connection);
+ attachListeners();
+ }
+
+ private void attachListeners() {
+ model.getMessages().addListener((ListChangeListener) change -> {
+ Platform.runLater(() -> {
+ while (change.next()) {
+ if (change.wasAdded()) {
+ for (NtfyMessageDto msg : change.getAddedSubList()) {
+ boolean sentByUser = msg.clientId() != null && msg.clientId().equals(model.getClientId());
+
+ if (msg.imageUrl() != null && !msg.imageUrl().isBlank()) {
+ addImageBubbleFromUrl(msg.imageUrl(), sentByUser, msg.time());
+ } else if (msg.message() != null && !msg.message().isBlank()) {
+ addMessageBubble(msg.message(), sentByUser, msg.time());
+ }
+ }
+ }
+ }
+ });
+ });
+ }
- @FXML
- private Label messageLabel;
@FXML
private void initialize() {
- if (messageLabel != null) {
- messageLabel.setText(model.getGreeting());
+ if (messageField != null) {
+ messageField.setOnAction(e -> handleSend());
+ }
+
+ chatBox.heightProperty().addListener((obs, oldVal, newVal) -> chatScroll.setVvalue(1.0));
+ }
+
+ @FXML
+ private void handleSend() {
+ if (model == null) return;
+ String message = messageField.getText().trim();
+ if (!message.isEmpty()) {
+ model.sendMessage(message);
+ messageField.clear();
+ model.setMessageToSend(""); // Clear property for bound text fields
+ }
+ }
+
+ @FXML
+ private void handleSendImage() {
+ if (model == null) return;
+
+ FileChooser fileChooser = new FileChooser();
+ fileChooser.setTitle("Välj en bild att skicka");
+ fileChooser.getExtensionFilters().addAll(
+ new FileChooser.ExtensionFilter("Bildfiler", "*.png", "*.jpg", "*.jpeg", "*.gif")
+ );
+
+ Window window = messageField.getScene() != null ? messageField.getScene().getWindow() : null;
+ File imageFile = fileChooser.showOpenDialog(window);
+
+ if (imageFile != null) {
+ if (!imageFile.exists()) {
+ showStatus("Fil finns inte: " + imageFile.getName());
+ return;
+ }
+
+ boolean success = model.sendImage(imageFile);
+ if (!success) {
+ showStatus("Misslyckades att skicka bilden: " + imageFile.getName());
+ }
}
}
+
+ private void addMessageBubble(String text, boolean isSentByUser, long timestamp) {
+ if (text == null || text.isBlank()) return;
+
+ Label messageLabel = new Label(text);
+ messageLabel.setWrapText(true);
+ messageLabel.setMaxWidth(400);
+ messageLabel.getStyleClass().add(isSentByUser ? "sent-message" : "received-message");
+
+ String timeString = Instant.ofEpochSecond(timestamp)
+ .atZone(ZoneId.systemDefault())
+ .toLocalTime().toString().substring(0, 5);
+ Label timeLabel = new Label(timeString);
+ timeLabel.getStyleClass().add("timestamp");
+
+ VBox bubble = new VBox(messageLabel, timeLabel);
+ bubble.setAlignment(isSentByUser ? Pos.CENTER_RIGHT : Pos.CENTER_LEFT);
+
+ HBox container = new HBox(bubble);
+ container.setPadding(new Insets(5));
+ container.setAlignment(isSentByUser ? Pos.CENTER_RIGHT : Pos.CENTER_LEFT);
+
+ chatBox.getChildren().add(container);
+ }
+
+ private void addImageBubbleFromUrl(String url, boolean isSentByUser, long timestamp) {
+ if (url == null || url.isBlank()) return;
+
+ ImageView imageView = new ImageView(new Image(url, true));
+ imageView.setFitWidth(200);
+ imageView.setPreserveRatio(true);
+ imageView.setSmooth(true);
+
+ imageView.setOnMouseClicked(e -> {
+ Stage stage = new Stage();
+ ImageView bigView = new ImageView(new Image(url));
+ bigView.setPreserveRatio(true);
+ bigView.setFitWidth(800);
+
+ ScrollPane sp = new ScrollPane(bigView);
+ sp.setFitToWidth(true);
+
+ stage.setScene(new Scene(sp, 900, 700));
+ stage.setTitle("Bildvisning");
+ stage.show();
+ });
+
+ String timeString = Instant.ofEpochSecond(timestamp)
+ .atZone(ZoneId.systemDefault())
+ .toLocalTime().toString().substring(0, 5);
+ Label timeLabel = new Label(timeString);
+ timeLabel.getStyleClass().add("timestamp");
+
+ VBox box = new VBox(imageView, timeLabel);
+ box.setSpacing(3);
+ box.setAlignment(isSentByUser ? Pos.CENTER_RIGHT : Pos.CENTER_LEFT);
+
+ HBox wrapper = new HBox(box);
+ wrapper.setPadding(new Insets(5));
+ wrapper.setAlignment(isSentByUser ? Pos.CENTER_RIGHT : Pos.CENTER_LEFT);
+
+ chatBox.getChildren().add(wrapper);
+ }
+
+ public void showStatus(String text) {
+ Platform.runLater(() -> statusLabel.setText(text));
+ }
}
diff --git a/src/main/java/com/example/HelloFX.java b/src/main/java/com/example/HelloFX.java
index 96bdc5ca..2a666712 100644
--- a/src/main/java/com/example/HelloFX.java
+++ b/src/main/java/com/example/HelloFX.java
@@ -2,24 +2,78 @@
import javafx.application.Application;
import javafx.fxml.FXMLLoader;
-import javafx.scene.Parent;
import javafx.scene.Scene;
import javafx.stage.Stage;
+import io.github.cdimascio.dotenv.Dotenv;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
public class HelloFX extends Application {
+ private NtfyConnection connection;
+ private ImageServer imageServer;
+
@Override
- public void start(Stage stage) throws Exception {
+ public void start(Stage stage) {
+
+ Dotenv dotenv = Dotenv.load();
+ String hostName = dotenv.get("HOST_NAME");
+ if (hostName == null || hostName.isBlank()) {
+ showError("Configuration error", "HOST_NAME is not set in .env");
+ return;
+ }
+
+ connection = new NtfyConnectionImpl(hostName);
+
+ try {
+ Path imageDir = Path.of("images");
+ Files.createDirectories(imageDir);
+ imageServer = new ImageServer(8081, imageDir);
+ } catch (IOException e) {
+ showError("Image server error", "Could not start local image server:\n" + e.getMessage());
+ return;
+ }
+
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");
+ Scene scene;
+ try {
+ scene = new Scene(fxmlLoader.load(), 600, 400);
+ } catch (IOException e) {
+ showError("FXML loading error", "Could not load GUI:\n" + e.getMessage());
+ return;
+ }
+
+ HelloController controller = fxmlLoader.getController();
+ controller.setConnection(connection);
+
+ stage.setTitle("HelloFX Chat");
stage.setScene(scene);
stage.show();
+
+ stage.setOnCloseRequest(event -> {
+ System.out.println("🛑 Application closing...");
+
+ try {
+ if (connection != null) connection.stopReceiving();
+ } catch (Exception e) {
+ e.printStackTrace();
+ }
+
+ try {
+ if (imageServer != null) imageServer.stop();
+ } catch (Exception e) {
+ e.printStackTrace();
+ }
+ });
+ }
+
+ private void showError(String title, String message) {
+ System.err.println("❌ " + title + ": " + message);
}
public static void main(String[] args) {
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..36caaaa2 100644
--- a/src/main/java/com/example/HelloModel.java
+++ b/src/main/java/com/example/HelloModel.java
@@ -1,15 +1,185 @@
package com.example;
-/**
- * Model layer: encapsulates application data and business logic.
- */
+import javafx.application.Platform;
+import javafx.beans.property.SimpleStringProperty;
+import javafx.beans.property.StringProperty;
+import javafx.collections.FXCollections;
+import javafx.collections.ObservableList;
+
+import java.io.File;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.net.HttpURLConnection;
+import java.net.URL;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.util.UUID;
+
public class HelloModel {
+
+ private final NtfyConnection connection;
+ private final ObservableList messages = FXCollections.observableArrayList();
+ private final StringProperty messageToSend = new SimpleStringProperty();
+ private final String clientId = UUID.randomUUID().toString();
+ public static final String DEFAULT_TOPIC = "MartinsTopic";
+
+ private final boolean headless; // <-- För tester utan JavaFX
+
+ // Konstruktor för GUI
+ public HelloModel(NtfyConnection connection) {
+ this(connection, false);
+ }
+
+ // Konstruktor för headless eller GUI
+ public HelloModel(NtfyConnection connection, boolean headless) {
+ if (connection == null) throw new IllegalArgumentException("Connection cannot be null");
+ this.connection = connection;
+ this.headless = headless;
+ receiveMessages();
+ }
+
+ public String getClientId() {
+ return clientId;
+ }
+
+ public ObservableList getMessages() {
+ return messages;
+ }
+
+ public StringProperty messageToSendProperty() {
+ return messageToSend;
+ }
+
+ public void setMessageToSend(String message) {
+ messageToSend.set(message);
+ }
+
/**
- * Returns a greeting based on the current Java and JavaFX versions.
+ * Skickar ett textmeddelande via NtfyConnection och lägger till det i lokal lista.
*/
- public String getGreeting() {
- String javaVersion = System.getProperty("java.version");
- String javafxVersion = System.getProperty("javafx.version");
- return "Hello, JavaFX " + javafxVersion + ", running on Java " + javaVersion + ".";
+ public void sendMessage(String messageText) {
+ if (messageText == null || messageText.isBlank()) return;
+
+ connection.send(messageText); // plain text
+ addMessageSafely(new NtfyMessageDto(
+ UUID.randomUUID().toString(),
+ System.currentTimeMillis() / 1000,
+ "message",
+ DEFAULT_TOPIC,
+ messageText,
+ null,
+ null,
+ clientId
+ ));
+ messageToSend.set(""); // Clear bound property
}
+
+ /**
+ * Skickar en bildfil via lokal uppladdning och notifikation.
+ */
+ public boolean sendImage(File imageFile) {
+ if (imageFile == null || !imageFile.exists()) return false;
+ try {
+ String imageUrl = uploadToLocalServer(imageFile);
+
+ // Skicka bild-notis via Ntfy
+ connection.send("Bild: " + imageFile.getName() + "\n");
+
+ // Lägg till i lokal lista
+ addMessageSafely(new NtfyMessageDto(
+ UUID.randomUUID().toString(),
+ System.currentTimeMillis() / 1000,
+ "message",
+ DEFAULT_TOPIC,
+ null,
+ null,
+ imageUrl,
+ clientId
+ ));
+ return true;
+ } catch (IOException e) {
+ e.printStackTrace();
+ return false;
+ }
+ }
+
+ /**
+ * Startar mottagning av meddelanden från NtfyConnection.
+ */
+ private void receiveMessages() {
+ connection.receive(dto -> {
+ if (dto == null) return;
+
+ // Ignorera egna meddelanden
+ if (clientId.equals(dto.clientId())) return;
+
+ if (dto.imageUrl() != null) {
+ // Bild-meddelande
+ addMessageSafely(new NtfyMessageDto(
+ dto.id(),
+ dto.time(),
+ dto.event(),
+ dto.topic(),
+ null,
+ null,
+ dto.imageUrl(),
+ dto.clientId()
+ ));
+ } else {
+ // Text-meddelande
+ addMessageSafely(dto);
+ }
+ });
+ }
+
+ /**
+ * Lägger till meddelande i ObservableList på korrekt tråd.
+ * Headless: läggs direkt, annars via Platform.runLater om ej på FX-tråden.
+ */
+ private void addMessageSafely(NtfyMessageDto msg) {
+ if (headless) {
+ messages.add(msg);
+ } else if (Platform.isFxApplicationThread()) {
+ messages.add(msg);
+ } else {
+ Platform.runLater(() -> messages.add(msg));
+ }
+ }
+
+ /**
+ * Laddar upp en fil till lokal server och returnerar URL som sträng.
+ */
+ protected String uploadToLocalServer(File imageFile) throws IOException {
+ URL url = new URL("http://localhost:8081/upload");
+ HttpURLConnection conn = (HttpURLConnection) url.openConnection();
+ conn.setConnectTimeout(5_000);
+ conn.setReadTimeout(5_000);
+ conn.setDoOutput(true);
+ conn.setRequestMethod("POST");
+
+ // Bestäm MIME-typ
+ String contentType = Files.probeContentType(imageFile.toPath());
+ if (contentType == null || (!contentType.startsWith("image/"))) {
+ throw new IOException("Unsupported image type: " + contentType);
+ }
+ conn.setRequestProperty("Content-Type", contentType);
+
+ try (OutputStream os = conn.getOutputStream()) {
+ Files.copy(imageFile.toPath(), os);
+ }
+
+ int status = conn.getResponseCode();
+ if (status < 200 || status >= 300) {
+ conn.disconnect();
+ throw new IOException("Upload failed with HTTP status: " + status);
+ }
+
+ try (InputStream in = conn.getInputStream()) {
+ return new String(in.readAllBytes(), java.nio.charset.StandardCharsets.UTF_8);
+ } finally {
+ conn.disconnect();
+ }
+ }
+
}
diff --git a/src/main/java/com/example/ImageServer.java b/src/main/java/com/example/ImageServer.java
new file mode 100644
index 00000000..dff51d56
--- /dev/null
+++ b/src/main/java/com/example/ImageServer.java
@@ -0,0 +1,101 @@
+package com.example;
+
+import com.sun.net.httpserver.HttpServer;
+
+import java.io.IOException;
+import java.io.OutputStream;
+import java.net.InetSocketAddress;
+import java.nio.file.*;
+import java.util.UUID;
+
+public class ImageServer {
+
+ private final int port;
+ private final String baseUrl;
+ private final Path imageDir;
+ private HttpServer server;
+
+ public ImageServer(int port, Path imageDir) throws IOException {
+ this.port = port;
+ this.imageDir = imageDir;
+ if (!Files.exists(imageDir)) Files.createDirectories(imageDir);
+
+ this.baseUrl = "http://localhost:" + port;
+
+ startServer();
+ }
+
+ private void startServer() throws IOException {
+ server = HttpServer.create(new InetSocketAddress(port), 0);
+
+ // Upload endpoint
+ server.createContext("/upload", exchange -> {
+ try {
+ if (!"POST".equalsIgnoreCase(exchange.getRequestMethod())) {
+ exchange.sendResponseHeaders(405, -1); // Method Not Allowed
+ return;
+ }
+
+ // Läs Content-Type från header
+ String contentType = exchange.getRequestHeaders().getFirst("Content-Type");
+ String extension = getExtensionForType(contentType);
+
+ if (extension == null) {
+ exchange.sendResponseHeaders(415, -1); // Unsupported Media Type
+ return;
+ }
+
+ byte[] content = exchange.getRequestBody().readAllBytes();
+ String filename = UUID.randomUUID() + extension;
+ Path filePath = imageDir.resolve(filename);
+
+ Files.write(filePath, content, StandardOpenOption.CREATE);
+
+ // Returnera URL
+ String imageUrl = baseUrl + "/images/" + filename;
+ byte[] response = imageUrl.getBytes();
+ exchange.sendResponseHeaders(200, response.length);
+ try (OutputStream os = exchange.getResponseBody()) {
+ os.write(response);
+ }
+ } finally {
+ exchange.close();
+ }
+ });
+
+ // Serve images
+ server.createContext("/images", exchange -> {
+ try {
+ Path filePath = imageDir.resolve(exchange.getRequestURI().getPath().replace("/images/", "")).normalize();
+ if (Files.exists(filePath)) {
+ exchange.sendResponseHeaders(200, Files.size(filePath));
+ try (OutputStream os = exchange.getResponseBody()) {
+ Files.copy(filePath, os);
+ }
+ } else {
+ exchange.sendResponseHeaders(404, -1);
+ }
+ } finally {
+ exchange.close();
+ }
+ });
+
+ server.setExecutor(null);
+ server.start();
+ System.out.println("🖼️ Image server running at " + baseUrl);
+ }
+
+ public void stop() {
+ if (server != null) server.stop(0);
+ }
+
+ private String getExtensionForType(String contentType) {
+ if (contentType == null) return null;
+ return switch (contentType) {
+ case "image/jpeg" -> ".jpg";
+ case "image/png" -> ".png";
+ case "image/gif" -> ".gif";
+ default -> null;
+ };
+ }
+}
diff --git a/src/main/java/com/example/NtfyConnection.java b/src/main/java/com/example/NtfyConnection.java
new file mode 100644
index 00000000..04e164aa
--- /dev/null
+++ b/src/main/java/com/example/NtfyConnection.java
@@ -0,0 +1,39 @@
+package com.example;
+
+import java.io.File;
+import java.util.function.Consumer;
+
+public interface NtfyConnection {
+
+ /**
+ * Sends a message to ntfy. The message can be plain text or a JSON/Markdown string containing links or formatting.
+ *
+ * @param jsonMessage the message to send
+ */
+ void send(String jsonMessage);
+
+ /**
+ * Uploads an image and notifies the server.
+ *
+ * @param imageFile the image file to send
+ * @param clientId the identifier of the sending client
+ * @return true if the image was successfully uploaded and the notification sent, false otherwise (e.g., network error)
+ */
+ boolean sendImage(File imageFile, String clientId);
+
+ /**
+ * Registers a consumer to receive incoming messages from ntfy.
+ * The consumer is called for each incoming message.
+ *
+ * @param consumer the handler for incoming messages
+ * @implNote Only one consumer should be registered per connection. Calling receive multiple times may overwrite the previous consumer.
+ * @implNote Messages from the client itself (matching the same clientId) may be ignored depending on the implementation.
+ */
+ void receive(Consumer consumer);
+
+ /**
+ * Stops receiving messages. After calling this method, the consumer passed to {@link #receive(Consumer)} may no longer be called.
+ * Safe to call multiple times.
+ */
+ void stopReceiving();
+}
diff --git a/src/main/java/com/example/NtfyConnectionImpl.java b/src/main/java/com/example/NtfyConnectionImpl.java
new file mode 100644
index 00000000..0fcedaa6
--- /dev/null
+++ b/src/main/java/com/example/NtfyConnectionImpl.java
@@ -0,0 +1,93 @@
+package com.example;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+
+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.util.Objects;
+import java.util.function.Consumer;
+
+public class NtfyConnectionImpl implements NtfyConnection {
+
+ private final HttpClient client = HttpClient.newHttpClient();
+ private final String hostName;
+ private final ObjectMapper mapper = new ObjectMapper();
+ private volatile boolean running = true;
+ private Thread receiverThread;
+
+ public NtfyConnectionImpl(String hostName) {
+ if (hostName == null || hostName.isBlank()) {
+ throw new IllegalStateException("HOST_NAME is not configured for NtfyConnectionImpl");
+ }
+ this.hostName = hostName;
+ }
+
+ @Override
+ public void send(String message) {
+ try {
+ HttpRequest request = HttpRequest.newBuilder()
+ .uri(URI.create(hostName + "/" + HelloModel.DEFAULT_TOPIC))
+ .POST(HttpRequest.BodyPublishers.ofString(message))
+ .header("Content-Type", "text/plain; charset=utf-8")
+ .build();
+
+ HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString());
+ System.out.println("Sent message, status: " + response.statusCode());
+ } catch (IOException | InterruptedException e) {
+ e.printStackTrace();
+ }
+ }
+
+ @Override
+ public boolean sendImage(java.io.File imageFile, String clientId) {
+ throw new UnsupportedOperationException("sendImage is handled in HelloModel now.");
+ }
+
+ @Override
+ public void receive(Consumer messageHandler) {
+ running = true;
+ receiverThread = new Thread(() -> {
+ while (running) {
+ try {
+ HttpRequest request = HttpRequest.newBuilder()
+ .uri(URI.create(hostName + "/" + HelloModel.DEFAULT_TOPIC + "/json"))
+ .GET()
+ .build();
+
+ HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString());
+ String body = response.body();
+ for (String line : body.split("\\R")) {
+ line = line.trim();
+ if (line.isEmpty()) continue;
+ if (line.startsWith("data:")) line = line.substring(5).trim();
+
+ try {
+ NtfyMessageDto msg = mapper.readValue(line, NtfyMessageDto.class);
+ if ("message".equals(msg.event())) {
+ messageHandler.accept(msg);
+ }
+ } catch (Exception ignored) {}
+ }
+
+ Thread.sleep(1000);
+ } catch (IOException | InterruptedException e) {
+ if (!running) break;
+ try { Thread.sleep(2000); } catch (InterruptedException ex) { break; }
+ }
+ }
+ }, "NtfyReceiverThread");
+ receiverThread.setDaemon(true);
+ receiverThread.start();
+ }
+
+ @Override
+ public void stopReceiving() {
+ running = false;
+ if (receiverThread != null && receiverThread.isAlive()) {
+ receiverThread.interrupt();
+ }
+ }
+}
diff --git a/src/main/java/com/example/NtfyMessageDto.java b/src/main/java/com/example/NtfyMessageDto.java
new file mode 100644
index 00000000..abf86f42
--- /dev/null
+++ b/src/main/java/com/example/NtfyMessageDto.java
@@ -0,0 +1,24 @@
+package com.example;
+
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+
+@JsonIgnoreProperties(ignoreUnknown = true)
+public record NtfyMessageDto(
+ String id,
+ long time,
+ String event,
+ String topic,
+ String message,
+ Attachment attachment,
+ String imageUrl,
+ String clientId
+) {
+
+ @JsonIgnoreProperties(ignoreUnknown = true)
+ public static record Attachment(
+ String url,
+ String name,
+ String type,
+ long size
+ ) {}
+}
diff --git a/src/main/java/module-info.java b/src/main/java/module-info.java
index 71574a27..81d64672 100644
--- a/src/main/java/module-info.java
+++ b/src/main/java/module-info.java
@@ -1,7 +1,13 @@
-module hellofx {
+module HelloFX {
requires javafx.controls;
requires javafx.fxml;
+ requires io.github.cdimascio.dotenv.java;
+ requires java.net.http;
+ requires com.fasterxml.jackson.annotation;
+ requires com.fasterxml.jackson.databind;
+ requires jdk.httpserver;
+
+ opens com.example to javafx.fxml, com.fasterxml.jackson.databind;
- opens com.example to javafx.fxml;
exports com.example;
-}
\ No newline at end of file
+}
diff --git a/src/main/resources/com/example/chat-style.css b/src/main/resources/com/example/chat-style.css
new file mode 100644
index 00000000..3e8083f7
--- /dev/null
+++ b/src/main/resources/com/example/chat-style.css
@@ -0,0 +1,64 @@
+.root {
+ -fx-background-color: #F0F0F0;
+ -fx-font-family: "Segoe UI";
+}
+
+.sent-message {
+ -fx-background-color: #AEE6AA;
+ -fx-text-fill: #000000;
+ -fx-background-radius: 16 16 0 16;
+ -fx-font-size: 14px;
+ -fx-padding: 10px;
+ -fx-border-color: #98D59B;
+ -fx-border-width: 1px;
+ -fx-border-radius: 16 16 0 16;
+}
+
+.received-message {
+ -fx-background-color: #FFFFFF;
+ -fx-text-fill: #000000;
+ -fx-background-radius: 16 16 16 0;
+ -fx-font-size: 14px;
+ -fx-padding: 10px;
+ -fx-border-color: #E0E0E0;
+ -fx-border-width: 1px;
+ -fx-border-radius: 16 16 16 0;
+}
+
+.text-field {
+ -fx-background-radius: 10px;
+ -fx-padding: 5px;
+}
+
+.button {
+ -fx-background-radius: 10px;
+ -fx-cursor: hand;
+}
+
+
+.image-view {
+ -fx-effect: dropshadow(gaussian, rgba(0,0,0,0.2), 10, 0, 0, 2);
+ -fx-background-radius: 10px;
+ -fx-border-radius: 10px;
+ -fx-padding: 5px;
+ -fx-cursor: hand;
+}
+
+.scroll-pane {
+ -fx-background-color: transparent;
+ -fx-padding: 5px;
+}
+
+.hbox {
+ -fx-spacing: 5px;
+}
+
+.vbox {
+ -fx-spacing: 3px;
+}
+
+.timestamp {
+ -fx-font-size: 10px;
+ -fx-text-fill: #888888;
+ -fx-padding: 0 5 0 5;
+}
diff --git a/src/main/resources/com/example/hello-view.fxml b/src/main/resources/com/example/hello-view.fxml
index 20a7dc82..ada4c1e2 100644
--- a/src/main/resources/com/example/hello-view.fxml
+++ b/src/main/resources/com/example/hello-view.fxml
@@ -1,9 +1,31 @@
-
+
+
+
+
+
+
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/test/java/com/example/HelloModelTest.java b/src/test/java/com/example/HelloModelTest.java
new file mode 100644
index 00000000..41eab37b
--- /dev/null
+++ b/src/test/java/com/example/HelloModelTest.java
@@ -0,0 +1,112 @@
+package com.example;
+
+import com.github.tomakehurst.wiremock.junit5.WireMockRuntimeInfo;
+import com.github.tomakehurst.wiremock.junit5.WireMockTest;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import java.io.File;
+
+import static com.github.tomakehurst.wiremock.client.WireMock.*;
+import static org.assertj.core.api.Assertions.assertThat;
+
+@WireMockTest
+public class HelloModelTest {
+
+ // ---------------------------------------------------------------
+ // 1. Simple unit test – model logic with spy (headless)
+ // ---------------------------------------------------------------
+ @Test
+ @DisplayName("sendMessage calls connection.send(msg)")
+ void sendMessageCallsConnection() {
+ var spy = new NtfyConnectionSpy();
+ var model = new HelloModel(spy, true); // headless = true
+
+ model.setMessageToSend("Hello World");
+ model.sendMessage(model.messageToSendProperty().get());
+
+ assertThat(spy.lastSentMessage).isEqualTo("Hello World");
+ }
+
+ // ---------------------------------------------------------------
+ // 2. Receiving messages via spy
+ // ---------------------------------------------------------------
+ @Test
+ @DisplayName("Incoming text via spy is added to model messages")
+ void receiveMessagesWithSpy() {
+ var spy = new NtfyConnectionSpy();
+ var model = new HelloModel(spy, true);
+
+ spy.simulateIncomingMessage("Hello from test", null, "other-client");
+
+ assertThat(model.getMessages())
+ .extracting(NtfyMessageDto::message)
+ .contains("Hello from test");
+ }
+
+ @Test
+ @DisplayName("Incoming image via spy is added to model messages")
+ void receiveImageWithSpy() {
+ var spy = new NtfyConnectionSpy();
+ var model = new HelloModel(spy, true);
+
+ spy.simulateIncomingMessage(null, "http://example.com/test.png", "other-client");
+
+ assertThat(model.getMessages())
+ .extracting(NtfyMessageDto::imageUrl)
+ .contains("http://example.com/test.png");
+ }
+
+ // ---------------------------------------------------------------
+ // 3. WireMock test – sending text
+ // ---------------------------------------------------------------
+ @Test
+ @DisplayName("sendMessage sends POST request to WireMock server")
+ void sendMessageToWireMock(WireMockRuntimeInfo wireMockRuntimeInfo) {
+ stubFor(post("/" + HelloModel.DEFAULT_TOPIC).willReturn(ok()));
+
+ var con = new NtfyConnectionImpl("http://localhost:" + wireMockRuntimeInfo.getHttpPort());
+ var model = new HelloModel(con, true);
+
+ model.setMessageToSend("Hello WireMock");
+ model.sendMessage(model.messageToSendProperty().get());
+
+ verify(postRequestedFor(urlEqualTo("/" + HelloModel.DEFAULT_TOPIC))
+ .withRequestBody(matching("Hello WireMock")));
+ }
+
+ // ---------------------------------------------------------------
+ // 4. Integration test: upload image + send notification → verify with WireMock
+ // ---------------------------------------------------------------
+ @Test
+ @DisplayName("sendImage uploads file and sends URL via WireMock server")
+ void sendImageIntegratesWithWireMock(WireMockRuntimeInfo wireMockRuntimeInfo) throws Exception {
+ String baseUrl = "http://localhost:" + wireMockRuntimeInfo.getHttpPort();
+
+ // Stub for upload endpoint
+ stubFor(post("/upload").willReturn(ok(baseUrl + "/images/test.png")));
+
+ // Stub for notification endpoint
+ stubFor(post("/MartinsTopic").willReturn(ok()));
+
+ var con = new NtfyConnectionImpl(baseUrl);
+
+ // Override uploadToLocalServer for test → headless
+ HelloModel model = new HelloModel(con, true) {
+ @Override
+ protected String uploadToLocalServer(File imageFile) {
+ return baseUrl + "/images/" + imageFile.getName();
+ }
+ };
+
+ File testFile = File.createTempFile("test", ".png");
+ testFile.deleteOnExit();
+
+ boolean result = model.sendImage(testFile);
+ assertThat(result).isTrue();
+
+ // Verify that notification POST contains the image URL in Markdown format
+ verify(postRequestedFor(urlEqualTo("/MartinsTopic"))
+ .withRequestBody(matching(".*!\\[Bild\\]\\(.*" + testFile.getName() + "\\).*")));
+ }
+}
diff --git a/src/test/java/com/example/NtfyConnectionSpy.java b/src/test/java/com/example/NtfyConnectionSpy.java
new file mode 100644
index 00000000..3e88797f
--- /dev/null
+++ b/src/test/java/com/example/NtfyConnectionSpy.java
@@ -0,0 +1,60 @@
+package com.example;
+
+import java.io.File;
+import java.util.function.Consumer;
+
+/**
+ * Test-double för HelloModel.
+ * Simulerar skickande och mottagning av meddelanden utan HTTP.
+ */
+public class NtfyConnectionSpy implements NtfyConnection {
+
+ public String lastSentMessage;
+ public File lastSentImage;
+ public String lastClientId;
+ public Consumer messageHandler;
+
+ @Override
+ public void send(String message) {
+ this.lastSentMessage = message;
+ this.lastClientId = null; // Plain text, ingen clientId
+ System.out.println("🧪 Spy send(): " + message);
+ }
+
+ @Override
+ public boolean sendImage(File imageFile, String clientId) {
+ this.lastSentImage = imageFile;
+ this.lastClientId = clientId;
+ System.out.println("🧪 Spy sendImage(): " + imageFile.getName() + " | clientId=" + clientId);
+ return true;
+ }
+
+ @Override
+ public void receive(Consumer messageHandler) {
+ this.messageHandler = messageHandler;
+ System.out.println("🧪 Spy receive() handler registered");
+ }
+
+ @Override
+ public void stopReceiving() { }
+
+ /**
+ * Simulerar inkommande meddelande.
+ */
+ public void simulateIncomingMessage(String message, String imageUrl, String clientId) {
+ if (messageHandler != null) {
+ long now = System.currentTimeMillis() / 1000;
+ NtfyMessageDto dto = new NtfyMessageDto(
+ "test-id",
+ now,
+ "message",
+ HelloModel.DEFAULT_TOPIC,
+ message,
+ null,
+ imageUrl,
+ clientId
+ );
+ messageHandler.accept(dto);
+ }
+ }
+}