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..f410eea0 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 + @@ -55,7 +71,7 @@ com.example.HelloFX - + javafx true @@ -65,4 +81,4 @@ - + \ No newline at end of file diff --git a/src/main/java/com/example/FxUtils.java b/src/main/java/com/example/FxUtils.java new file mode 100644 index 00000000..c723c75d --- /dev/null +++ b/src/main/java/com/example/FxUtils.java @@ -0,0 +1,26 @@ +package com.example; + +import javafx.application.Platform; + +public class FxUtils { + + /** + * Runs a task on the JavaFX thread. + * If already on the thread, runs immediately. + * If not, schedules it to run later. + * If JavaFX is not initialized, runs on the current thread. + */ + + public static void runOnFx(Runnable task) { + try { + if (Platform.isFxApplicationThread()) { + task.run(); + } else { + Platform.runLater(task); + } + } catch (IllegalStateException notInitialized) { + //headless + task.run(); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/example/HelloController.java b/src/main/java/com/example/HelloController.java index fdd160a0..d5f14f05 100644 --- a/src/main/java/com/example/HelloController.java +++ b/src/main/java/com/example/HelloController.java @@ -1,22 +1,156 @@ package com.example; +import javafx.application.Platform; +import javafx.beans.binding.Bindings; +import javafx.event.ActionEvent; import javafx.fxml.FXML; -import javafx.scene.control.Label; +import javafx.geometry.Insets; +import javafx.geometry.Pos; +import javafx.scene.control.*; +import javafx.scene.layout.HBox; + +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; /** - * Controller layer: mediates between the view (FXML) and the model. + * Controller for the chat app. + *

+ * Connects the FXML view to the HelloModel. + * Handles sending messages, changing topics, and updating the UI. */ + public class HelloController { - private final HelloModel model = new HelloModel(); + private final HelloModel model = new HelloModel(new NtfyConnectionImpl()); + + @FXML + private Button sendButton; @FXML private Label messageLabel; + @FXML + private Label topicLabel; + + @FXML + private ListView messageView; + + @FXML + private TextArea messageInput; + + @FXML + private TextField topicInput; + + @FXML + private Button changeTopicButton; + + private final DateTimeFormatter timeFormatter = + DateTimeFormatter.ofPattern("HH:mm:ss") + .withZone(ZoneId.systemDefault()); + @FXML private void initialize() { - if (messageLabel != null) { - messageLabel.setText(model.getGreeting()); + messageLabel.setText(model.getGreeting()); + + Platform.runLater(() -> messageInput.requestFocus()); + + topicLabel.setText("/" + model.getCurrentTopic()); + model.currentTopicProperty().addListener((obs, oldVal, newVal) -> { + topicLabel.setText("/" + newVal); + }); + + messageView.setItems(model.getMessages()); + + messageInput.textProperty().bindBidirectional(model.messageToSendProperty()); + + sendButton.disableProperty().bind(Bindings.createBooleanBinding( + () -> { + String text = messageInput.getText(); + return text == null || text.trim().isEmpty(); + }, + messageInput.textProperty() + )); + + if (changeTopicButton != null) { + changeTopicButton.disableProperty().bind(Bindings.createBooleanBinding( + () -> { + String text = topicInput.getText(); + return text == null || text.trim().isEmpty(); + }, + topicInput.textProperty() + )); + } + + + messageView.setCellFactory(lv -> new ListCell<>() { + @Override + protected void updateItem(NtfyMessageDto msg, boolean empty) { + super.updateItem(msg, empty); + + if (empty || msg == null || msg.message() == null || msg.message().isBlank()) { + setText(null); + setGraphic(null); + } else { + // Skapa bubble-label + Label bubble = new Label(msg.message()); + bubble.setWrapText(true); + bubble.setMaxWidth(250); + bubble.setPadding(new Insets(10)); + bubble.getStyleClass().add("chat-bubble"); // Basstyle + + HBox container = new HBox(bubble); + container.setPadding(new Insets(5)); + + // Använd CSS-klasser för skickat/mottaget + if (model.getUserId().equals(msg.id())) { + bubble.getStyleClass().add("chat-bubble-sent"); + container.setAlignment(Pos.CENTER_RIGHT); + } else { + bubble.getStyleClass().add("chat-bubble-received"); + container.setAlignment(Pos.CENTER_LEFT); + } + + setText(null); + setGraphic(container); + } + } + }); + + + // Scrolla ner till senaste meddelandet + model.getMessages().addListener((javafx.collections.ListChangeListener) change -> { + Platform.runLater(() -> { + if (!messageView.getItems().isEmpty()) { + messageView.scrollTo(messageView.getItems().size() - 1); + } + }); + }); + } + + @FXML + private void sendMessage(ActionEvent actionEvent) { + model.sendMessageAsync(success -> { + if (success) { + Platform.runLater(() -> messageInput.clear()); + Platform.runLater(() -> messageInput.requestFocus()); + } else { + Platform.runLater(() -> { + Alert alert = new Alert(Alert.AlertType.ERROR); + alert.setTitle("Send Failed"); + alert.setHeaderText("Failed to send message"); + alert.setContentText("Could not send your message. Please try again."); + alert.showAndWait(); + }); + } + }); + } + + @FXML + private void changeTopic(ActionEvent actionEvent) { + String newTopic = topicInput.getText(); + if (newTopic != null && !newTopic.isBlank()) { + model.setCurrentTopic(newTopic); + topicInput.clear(); } } -} +} \ 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..cd6c9775 100644 --- a/src/main/java/com/example/HelloFX.java +++ b/src/main/java/com/example/HelloFX.java @@ -6,16 +6,30 @@ import javafx.scene.Scene; import javafx.stage.Stage; +import java.util.Objects; + +/** + * Main JavaFX application class for RuneChat. + *

+ * Loads the FXML view, applies the stylesheet, and starts the application window. + */ + 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"); + + Scene scene = new Scene(root, 768, 576); + stage.setTitle("RuneChat"); + + scene.getStylesheets().add(Objects.requireNonNull(HelloFX.class.getResource("style.css")).toExternalForm()); + stage.setScene(scene); stage.show(); + + } public static void main(String[] args) { diff --git a/src/main/java/com/example/HelloModel.java b/src/main/java/com/example/HelloModel.java index 385cfd10..fdf767a3 100644 --- a/src/main/java/com/example/HelloModel.java +++ b/src/main/java/com/example/HelloModel.java @@ -1,15 +1,109 @@ 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.function.Consumer; + +import static com.example.FxUtils.runOnFx; + /** - * Model layer: encapsulates application data and business logic. + * Model layer for the chatapp RuneChat. + *

+ * Manages messages, the current topic, and sending/receiving messages via NtfyConnection. */ + public class HelloModel { - /** - * Returns a greeting based on the current Java and JavaFX versions. - */ + + private final NtfyConnection connection; + private final ObservableList messages = FXCollections.observableArrayList(); + private final StringProperty messageToSend = new SimpleStringProperty(); + private final StringProperty currentTopic = new SimpleStringProperty(); + + public HelloModel(NtfyConnection connection) { + this.connection = connection; + this.currentTopic.set(connection.getCurrentTopic()); + receiveMessage(); + } + + 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 String getCurrentTopic() { + return currentTopic.get(); + } + + public StringProperty currentTopicProperty() { + return currentTopic; + } + + public void setCurrentTopic(String topic) { + if (topic != null && !topic.isBlank()) { + connection.setCurrentTopic(topic); + this.currentTopic.set(topic); + messages.clear(); + receiveMessage(); + } + } + + public String getUserId() { + return connection.getUserId(); + } + public String getGreeting() { - String javaVersion = System.getProperty("java.version"); - String javafxVersion = System.getProperty("javafx.version"); - return "Hello, JavaFX " + javafxVersion + ", running on Java " + javaVersion + "."; + return "RuneChat"; + } + + public boolean canSendMessage() { + String msg = messageToSend.get(); + return msg != null && !msg.isBlank(); + } + + public void sendMessageAsync(Consumer callback) { + String msg = messageToSend.get(); + if (msg == null || msg.isBlank()) { + System.out.println("Nothing to send!"); + callback.accept(false); + return; + } + + connection.send(msg, success -> { + if (success) { + runOnFx(() -> { + if (msg.equals(messageToSend.get())) { + messageToSend.set(""); + } + }); + callback.accept(true); + } else { + System.out.println("Failed to send message!"); + callback.accept(false); + } + }); } -} + + public void receiveMessage() { + connection.receive(m -> { + if (m == null || m.message() == null || m.message().isBlank()) return; + runOnFx(() -> messages.add(m)); + }); + } + + +} \ No newline at end of file diff --git a/src/main/java/com/example/NtfyConnection.java b/src/main/java/com/example/NtfyConnection.java new file mode 100644 index 00000000..c9ea2d90 --- /dev/null +++ b/src/main/java/com/example/NtfyConnection.java @@ -0,0 +1,34 @@ +package com.example; + +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; +import java.util.function.Consumer; + +/** + * Interface for sending and receiving messages via a notification service. + */ +public interface NtfyConnection { + + void send(String message, Consumer callback); + + /** + * Receives messages from the service. + * @param messageHandler called for each received message + */ + void receive(Consumer messageHandler); + + /** Returns the current topic. Default is "mytopic". */ + default String getCurrentTopic() { + return "mytopic"; + } + + /** Sets the current topic. Default does nothing. */ + default void setCurrentTopic(String topic) { + + } + + /** Returns the user ID. Default is "unknown". */ + default String getUserId() { + return "unknown"; + } +} \ 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..176c4fad --- /dev/null +++ b/src/main/java/com/example/NtfyConnectionImpl.java @@ -0,0 +1,109 @@ +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.function.Consumer; + +/** + * Implementation of NtfyConnection using HTTP for sending and receiving messages. + */ +public class NtfyConnectionImpl implements NtfyConnection { + + private final HttpClient http = HttpClient.newHttpClient(); + private final String hostName; + private final String userId; + private String currentTopic; + private final ObjectMapper mapper = new ObjectMapper(); + + /** Loads configuration from environment variables. */ + public NtfyConnectionImpl() { + Dotenv dotenv = Dotenv.load(); + this.hostName = Objects.requireNonNull(dotenv.get("HOST_NAME")); + this.userId = Objects.requireNonNull(dotenv.get("USER_ID"), "USER_ID"); + this.currentTopic = dotenv.get("DEFAULT_TOPIC", "mytopic"); + } + + /** Creates connection with a custom host, default user and topic. */ + public NtfyConnectionImpl(String hostName) { + this.hostName = hostName; + this.userId = "testuser"; + this.currentTopic = "mytopic"; + } + + /** Creates connection with custom host, user, and topic. */ + public NtfyConnectionImpl(String hostName, String userId, String topic) { + this.hostName = hostName; + this.userId = userId; + this.currentTopic = topic; + } + + /** Returns the user ID. */ + public String getUserId() { + return userId; + } + + /** Returns the current topic. */ + public String getCurrentTopic() { + return currentTopic; + } + + /** Sets the current topic. */ + public void setCurrentTopic(String topic) { + this.currentTopic = topic; + } + + /** + * Sends a message asynchronously to the current topic. + * @param message message to send + * @param callback called with true if successful, false otherwise + */ + @Override + public void send(String message, Consumer callback) { + HttpRequest httpRequest = HttpRequest.newBuilder() + .POST(HttpRequest.BodyPublishers.ofString(message)) + .header("Cache", "no") + .header("X-User-Id", userId) + .uri(URI.create(hostName + "/" + currentTopic)) + .build(); + + http.sendAsync(httpRequest, HttpResponse.BodyHandlers.discarding()) + .thenApply(response -> response.statusCode() / 100 == 2) + .exceptionally(ex -> { + System.out.println("Error sending message: " + ex.getMessage()); + return false; + }) + .thenAccept(callback); + } + + /** + * Receives messages from the current topic asynchronously. + * @param messageHandler called for each received message + */ + @Override + public void receive(Consumer messageHandler) { + HttpRequest httpRequest = HttpRequest.newBuilder() + .GET() + .uri(URI.create(hostName + "/" + currentTopic + "/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: " + e.getMessage()); + return null; + } + }) + .filter(Objects::nonNull) + .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..ce08bb5a --- /dev/null +++ b/src/main/java/com/example/NtfyMessageDto.java @@ -0,0 +1,10 @@ +package com.example; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; + +/** + * Data transfer object for messages from the Ntfy server. + */ +@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..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..2c9739ce 100644 --- a/src/main/resources/com/example/hello-view.fxml +++ b/src/main/resources/com/example/hello-view.fxml @@ -1,9 +1,49 @@ - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +