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/pom.xml b/pom.xml
index c40f667e..c4a936d6 100644
--- a/pom.xml
+++ b/pom.xml
@@ -45,6 +45,32 @@
javafx-fxml
${javafx.version}
+
+ io.github.cdimascio
+ dotenv-java
+ 3.2.0
+
+
+ tools.jackson.core
+ jackson-databind
+ 3.0.1
+
+
+ org.openjfx
+ javafx-swing
+ 26-ea+16
+
+
+ org.slf4j
+ slf4j-api
+ 2.0.17
+
+
+ org.wiremock
+ wiremock
+ 4.0.0-beta.15
+ test
+
diff --git a/src/main/java/com/example/HelloController.java b/src/main/java/com/example/HelloController.java
index fdd160a0..1296b769 100644
--- a/src/main/java/com/example/HelloController.java
+++ b/src/main/java/com/example/HelloController.java
@@ -1,22 +1,59 @@
package com.example;
+import javafx.event.ActionEvent;
import javafx.fxml.FXML;
import javafx.scene.control.Label;
+import javafx.scene.control.ListCell;
+import javafx.scene.control.ListView;
+import javafx.scene.control.TextField;
/**
* Controller layer: mediates between the view (FXML) and the model.
*/
public class HelloController {
- private final HelloModel model = new HelloModel();
+ private final HelloModel model = new HelloModel(new NtfyConnectionImpl());
+
+ @FXML
+ public ListView messageView;
@FXML
private Label messageLabel;
+ @FXML
+ private TextField messageInput;
+
@FXML
private void initialize() {
if (messageLabel != null) {
messageLabel.setText(model.getGreeting());
}
+ messageView.setItems(model.getMessages());
+
+ if (messageInput != null) {
+ messageInput.textProperty().bindBidirectional(model.messageToSendProperty());
+ }
+
+ messageView.setCellFactory(param -> new ListCell<>() {
+ @Override
+ protected void updateItem(NtfyMessageDto item, boolean empty) {
+ super.updateItem(item, empty);
+
+ if (empty || item == null) {
+ setText(null);
+ } else {
+ var time = java.time.Instant.ofEpochSecond(item.time())
+ .atZone(java.time.ZoneId.systemDefault())
+ .toLocalTime();
+ var formatter = java.time.format.DateTimeFormatter.ofPattern("HH:mm");
+ setText(time.format(formatter) + " " + item.message());
+ }
+ }
+ });
+ }
+
+
+ public void sendMessage(ActionEvent actionEvent) {
+ model.sendMessage();
}
}
diff --git a/src/main/java/com/example/HelloFX.java b/src/main/java/com/example/HelloFX.java
index 96bdc5ca..043215d7 100644
--- a/src/main/java/com/example/HelloFX.java
+++ b/src/main/java/com/example/HelloFX.java
@@ -1,5 +1,6 @@
package com.example;
+import io.github.cdimascio.dotenv.Dotenv;
import javafx.application.Application;
import javafx.fxml.FXMLLoader;
import javafx.scene.Parent;
@@ -10,6 +11,14 @@ public class HelloFX extends Application {
@Override
public void start(Stage stage) throws Exception {
+ Dotenv dotenv = Dotenv.configure()
+ .ignoreIfMissing()
+ .load();
+ String hostName = dotenv.get("HOST_NAME");
+ if (hostName != null) {
+ System.out.println("Connected to: " + hostName);
+ }
+
FXMLLoader fxmlLoader = new FXMLLoader(HelloFX.class.getResource("hello-view.fxml"));
Parent root = fxmlLoader.load();
Scene scene = new Scene(root, 640, 480);
diff --git a/src/main/java/com/example/HelloModel.java b/src/main/java/com/example/HelloModel.java
index 385cfd10..e2ba7cb7 100644
--- a/src/main/java/com/example/HelloModel.java
+++ b/src/main/java/com/example/HelloModel.java
@@ -1,9 +1,61 @@
package com.example;
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+import io.github.cdimascio.dotenv.Dotenv;
+import javafx.application.Platform;
+import javafx.beans.property.SimpleStringProperty;
+import javafx.beans.property.StringProperty;
+import javafx.collections.FXCollections;
+import javafx.collections.ObservableList;
+import tools.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.ArrayList;
+import java.util.Objects;
+import java.util.function.Consumer;
+
/**
* Model layer: encapsulates application data and business logic.
*/
public class HelloModel {
+
+ private final NtfyConnection connection;
+
+ private final Consumer uiExecutor;
+
+ private final ObservableList messages = FXCollections.observableArrayList();
+ private final StringProperty messageToSend = new SimpleStringProperty();
+
+ public HelloModel(NtfyConnection connection, Consumer uiExecutor) {
+ this.connection = connection;
+ this.uiExecutor = uiExecutor;
+ receiveMessage();
+ }
+
+ public HelloModel(NtfyConnection connection) {
+ this(connection, Platform::runLater);
+ }
+
+ public ObservableList getMessages() {
+ return messages;
+ }
+
+ public String getMessageToSend() {
+ return messageToSend.get();
+ }
+
+ public StringProperty messageToSendProperty() {
+ return messageToSend;
+ }
+
+ public void setMessageToSend(String message) {
+ messageToSend.set(message);
+ }
+
/**
* Returns a greeting based on the current Java and JavaFX versions.
*/
@@ -12,4 +64,15 @@ public String getGreeting() {
String javafxVersion = System.getProperty("javafx.version");
return "Hello, JavaFX " + javafxVersion + ", running on Java " + javaVersion + ".";
}
+ public void sendMessage() {
+ String msg = messageToSend.get();
+ if (msg != null && !msg.isBlank()) {
+ connection.sendMessage(msg);
+ }
+ }
+
+ public void receiveMessage() {
+ connection.receiveMessage(m -> uiExecutor.accept(() -> messages.add(m)));
+ }
}
+
diff --git a/src/main/java/com/example/NtfyConnection.java b/src/main/java/com/example/NtfyConnection.java
new file mode 100644
index 00000000..b0f3072b
--- /dev/null
+++ b/src/main/java/com/example/NtfyConnection.java
@@ -0,0 +1,10 @@
+package com.example;
+
+import java.util.function.Consumer;
+
+public interface NtfyConnection {
+
+ public boolean sendMessage(String message);
+
+ public void receiveMessage(Consumer messageHandler);
+}
diff --git a/src/main/java/com/example/NtfyConnectionImpl.java b/src/main/java/com/example/NtfyConnectionImpl.java
new file mode 100644
index 00000000..1ccb4b7b
--- /dev/null
+++ b/src/main/java/com/example/NtfyConnectionImpl.java
@@ -0,0 +1,95 @@
+package com.example;
+
+import io.github.cdimascio.dotenv.Dotenv;
+import javafx.application.Platform;
+import tools.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 http = HttpClient.newHttpClient();
+ private final String hostName;
+ private final ObjectMapper mapper = new ObjectMapper();
+
+ public NtfyConnectionImpl() {
+ Dotenv dotenv = Dotenv.load();
+ String envHost = dotenv.get("HOST_NAME");
+ if (envHost == null || envHost.isBlank()) {
+ throw new IllegalStateException("Environment variable 'HOST_NAME' is required but was not found.");
+ }
+ hostName = envHost;
+ }
+
+ public NtfyConnectionImpl(String hostName) {
+ this.hostName = hostName;
+ }
+
+ @Override
+ public boolean sendMessage(String message) {
+ //Todo: Send message using HTTPClient
+ HttpRequest httpRequest = HttpRequest.newBuilder()
+ .POST(HttpRequest.BodyPublishers.ofString(message))
+ .uri(URI.create(hostName + "/mytopic"))
+ .timeout(java.time.Duration.ofSeconds(5))
+ .build();
+ //Todo: handle long blocking send requests to not freeze the JavaFX thread
+ //1. Use thread send message?
+ //2. Use async?
+ var response = http.sendAsync(httpRequest, HttpResponse.BodyHandlers.ofString())
+ .orTimeout(5, java.util.concurrent.TimeUnit.SECONDS)
+ .join();
+ if (response.statusCode() != 200) {
+ System.err.println("Failed to send message. HTTP Status: " + response.statusCode());
+ System.err.println("Response body: " + response.body());
+ return false;
+ }
+ return true;
+ }
+
+
+ @Override
+ public void receiveMessage(Consumer messageHandler) {
+ HttpRequest httpRequest = HttpRequest.newBuilder()
+ .GET()
+ .uri(URI.create(hostName + "/mytopic/json"))
+ .build();
+
+ http.sendAsync(httpRequest, HttpResponse.BodyHandlers.ofLines())
+ .thenAccept(response -> {
+ if (response.statusCode() != 200) {
+ System.err.println("Error connecting to stream. HTTP Status: " + response.statusCode());
+ return;
+ }
+ response.body()
+ .map(s -> {
+ try {
+ return mapper.readValue(s, NtfyMessageDto.class);
+ } catch (Exception e) {
+ System.err.println("Failed to parse JSON: " + e.getMessage());
+ return null;
+ }
+ })
+ .filter(Objects::nonNull)
+ .filter(message -> "message".equals(message.event()))
+ .peek(m -> {
+ var time = java.time.Instant.ofEpochSecond(m.time())
+ .atZone(java.time.ZoneId.systemDefault())
+ .toLocalTime();
+ System.out.println(time + " " + m.message());
+ })
+ .forEach(messageHandler);
+ }).exceptionally(e -> {
+ System.err.println("Async connection error: " + e.getMessage());
+ e.printStackTrace();
+ return null;
+ });
+ }
+}
+
diff --git a/src/main/java/com/example/NtfyMessageDto.java b/src/main/java/com/example/NtfyMessageDto.java
new file mode 100644
index 00000000..d7a1682c
--- /dev/null
+++ b/src/main/java/com/example/NtfyMessageDto.java
@@ -0,0 +1,6 @@
+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){}
diff --git a/src/main/java/module-info.java b/src/main/java/module-info.java
index 71574a27..89e041cb 100644
--- a/src/main/java/module-info.java
+++ b/src/main/java/module-info.java
@@ -1,6 +1,10 @@
module hellofx {
requires javafx.controls;
requires javafx.fxml;
+ requires io.github.cdimascio.dotenv.java;
+ requires java.net.http;
+ requires tools.jackson.databind;
+ requires javafx.graphics;
opens com.example to javafx.fxml;
exports com.example;
diff --git a/src/main/resources/com/example/hello-view.fxml b/src/main/resources/com/example/hello-view.fxml
index 20a7dc82..dc20f177 100644
--- a/src/main/resources/com/example/hello-view.fxml
+++ b/src/main/resources/com/example/hello-view.fxml
@@ -2,8 +2,13 @@
-
-
+
+
+
+
+
-
-
+
+
+
+
diff --git a/src/test/java/com/example/HelloModelTest.java b/src/test/java/com/example/HelloModelTest.java
new file mode 100644
index 00000000..f0b49ded
--- /dev/null
+++ b/src/test/java/com/example/HelloModelTest.java
@@ -0,0 +1,103 @@
+package com.example;
+
+
+import com.github.tomakehurst.wiremock.junit5.WireMockRuntimeInfo;
+import com.github.tomakehurst.wiremock.junit5.WireMockTest;
+import javafx.application.Platform;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicReference;
+
+import static com.github.tomakehurst.wiremock.client.WireMock.*;
+import static org.assertj.core.api.Assertions.assertThat;
+
+@WireMockTest
+class HelloModelTest {
+
+ @Test
+ @DisplayName("When calling sendMessage it should call connection send")
+ void sendMessageCallsConnectionWithMessageToSend() {
+ //Arrange Given
+ var spy = new NtfyConnectionSpy();
+ var model = new HelloModel(spy);
+ model.setMessageToSend("Hello World");
+ //Act When
+ model.sendMessage();
+ //Assert Then
+ assertThat(spy.message()).isEqualTo("Hello World");
+ }
+
+ @Test
+ void sendMessageToFakeServer(WireMockRuntimeInfo wireMockRuntimeInfo) {
+ stubFor(get(urlPathMatching("/mytopic/json.*")).willReturn(ok()));
+
+ stubFor(post("/mytopic").willReturn(ok()));
+
+ var con = new NtfyConnectionImpl("http://localhost:" + wireMockRuntimeInfo.getHttpPort());
+ var model = new HelloModel(con);
+ model.setMessageToSend("Hello World");
+
+ model.sendMessage();
+
+ verify(postRequestedFor(urlEqualTo("/mytopic"))
+ .withRequestBody(containing("Hello World")));
+ }
+
+ @Test
+ @DisplayName("Real NtfyConnectionImpl should parse JSON correctly from WireMock")
+ void realConnectionParsesJson(WireMockRuntimeInfo wm) throws InterruptedException {
+ // Arrange
+ String jsonResponse = """
+ {"id":"123","time":1640995200,"event":"message","topic":"mytopic","message":"Hello from WireMock"}
+ """;
+
+ stubFor(get(urlEqualTo("/mytopic/json"))
+ .willReturn(aResponse()
+ .withStatus(200)
+ .withBody(jsonResponse)));
+
+ // Vi använder NtfyConnectionImpl direkt här för att isolera parsing-logiken från Modellen
+ var con = new NtfyConnectionImpl("http://localhost:" + wm.getHttpPort());
+
+ AtomicReference receivedDto = new AtomicReference<>();
+ CountDownLatch latch = new CountDownLatch(1);
+
+ // Act
+ con.receiveMessage(dto -> {
+ receivedDto.set(dto);
+ latch.countDown();
+ });
+
+ // Assert
+ boolean received = latch.await(5, TimeUnit.SECONDS);
+ assertThat(received).as("Did not receive message from stream").isTrue();
+
+ assertThat(receivedDto.get()).isNotNull();
+ assertThat(receivedDto.get().message()).isEqualTo("Hello from WireMock");
+ assertThat(receivedDto.get().time()).isEqualTo(1640995200L);
+ }
+
+ @Test
+ @DisplayName("Incoming message adds to list immediately")
+ void incomingMessageAddsToModelList_NoToolkit() {
+ // Arrange
+ var spy = new NtfyConnectionSpy();
+
+ // VIKTIGT: Skicka in Runnable::run för att köra synkront istället för Platform.runLater
+ var model = new HelloModel(spy, Runnable::run);
+
+ var incomingMessage = new NtfyMessageDto("1", 123456L, "message", "test", "Direct Test");
+
+ // Act
+ spy.triggerReceive(incomingMessage);
+
+ // Assert
+ // Eftersom vi kör synkront (Runnable::run) behöver vi ingen CountDownLatch eller wait
+ assertThat(model.getMessages()).hasSize(1);
+ assertThat(model.getMessages().getFirst().message()).isEqualTo("Direct Test");
+ }
+}
\ No newline at end of file
diff --git a/src/test/java/com/example/NtfyConnectionSpy.java b/src/test/java/com/example/NtfyConnectionSpy.java
new file mode 100644
index 00000000..f255c5b0
--- /dev/null
+++ b/src/test/java/com/example/NtfyConnectionSpy.java
@@ -0,0 +1,36 @@
+package com.example;
+
+import java.util.function.Consumer;
+
+public class NtfyConnectionSpy implements NtfyConnection {
+
+ private String message;
+ private boolean receiveCalled = false;
+ private Consumer lastHandler;
+
+ @Override
+ public boolean sendMessage(String message) {
+ this.message = message;
+ return true;
+ }
+
+ @Override
+ public void receiveMessage(Consumer messageHandler) {
+ this.receiveCalled = true;
+ this.lastHandler = messageHandler;
+ }
+
+ public String message() {
+ return message;
+ }
+
+ public boolean wasReceiveCalled() {
+ return receiveCalled;
+ }
+
+ public void triggerReceive(NtfyMessageDto dto) {
+ if (lastHandler != null) {
+ lastHandler.accept(dto);
+ }
+ }
+}