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..1bbd4424 100644
--- a/pom.xml
+++ b/pom.xml
@@ -45,6 +45,28 @@
javafx-fxml
${javafx.version}
+
+ io.github.cdimascio
+ dotenv-java
+ 3.2.0
+
+
+ tools.jackson.core
+ jackson-databind
+ 3.0.1
+
+
+ org.wiremock
+ wiremock
+ 4.0.0-beta.15
+ test
+
+
+ org.awaitility
+ awaitility
+ 4.3.0
+ test
+
diff --git a/src/main/java/com/example/HelloController.java b/src/main/java/com/example/HelloController.java
index fdd160a0..7292a73d 100644
--- a/src/main/java/com/example/HelloController.java
+++ b/src/main/java/com/example/HelloController.java
@@ -1,14 +1,26 @@
package com.example;
+import javafx.event.ActionEvent;
import javafx.fxml.FXML;
-import javafx.scene.control.Label;
+import javafx.scene.control.*;
+import javafx.util.Callback;
+
/**
* 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
+ private ListView messageView;
+
+ @FXML
+ private TextArea chatArea;
+
+ @FXML
+ private Button chatButton;
@FXML
private Label messageLabel;
@@ -18,5 +30,34 @@ private void initialize() {
if (messageLabel != null) {
messageLabel.setText(model.getGreeting());
}
+ messageView.setItems(model.getMessages());
+ messageView.setCellFactory(showOnlyMessages());
+ }
+
+
+
+
+ private static Callback, ListCell> showOnlyMessages() {
+ return List -> new ListCell<>() {
+ @Override
+ protected void updateItem(NtfyMessageDto item, boolean empty) {
+ super.updateItem(item, empty);
+ if (empty || item == null) {
+ setText(null);
+ } else {
+ setText(item.message());
+ }
+ }
+ };
+ }
+
+ public void sendMessage(ActionEvent actionEvent) {
+ String input = chatArea.getText().trim();
+ if (!input.isEmpty()) {
+ model.setMessageToSend(input);
+ model.sendMessage();
+ chatArea.clear();
+ }
}
}
+
diff --git a/src/main/java/com/example/HelloFX.java b/src/main/java/com/example/HelloFX.java
index 96bdc5ca..f37f5cb9 100644
--- a/src/main/java/com/example/HelloFX.java
+++ b/src/main/java/com/example/HelloFX.java
@@ -10,6 +10,7 @@ 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);
diff --git a/src/main/java/com/example/HelloModel.java b/src/main/java/com/example/HelloModel.java
index 385cfd10..2b453655 100644
--- a/src/main/java/com/example/HelloModel.java
+++ b/src/main/java/com/example/HelloModel.java
@@ -1,9 +1,28 @@
package com.example;
+import javafx.application.Platform;
+import javafx.beans.property.SimpleStringProperty;
+import javafx.beans.property.StringProperty;
+import javafx.collections.FXCollections;
+import javafx.collections.ObservableList;
+
+import java.util.concurrent.CompletableFuture;
+
/**
* Model layer: encapsulates application data and business logic.
*/
public class HelloModel {
+
+ private final NtfyConnection connection;
+
+ private final ObservableList messages = FXCollections.observableArrayList();
+ private final StringProperty messageToSend = new SimpleStringProperty();
+
+ public HelloModel(NtfyConnection connection) {
+ this.connection = connection;
+ receiveMessage();
+ }
+
/**
* Returns a greeting based on the current Java and JavaFX versions.
*/
@@ -12,4 +31,40 @@ public String getGreeting() {
String javafxVersion = System.getProperty("javafx.version");
return "Hello, JavaFX " + javafxVersion + ", running on Java " + javaVersion + ".";
}
+ public ObservableList getMessages() {
+ return messages;
+ }
+
+ public String getMessageToSend() {
+ return messageToSend.get();
+ }
+
+ public StringProperty messageToSendProperty() {
+ return messageToSend;
+ }
+
+ public void setMessageToSend(String message) {
+ messageToSend.set(message);
+ }
+
+ public CompletableFuture sendMessage() {
+ return connection.send(messageToSend.get());
}
+
+ public void receiveMessage() {
+ connection.receive(m->runOnFx(()-> messages.add(m)));
+ }
+
+ private static void runOnFx(Runnable task) {
+ try {
+ if (Platform.isFxApplicationThread()) task.run();
+ else Platform.runLater(task);
+ } catch (IllegalStateException notInitialized) {
+ // JavaFX toolkit not initialized (e.g., unit tests): run inline
+ task.run();
+ }
+ }
+
+}
+
+
diff --git a/src/main/java/com/example/NtfyConnection.java b/src/main/java/com/example/NtfyConnection.java
new file mode 100644
index 00000000..55ee5b65
--- /dev/null
+++ b/src/main/java/com/example/NtfyConnection.java
@@ -0,0 +1,11 @@
+package com.example;
+
+import java.util.concurrent.CompletableFuture;
+import java.util.function.Consumer;
+
+public interface NtfyConnection {
+
+ CompletableFuture send(String message);
+
+ void receive(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..86c03571
--- /dev/null
+++ b/src/main/java/com/example/NtfyConnectionImpl.java
@@ -0,0 +1,67 @@
+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.Objects;
+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() {
+ Dotenv dotenv = Dotenv.load();
+ hostName = Objects.requireNonNull(dotenv.get("HOST_NAME"));
+ }
+
+ public NtfyConnectionImpl(String hostName) {
+ this.hostName = hostName;
+ }
+
+ @Override
+ public CompletableFuture send(String message) {
+ HttpRequest httpRequest = HttpRequest.newBuilder()
+ .POST(HttpRequest.BodyPublishers.ofString(message))
+ .header("Cache-Control", "no")
+ .uri(URI.create(hostName + "/mytopic"))
+ .build();
+
+ return http.sendAsync(httpRequest, HttpResponse.BodyHandlers.discarding())
+ .thenAccept(response -> System.out.println("Message sent!"))
+ .exceptionally(e -> {
+ System.out.println("Error sending message");
+ return null;
+ });
+ }
+
+ @Override
+ public void receive (Consumer < NtfyMessageDto > 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.out.println("Failed to parse message");
+ return null;
+ }
+ })
+ .filter(message -> message !=null && message.event().equals("message"))
+ .peek(System.out::println)
+ .forEach(messageHandler));
+ }
+ }
+
diff --git a/src/main/java/com/example/NtfyMessageDto.java b/src/main/java/com/example/NtfyMessageDto.java
new file mode 100644
index 00000000..418257c8
--- /dev/null
+++ b/src/main/java/com/example/NtfyMessageDto.java
@@ -0,0 +1,12 @@
+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..a2135d72 100644
--- a/src/main/resources/com/example/hello-view.fxml
+++ b/src/main/resources/com/example/hello-view.fxml
@@ -1,9 +1,25 @@
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/main/resources/css/style.css b/src/main/resources/css/style.css
new file mode 100644
index 00000000..db902cb4
--- /dev/null
+++ b/src/main/resources/css/style.css
@@ -0,0 +1,59 @@
+
+/*
+styles.css
+ */
+.root {
+ -fx-background-color:
+ linear-gradient(to bottom,
+ rgba(0,0,0,0.1) 0%,
+ rgba(0,0,0,0) 10%),
+ linear-gradient(to bottom,
+ #8b0000 30%,
+ #ffd700 100%);
+ -fx-background-insets: 0, 4;
+ -fx-background-radius: 0, 3;
+ -fx-effect: dropshadow(gaussian, rgba(0, 0, 0, 0.4), 10, 0.5, 0, 0)
+
+}
+.list-view {
+ -fx-background-color: transparent; /* Make background transparent */
+ -fx-background-insets: 0;
+ -fx-padding: 0;
+ -fx-border-color: transparent;
+ -fx-background-radius: 5;
+ -fx-border-radius: 5;
+}
+.list-cell{
+ -fx-background-color: transparent;
+ -fx-border-color: transparent;
+ -fx-control-inner-background: transparent;
+ -fx-font-size: 14;
+ -fx-font-weight: bold;
+ -fx-padding: 5 10 5 10;
+}
+.text-area{
+ -fx-control-inner-background: #FFFFFF;
+ -fx-padding: 5px 7px;
+ -fx-text-fill: black;
+ -fx-font-size: 14;
+
+ -fx-border-radius: 10;
+ -fx-background-radius: 10;
+ -fx-background-color: transparent;
+ -fx-effect: dropshadow(three-pass-box, rgba(0,0,0,0.2), 5, 0, 0, 0);
+}
+.text-area .content, .viewport {
+ -fx-background-radius: 8;
+ -fx-border-radius: 8;
+}
+.button{
+ -fx-background-color: #610202;
+ -fx-text-fill: white;
+ -fx-font-size: 12px;
+ -fx-padding: 10 15 10 15;
+ -fx-background-radius: 5px;
+ -fx-border-radius: 8px;
+}
+.button:hover{
+ -fx-background-color: #400101;
+}
\ No newline at end of file
diff --git a/src/test/java/com/example/HelloModelTest.java b/src/test/java/com/example/HelloModelTest.java
new file mode 100644
index 00000000..25897b5e
--- /dev/null
+++ b/src/test/java/com/example/HelloModelTest.java
@@ -0,0 +1,85 @@
+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.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.awaitility.Awaitility;
+
+import java.time.Duration;
+import java.util.concurrent.ExecutionException;
+
+import static com.github.tomakehurst.wiremock.client.WireMock.*;
+import static org.assertj.core.api.Assertions.assertThat;
+
+
+@WireMockTest
+class HelloModelTest {
+
+
+ @Test
+ @DisplayName("Given a model with messageToSend when calling sendMessage then send")
+ void sendMessageCallsConnectionWithMessagesToSend() {
+ //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 wmRunTimeInfo) throws InterruptedException, ExecutionException {
+ var con = new NtfyConnectionImpl("http://localhost:" + wmRunTimeInfo.getHttpPort());
+ var model = new HelloModel(con);
+ model.setMessageToSend("Hello World");
+ stubFor(post("/mytopic").willReturn(ok()));
+
+ var messageHolder = model.sendMessage();
+ messageHolder.get();
+
+ //Verify call made to server
+ WireMock.verify(postRequestedFor(urlEqualTo("/mytopic"))
+ .withRequestBody(matching("Hello World")));
+ }
+
+ @Test
+ void checkReceivedMessagesAfterSendingAMessageToAFakeServer(WireMockRuntimeInfo wmRunTimeInfo) {
+ // Arrange
+ var conImp = new NtfyConnectionImpl("http://localhost:" + wmRunTimeInfo.getHttpPort());
+
+ stubFor(get("/mytopic/json")
+ .willReturn(aResponse()
+ .withHeader("Content-type", "application/json")
+ .withBody("{\"event\": \"message\",\"message\": \"Hello World\", \"time\": \"12314244\"}")));
+
+ var model = new HelloModel(conImp);
+
+ Awaitility.await()
+ .atMost(Duration.ofSeconds(4))
+ .pollInterval(Duration.ofMillis(100))
+ .untilAsserted(() -> {
+ assertThat(model.getMessages()).isNotEmpty();
+ assertThat(model.getMessages().getLast().message()).isEqualTo("Hello World");
+ });
+ }
+
+ // Test that sends a fake message via record and verifies that the message appears in the observable list
+ @Test
+ void checkThatReceivedFakeMessageAppearInList() {
+ var spy = new NtfyConnectionSpy();
+ var model = new HelloModel(spy);
+
+ // Create a message through a record and send it to the list
+ var fakeMessage = new NtfyMessageDto("id1", 1746598362, "message", "fmtopic", "HallÄ");
+ spy.simulateIncomingMessage(fakeMessage);
+
+ // Verify that the message is in the list
+ assertThat(model.getMessages()).extracting(NtfyMessageDto::message).contains("HallÄ");
+
+ }
+}
\ 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..bd6ebf6f
--- /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);
+ }
+}