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/mvnw b/mvnw
old mode 100644
new mode 100755
diff --git a/pom.xml b/pom.xml
index c40f667e..507a44b4 100644
--- a/pom.xml
+++ b/pom.xml
@@ -45,6 +45,22 @@
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
+
diff --git a/src/main/java/com/example/HelloController.java b/src/main/java/com/example/HelloController.java
index fdd160a0..fe95c4c8 100644
--- a/src/main/java/com/example/HelloController.java
+++ b/src/main/java/com/example/HelloController.java
@@ -1,22 +1,52 @@
package com.example;
+import javafx.event.ActionEvent;
import javafx.fxml.FXML;
import javafx.scene.control.Label;
+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());
+
+ public ListView messageView;
@FXML
private Label messageLabel;
+ @FXML
+ private TextField inputField;
+
+ @FXML
+ private TextField topicField;
+
@FXML
private void initialize() {
if (messageLabel != null) {
messageLabel.setText(model.getGreeting());
}
+
+ inputField.textProperty().bindBidirectional(model.messageToSendProperty());
+
+ topicField.textProperty().bindBidirectional(model.topicProperty());
+
+ messageView.setItems(model.getMessages());
+
+ model.connectToTopic();
+ }
+
+ public void sendMessage(ActionEvent actionEvent) {
+ if (!inputField.getText().trim().isEmpty()) {
+ model.sendMessage();
+ inputField.clear();
+ }
+ }
+
+ public void connectToTopic(ActionEvent actionEvent) {
+ model.connectToTopic();
}
-}
+}
\ No newline at end of file
diff --git a/src/main/java/com/example/HelloFX.java b/src/main/java/com/example/HelloFX.java
index 96bdc5ca..4bd23ab4 100644
--- a/src/main/java/com/example/HelloFX.java
+++ b/src/main/java/com/example/HelloFX.java
@@ -12,8 +12,8 @@ public class HelloFX extends Application {
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");
+ Scene scene = new Scene(root, 740, 480);
+ stage.setTitle("JavaFX Chat App \uD83D\uDCAC");
stage.setScene(scene);
stage.show();
}
diff --git a/src/main/java/com/example/HelloModel.java b/src/main/java/com/example/HelloModel.java
index 385cfd10..d156bd1b 100644
--- a/src/main/java/com/example/HelloModel.java
+++ b/src/main/java/com/example/HelloModel.java
@@ -1,9 +1,54 @@
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;
+
/**
* 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();
+ private final StringProperty topic = new SimpleStringProperty("mytopic");
+
+ public HelloModel(NtfyConnection connection) {
+ this.connection = connection;
+ receiveMessage();
+ }
+
+ public ObservableList getMessages() {
+ return messages;
+ }
+
+ public StringProperty messageToSendProperty() {
+ return messageToSend;
+ }
+
+ public String getMessageToSend() {
+ return messageToSend.get();
+ }
+
+ public void setMessageToSend(String message) {
+ messageToSend.set(message);
+ }
+
+ public String getTopic() {
+ return topic.get();
+ }
+
+ public StringProperty topicProperty() {
+ return topic;
+ }
+
+ public void setTopic(String topic) {
+ this.topic.set(topic);
+ }
+
/**
* Returns a greeting based on the current Java and JavaFX versions.
*/
@@ -12,4 +57,17 @@ public String getGreeting() {
String javafxVersion = System.getProperty("javafx.version");
return "Hello, JavaFX " + javafxVersion + ", running on Java " + javaVersion + ".";
}
+
+ public void sendMessage() {
+ connection.send(topic.get(), messageToSend.get());
+ }
+
+ public void connectToTopic() {
+ messages.clear();
+ receiveMessage();
+ }
+
+ public void receiveMessage() {
+ connection.receive(topic.get(), m -> Platform.runLater(() -> 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..a0c64486
--- /dev/null
+++ b/src/main/java/com/example/NtfyConnection.java
@@ -0,0 +1,11 @@
+package com.example;
+
+import java.util.function.Consumer;
+
+public interface NtfyConnection {
+
+ public boolean send(String topic, String message);
+
+ public void receive(String topic, 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..495318d9
--- /dev/null
+++ b/src/main/java/com/example/NtfyConnectionImpl.java
@@ -0,0 +1,75 @@
+package com.example;
+
+import io.github.cdimascio.dotenv.Dotenv;
+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.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();
+ private CompletableFuture receiveTask;
+
+ public NtfyConnectionImpl() {
+ Dotenv dotenv = Dotenv.load();
+ hostName = Objects.requireNonNull(dotenv.get("HOST_NAME"));
+ }
+
+ public NtfyConnectionImpl(String hostName) {
+ this.hostName = hostName;
+ }
+
+ @Override
+ public boolean send(String topic, String message) {
+ HttpRequest httpRequest = HttpRequest.newBuilder()
+ .POST(HttpRequest.BodyPublishers.ofString(message))
+ .header("Cache", "no")
+ .uri(URI.create(hostName + "/" + topic))
+ .build();
+ try {
+ var response = http.send(httpRequest, HttpResponse.BodyHandlers.discarding());
+ return true;
+ } catch (IOException e) {
+ System.out.println("Error sending message");
+ } catch (InterruptedException e) {
+ System.out.println("Interrupted sending message");
+ Thread.currentThread().interrupt();
+ }
+ return false;
+ }
+
+ @Override
+ public synchronized void receive(String topic, Consumer messageHandler) {
+ if (receiveTask != null) {
+ receiveTask.cancel(true);
+ }
+ HttpRequest httpRequest = HttpRequest.newBuilder()
+ .GET()
+ .uri(URI.create(hostName + "/" + topic + "/json"))
+ .build();
+
+ receiveTask = 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(Objects::nonNull)
+ .filter(message -> "message".equals(message.event()))
+ .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..fc973013
--- /dev/null
+++ b/src/main/java/com/example/NtfyMessageDto.java
@@ -0,0 +1,13 @@
+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) {
+
+ @Override
+ public String toString() {
+ return 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..649be1ab 100644
--- a/src/main/java/module-info.java
+++ b/src/main/java/module-info.java
@@ -1,6 +1,9 @@
module hellofx {
requires javafx.controls;
requires javafx.fxml;
+ requires io.github.cdimascio.dotenv.java;
+ requires java.net.http;
+ requires tools.jackson.databind;
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..102ef1e8 100644
--- a/src/main/resources/com/example/hello-view.fxml
+++ b/src/main/resources/com/example/hello-view.fxml
@@ -1,9 +1,41 @@
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/test/java/com/example/HelloModelTest.java b/src/test/java/com/example/HelloModelTest.java
new file mode 100644
index 00000000..9044f57c
--- /dev/null
+++ b/src/test/java/com/example/HelloModelTest.java
@@ -0,0 +1,80 @@
+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 static com.github.tomakehurst.wiremock.client.WireMock.*;
+import static org.assertj.core.api.Assertions.assertThat;
+
+@WireMockTest
+class HelloModelTest {
+
+ @BeforeAll
+ static void initJavaFx() {
+ Platform.startup(() -> {
+ });
+ }
+
+ @Test
+ @DisplayName("GIVEN a model with messageToSend WHEN calling sendMessage THEN send method on connection should be called")
+ 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
+ @DisplayName("GIVEN a fake Ntfy server WHEN calling sendMessage THEN an HTTP POST request should be sent with correct body")
+ void sendMessageToFakeServer(WireMockRuntimeInfo wmRuntimeInfo) {
+ // Arrange - Given
+ var con = new NtfyConnectionImpl("http://localhost:" + wmRuntimeInfo.getHttpPort());
+ var model = new HelloModel(con);
+ model.setMessageToSend("Hello World");
+ stubFor(post("/mytopic").willReturn(ok()));
+ // Act - When
+ model.sendMessage();
+ // Assert - Then
+ verify(postRequestedFor(urlEqualTo("/mytopic"))
+ .withRequestBody(matching("Hello World")));
+ }
+
+ @Test
+ @DisplayName("GIVEN a stubbed connection WHEN receiving message THEN it should appear in the model's messages list")
+ void receiveMessageAddsMessagesToList() throws InterruptedException {
+ // Arrange - Given
+ var stub = new NtfyConnectionStub();
+ var model = new HelloModel(stub);
+ // Act - When
+ stub.simulateIncomingMessage(new NtfyMessageDto("1", System.currentTimeMillis(), "message", "mytopic", "Hello world"));
+ Thread.sleep(50);
+ // Assert - Then
+ assertThat(model.getMessages())
+ .extracting(NtfyMessageDto::message)
+ .containsExactly("Hello world");
+ }
+
+ @Test
+ @DisplayName("GIVEN a model with messages WHEN connecting to a new topic THEN old messages are cleared")
+ void connectToTopicClearsMessages() {
+ // Arrange - Given
+ var stub = new NtfyConnectionStub();
+ var model = new HelloModel(stub);
+ // Act - When
+ stub.simulateIncomingMessage(new NtfyMessageDto("1", System.currentTimeMillis(), "message", "mytopic", "Old message"));
+ assertThat(model.getMessages()).hasSize(1);
+ model.setTopic("newtopic");
+ model.connectToTopic();
+ // Assert - Then
+ assertThat(model.getMessages()).isEmpty();
+ }
+
+}
\ 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..ac43e1ed
--- /dev/null
+++ b/src/test/java/com/example/NtfyConnectionSpy.java
@@ -0,0 +1,21 @@
+package com.example;
+
+import java.util.function.Consumer;
+
+public class NtfyConnectionSpy implements NtfyConnection {
+
+ String message;
+ String topic;
+
+ @Override
+ public boolean send(String topic, String message) {
+ this.topic = topic;
+ this.message = message;
+ return true;
+ }
+
+ @Override
+ public void receive(String topic, Consumer messageHandler) {
+ }
+
+}
\ No newline at end of file
diff --git a/src/test/java/com/example/NtfyConnectionStub.java b/src/test/java/com/example/NtfyConnectionStub.java
new file mode 100644
index 00000000..1e2fdc92
--- /dev/null
+++ b/src/test/java/com/example/NtfyConnectionStub.java
@@ -0,0 +1,24 @@
+package com.example;
+
+import java.util.function.Consumer;
+
+public class NtfyConnectionStub implements NtfyConnection {
+
+ private Consumer messageHandler;
+
+ @Override
+ public boolean send(String topic, String message) {
+ return true;
+ }
+
+ @Override
+ public void receive(String topic, Consumer messageHandler) {
+ this.messageHandler = messageHandler;
+ }
+
+ public void simulateIncomingMessage(NtfyMessageDto message) {
+ if (messageHandler != null) {
+ messageHandler.accept(message);
+ }
+ }
+}