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 @@ - - + + + + + - +