diff --git a/.gitignore b/.gitignore
index 6ac465db..244268f9 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,2 +1,3 @@
target/
/.idea/
+.env
diff --git a/pom.xml b/pom.xml
index c40f667e..1ce2ce2b 100644
--- a/pom.xml
+++ b/pom.xml
@@ -17,6 +17,27 @@
25
+
+ org.openjfx
+ javafx-controls
+ ${javafx.version}
+
+
+ org.openjfx
+ javafx-fxml
+ ${javafx.version}
+
+
+ io.github.cdimascio
+ dotenv-java
+ 3.2.0
+
+
+ tools.jackson.core
+ jackson-databind
+ 3.0.1
+
+
org.junit.jupiter
junit-jupiter
@@ -36,14 +57,28 @@
test
- org.openjfx
- javafx-controls
- ${javafx.version}
+ org.wiremock
+ wiremock
+ 4.0.0-beta.15
+ test
- org.openjfx
- javafx-fxml
- ${javafx.version}
+ org.awaitility
+ awaitility
+ 4.3.0
+ test
+
+
+ org.testfx
+ testfx-junit5
+ 4.0.16-alpha
+ test
+
+
+ org.slf4j
+ slf4j-simple
+ 2.0.12
+ test
@@ -53,9 +88,9 @@
javafx-maven-plugin
0.0.8
- com.example.HelloFX
+ com.example.ChatApplication
-
+
javafx
true
@@ -65,4 +100,4 @@
-
+
\ No newline at end of file
diff --git a/src/main/java/com/example/ChatApplication.java b/src/main/java/com/example/ChatApplication.java
new file mode 100644
index 00000000..9c41f7b9
--- /dev/null
+++ b/src/main/java/com/example/ChatApplication.java
@@ -0,0 +1,36 @@
+package com.example;
+
+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 ChatApplication extends Application {
+
+ @Override
+ public void start(Stage stage) throws IOException {
+
+ NtfyConnection ntfyService = new NtfyConnectionImpl();
+ ChatModel model = new ChatModel(ntfyService);
+
+ model.startReceiving();
+
+ FXMLLoader fxmlLoader = new FXMLLoader(
+ ChatApplication.class.getResource("chat-view.fxml"));
+
+ ChatController controller = new ChatController(model);
+ fxmlLoader.setController(controller);
+
+ Scene scene = new Scene(fxmlLoader.load(), 600, 400);
+ stage.setTitle("JavaFX Ntfy Chat App");
+ stage.setScene(scene);
+ stage.show();
+ }
+
+ public static void main(String[] args) {
+ launch();
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/example/ChatController.java b/src/main/java/com/example/ChatController.java
new file mode 100644
index 00000000..56e3eec1
--- /dev/null
+++ b/src/main/java/com/example/ChatController.java
@@ -0,0 +1,67 @@
+package com.example;
+
+import io.github.cdimascio.dotenv.Dotenv;
+import javafx.fxml.FXML;
+import javafx.scene.control.Button;
+import javafx.scene.control.Label;
+import javafx.scene.control.ListView;
+import javafx.scene.control.TextField;
+
+import java.time.Instant;
+import java.time.ZoneId;
+import java.time.format.DateTimeFormatter;
+import java.util.Objects;
+
+
+public class ChatController {
+
+ @FXML private ListView messageListView;
+ @FXML private TextField inputTextField;
+ @FXML private Button sendButton;
+
+ private final ChatModel model;
+
+ private final DateTimeFormatter timeFormatter =
+ DateTimeFormatter.ofPattern("HH:mm:ss").withZone(ZoneId.systemDefault());
+
+
+ public ChatController(ChatModel model) {
+ this.model = model;
+ }
+
+ @FXML
+ public void initialize() {
+ messageListView.setItems(model.getMessages());
+
+ messageListView.setCellFactory(lv -> new javafx.scene.control.ListCell() {
+ @Override
+ protected void updateItem(ChatMessage msg, boolean empty) {
+ super.updateItem(msg, empty);
+ if (empty || msg == null) {
+ setText(null);
+ } else {
+ String formattedTime = timeFormatter.format(Instant.ofEpochSecond(msg.timestamp()));
+ setText("[" + formattedTime + "] " + msg.content());
+ }
+ }
+ });
+
+
+ inputTextField.setOnAction(event -> sendMessageAction());
+ sendButton.setOnAction(event -> sendMessageAction());
+ }
+
+
+ private void sendMessageAction() {
+ String message = inputTextField.getText().trim();
+ if (!message.isEmpty()) {
+
+ model.sendMessage(message);
+
+
+ inputTextField.clear();
+ }
+ }
+
+
+}
diff --git a/src/main/java/com/example/ChatMessage.java b/src/main/java/com/example/ChatMessage.java
new file mode 100644
index 00000000..652975f1
--- /dev/null
+++ b/src/main/java/com/example/ChatMessage.java
@@ -0,0 +1,4 @@
+package com.example;
+
+public record ChatMessage(String content, long timestamp) {
+}
diff --git a/src/main/java/com/example/ChatModel.java b/src/main/java/com/example/ChatModel.java
new file mode 100644
index 00000000..c69dae26
--- /dev/null
+++ b/src/main/java/com/example/ChatModel.java
@@ -0,0 +1,31 @@
+package com.example;
+
+
+import javafx.application.Platform;
+import javafx.collections.FXCollections;
+import javafx.collections.ObservableList;
+
+public class ChatModel {
+ private final ObservableList messages = FXCollections.observableArrayList();
+
+ private final NtfyConnection ntfyConnection;
+
+ public ChatModel(NtfyConnection ntfyConnection) {
+ this.ntfyConnection = ntfyConnection;
+ }
+ public ObservableList getMessages() {
+ return messages;
+ }
+
+ public void sendMessage(String text) {
+ ntfyConnection.send(text);
+ }
+
+ public void startReceiving() {
+ ntfyConnection.receive(ntfyDto -> {
+ ChatMessage chatMsg = new ChatMessage(ntfyDto.message(), ntfyDto.time());
+
+ messages.add(chatMsg);
+ });
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/example/HelloController.java b/src/main/java/com/example/HelloController.java
deleted file mode 100644
index fdd160a0..00000000
--- a/src/main/java/com/example/HelloController.java
+++ /dev/null
@@ -1,22 +0,0 @@
-package com.example;
-
-import javafx.fxml.FXML;
-import javafx.scene.control.Label;
-
-/**
- * Controller layer: mediates between the view (FXML) and the model.
- */
-public class HelloController {
-
- private final HelloModel model = new HelloModel();
-
- @FXML
- private Label messageLabel;
-
- @FXML
- private void initialize() {
- if (messageLabel != null) {
- messageLabel.setText(model.getGreeting());
- }
- }
-}
diff --git a/src/main/java/com/example/HelloFX.java b/src/main/java/com/example/HelloFX.java
deleted file mode 100644
index 96bdc5ca..00000000
--- a/src/main/java/com/example/HelloFX.java
+++ /dev/null
@@ -1,25 +0,0 @@
-package com.example;
-
-import javafx.application.Application;
-import javafx.fxml.FXMLLoader;
-import javafx.scene.Parent;
-import javafx.scene.Scene;
-import javafx.stage.Stage;
-
-public class HelloFX extends Application {
-
- @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");
- stage.setScene(scene);
- stage.show();
- }
-
- 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
deleted file mode 100644
index 385cfd10..00000000
--- a/src/main/java/com/example/HelloModel.java
+++ /dev/null
@@ -1,15 +0,0 @@
-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.
- */
- public String getGreeting() {
- String javaVersion = System.getProperty("java.version");
- String javafxVersion = System.getProperty("javafx.version");
- return "Hello, JavaFX " + javafxVersion + ", running on Java " + javaVersion + ".";
- }
-}
diff --git a/src/main/java/com/example/NtfyConnection.java b/src/main/java/com/example/NtfyConnection.java
new file mode 100644
index 00000000..d26fecaa
--- /dev/null
+++ b/src/main/java/com/example/NtfyConnection.java
@@ -0,0 +1,9 @@
+package com.example;
+
+import java.util.concurrent.CompletableFuture;
+import java.util.function.Consumer;
+
+public interface NtfyConnection {
+ CompletableFuture send(String message);
+ void receive(Consumer messageHandler);
+}
\ No newline at end of file
diff --git a/src/main/java/com/example/NtfyConnectionImpl.java b/src/main/java/com/example/NtfyConnectionImpl.java
new file mode 100644
index 00000000..3dd8dd72
--- /dev/null
+++ b/src/main/java/com/example/NtfyConnectionImpl.java
@@ -0,0 +1,83 @@
+package com.example;
+
+import io.github.cdimascio.dotenv.Dotenv;
+import tools.jackson.databind.ObjectMapper;
+
+import java.net.URI;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.util.concurrent.CompletableFuture;
+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() {
+ String loadedHostName = null;
+ try {
+ Dotenv dotenv = Dotenv.load();
+ loadedHostName = dotenv.get("HOST_NAME");
+ } catch (Exception e) {
+ System.err.println("WARNING: Could not load .env file for HOST_NAME. Using fallback.");
+ }
+
+ this.hostName = (loadedHostName != null)
+ ? loadedHostName
+ : "http://localhost:8080";
+
+ if (this.hostName.equals("http://localhost:8080")) {
+ System.out.println("DEBUG: NtfyConnectionImpl running in test/fallback mode.");
+ }
+ }
+
+ public NtfyConnectionImpl(String hostName) {
+ this.hostName = hostName;
+ }
+
+ @Override
+ public CompletableFuture send(String message) {
+ HttpRequest httpRequest = HttpRequest.newBuilder()
+ .POST(HttpRequest.BodyPublishers.ofString(message))
+ .uri(URI.create(hostName + "/mytopic"))
+ .build();
+
+ return http.sendAsync(httpRequest, HttpResponse.BodyHandlers.discarding())
+ .thenAccept(response -> {
+ if (response.statusCode() >= 200 && response.statusCode() < 300) {
+ System.out.println("Message sent successfully.");
+ } else {
+ System.err.println("Error while sending: " + response.statusCode());
+ }
+ })
+ .exceptionally(e -> {
+ System.err.println("Network issue: " + e.getMessage());
+ return null;
+ });
+ }
+
+ @Override
+ public void receive(Consumer messageHandler) {
+ HttpRequest httpRequest = HttpRequest.newBuilder()
+ .GET()
+ .uri(URI.create(hostName + "/mytopic/json"))
+ .build();
+
+ http.sendAsync(httpRequest, HttpResponse.BodyHandlers.ofLines())
+ .thenAccept(response -> response.body()
+ .map(s -> {
+ try {
+ return mapper.readValue(s, NtfyMessageDto.class);
+ } catch (Exception e) {
+ System.err.println("Failed to parse message: " + e.getMessage());
+ return null;
+ }
+ })
+ .filter(messageDto -> messageDto != null && messageDto.event().equals("message"))
+ .peek(System.out::println)
+ .forEach(messageHandler));
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/example/NtfyMessageDto.java b/src/main/java/com/example/NtfyMessageDto.java
new file mode 100644
index 00000000..697cccac
--- /dev/null
+++ b/src/main/java/com/example/NtfyMessageDto.java
@@ -0,0 +1,7 @@
+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) {
+}
\ No newline at end of file
diff --git a/src/main/java/module-info.java b/src/main/java/module-info.java
index 71574a27..85e1995c 100644
--- a/src/main/java/module-info.java
+++ b/src/main/java/module-info.java
@@ -1,6 +1,10 @@
-module hellofx {
+module chatapp {
requires javafx.controls;
requires javafx.fxml;
+ requires com.fasterxml.jackson.annotation;
+ requires io.github.cdimascio.dotenv.java;
+ requires tools.jackson.databind;
+ requires java.net.http;
opens com.example to javafx.fxml;
exports com.example;
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..040d8d64
--- /dev/null
+++ b/src/main/resources/com/example/chat-style.css
@@ -0,0 +1,46 @@
+
+.root {
+ -bg-light: #e5e5e5;
+ -text-color: #363636;
+ -primary: #4c76c2;
+ -secondary: #a8c64b;
+ -neutral: #747474;
+ -accent: #5e6ec7;
+ -radius-box: 20px;
+}
+
+.root {
+ -fx-background-color: -bg-light;
+ -fx-font-family: "Arial";
+}
+
+#messageListView {
+ -fx-background-color: -neutral;
+ -fx-border-color: -primary;
+ -fx-border-width: 2px;
+ -fx-border-radius: -radius-box;
+ -fx-padding: 10px;
+ -fx-background-radius: -radius-box;
+}
+
+#inputTextField {
+ -fx-background-color: white;
+ -fx-text-fill: -text-color;
+ -fx-border-color: -primary;
+ -fx-border-width: 1px;
+ -fx-border-radius: -radius-box;
+ -fx-background-radius: -radius-box;
+ -fx-padding: 8px 15px;
+}
+
+.button {
+ -fx-font-size: 14px;
+ -fx-background-radius: -radius-box;
+ -fx-background-color: -primary;
+ -fx-text-fill: white;
+ -fx-padding: 8px 15px;
+}
+
+.button:hover {
+ -fx-background-color: derive(-primary, 20%);
+}
\ No newline at end of file
diff --git a/src/main/resources/com/example/chat-view.fxml b/src/main/resources/com/example/chat-view.fxml
new file mode 100644
index 00000000..8b27d939
--- /dev/null
+++ b/src/main/resources/com/example/chat-view.fxml
@@ -0,0 +1,30 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/main/resources/com/example/hello-view.fxml b/src/main/resources/com/example/hello-view.fxml
deleted file mode 100644
index 20a7dc82..00000000
--- a/src/main/resources/com/example/hello-view.fxml
+++ /dev/null
@@ -1,9 +0,0 @@
-
-
-
-
-
-
-
-
-
diff --git a/src/test/java/com/example/ChatModelTest.java b/src/test/java/com/example/ChatModelTest.java
new file mode 100644
index 00000000..d2c9920d
--- /dev/null
+++ b/src/test/java/com/example/ChatModelTest.java
@@ -0,0 +1,71 @@
+package com.example;
+
+import com.github.tomakehurst.wiremock.client.WireMock;
+import com.github.tomakehurst.wiremock.junit5.WireMockRuntimeInfo;
+import com.github.tomakehurst.wiremock.junit5.WireMockTest;
+import org.junit.jupiter.api.Test;
+import org.awaitility.Awaitility;
+import java.time.Duration;
+
+import static com.github.tomakehurst.wiremock.client.WireMock.*;
+import static org.assertj.core.api.Assertions.assertThat;
+
+@WireMockTest
+class ChatModelTest {
+
+ @Test
+ void sendMessageCallsConnectionWithMessagesToSend() {
+ var spy = new NtfyConnectionSpy();
+ var model = new ChatModel(spy);
+ String messageToSend = "Test Message 123";
+
+ model.sendMessage(messageToSend);
+
+ assertThat(spy.message).isEqualTo(messageToSend);
+ }
+
+ @Test
+ void sendMessageToFakeServer_viaNtfyConnectionImpl(WireMockRuntimeInfo wmRunTimeInfo) throws InterruptedException {
+ var con = new NtfyConnectionImpl("http://localhost:" + wmRunTimeInfo.getHttpPort());
+ var model = new ChatModel(con);
+ String messageToSend = "Test Message 123";
+
+ stubFor(post("/mytopic").willReturn(ok()));
+
+ model.sendMessage(messageToSend);
+
+ Thread.sleep(500);
+
+ WireMock.verify(postRequestedFor(urlEqualTo("/mytopic"))
+ .withRequestBody(matching(messageToSend)));
+ }
+
+
+ @Test
+ void checkReceivedMessagesAfterSendingAMessageToAFakeServer(WireMockRuntimeInfo wmRunTimeInfo) throws InterruptedException {
+ var conImp = new NtfyConnectionImpl("http://localhost:" + wmRunTimeInfo.getHttpPort());
+
+ String expectedMessage = "Async Data Received";
+ // Använd System.currentTimeMillis() om din ChatModel skickar detta;
+ // annars, använd ett statiskt värde för att vara säker.
+ long expectedTimestamp = System.currentTimeMillis();
+
+ stubFor(get("/mytopic/json")
+ .willReturn(aResponse()
+ .withHeader("Content-type", "application/json")
+ .withBody("{\"event\": \"message\",\"message\": \"" + expectedMessage + "\", \"time\": \"" + expectedTimestamp + "\"}")));
+
+ var model = new ChatModel(conImp);
+ model.startReceiving();
+
+ // Awaitility: Väntar aktivt tills listan fylls.
+ Awaitility.await()
+ .atMost(Duration.ofSeconds(10))
+ .pollInterval(Duration.ofMillis(100))
+ .until(() -> model.getMessages().size() > 0);
+
+ assertThat(model.getMessages()).isNotEmpty();
+ assertThat(model.getMessages().getLast().content()).isEqualTo(expectedMessage);
+ assertThat(model.getMessages().getLast().timestamp()).isEqualTo(expectedTimestamp);
+ }
+}
\ 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..8d11828b
--- /dev/null
+++ b/src/test/java/com/example/NtfyConnectionSpy.java
@@ -0,0 +1,28 @@
+package com.example;
+
+import java.util.concurrent.CompletableFuture;
+import java.util.function.Consumer;
+
+public class NtfyConnectionSpy implements NtfyConnection {
+
+ String message;
+ private Consumer messageHandler;
+
+
+ @Override
+ public CompletableFuture send(String message) {
+ this.message = message;
+ return CompletableFuture.completedFuture(null);
+ }
+
+ @Override
+ public void receive(Consumer messageHandler) {
+ this.messageHandler = messageHandler;
+ }
+
+
+ public void simulateIncomingMessage(NtfyMessageDto messageDto){
+ if (messageHandler != null)
+ messageHandler.accept(messageDto);
+ }
+}
\ No newline at end of file