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